Malauch’s Swift Notes

Auto Resizable Text Size In SwiftUI

When working on watchOS app I wanted to display text as big as possible for given frame. Additionally, I wanted to achieve this on every Apple Watch screen size without hardcoding any values. Doing this for one Text view is a little strange but simple in SwiftUI. However "syncing" the same dynamic font size across multiple text views and preserving layout was an interesting challange.

Filling whole frame with text

Before presenting what works, let me show you what doesn't work.

Example1: adjusting frame

Doesn't work

Setting frame for a Text view changes the view size, but not the font size.

struct Example1: View {
	@State private var frameWidth: CGFloat = 175
	@State private var frameHeight: CGFloat = 175
	
	var body: some View {
		FrameAdjustingContainer(frameWidth: $frameWidth, frameHeight: $frameHeight) {
			Text("text")
				.frame(width: frameWidth, height: frameHeight)
				.border(Color.blue, width: 2)
		}
		
	}
}

struct FrameAdjustingContainer<Content: View>: View {
	@Binding var frameWidth: CGFloat
	@Binding var frameHeight: CGFloat
	let content: () -> Content
	
	var body: some View  {
		ZStack {
			content()
				.frame(width: frameWidth, height: frameHeight)
				.border(Color.red, width: 1)
			
			VStack {
				Spacer()
				Slider(value: $frameWidth, in: 50...300)
				Slider(value: $frameHeight, in: 50...600)
			}
			.padding()
		}
	}
}

Please note, that I wrapped Text with FrameAdjustingContainer helper container, which provides basic UI for adjusting frame of the Text view or parent VStack. I will use this wrapper for the rest of code examples in this article.

How it looks in action

Example1: adjusting frame - doesn't work

Example2: resizable and scaledToFit

Doesn't work.

Taking the same approach as with Image view, doesn't work either. Text doesn't have resizable modifier and scaledToFit does nothing.

struct Example2: View {
	@State private var frameWidth: CGFloat = 175
	@State private var frameHeight: CGFloat = 175
	
	var body: some View {
		FrameAdjustingContainer(frameWidth: $frameWidth, frameHeight: $frameHeight) {
			Text("text")
//				.resizable()  // Text view doesn't have modifier like this
				.scaledToFit()
				.border(Color.blue, width: 1)
		}
	}
}

How it looks in action

Example2: resizable and scaledToFit - doesn't work

Example3: resize with scaleEffect

It works!

We can achieve our goal by simple scaling proportionally Text view with scaleEffect modifier. However it resize as bitmap, not vector font shape. To get crisp text, we have to scale down the text view. Therefore, first we need to render it with bigger font and then scale that down.

struct Example3: View {
	@State private var frameWidth: CGFloat = 175
	@State private var frameHeight: CGFloat = 175
	@State private var textSize = CGSize(width: 200, height: 100)
	
	var body: some View {
		FrameAdjustingContainer(frameWidth: $frameWidth, frameHeight: $frameHeight) {
			Text("text")
				.font(.system(size: 300))  // Bigger font size then final rendering
				.fixedSize() // Prevents text truncating
				.background(
					GeometryReader { (geo) -> Color in
						DispatchQueue.main.async {  // hack for modifying state during view rendering.
							textSize = geo.size
						}
						return Color.clear
					}
				)
				.border(Color.blue, width: 1)
				.scaleEffect(min(frameWidth / textSize.width, frameHeight / textSize.height))  // making view smaller to fit the frame.
		}
	}
}

As you can see above it's multi-step process: 1. Render font bigger than final size. 2. fixedSize to prevents text truncating. 3. GeometryReader as background view to read view size. 4. DispatchQueue.main.async as a workaround for modifying state during view rendering. 5. Save view size to property. 6. Scale Text view down with scaleEffect. Determine scale factor using min for comparing scale factor calculated from width and height to makes sure that view will fit inside the provided parent frame.

This process has 3 major problem: - Scaling is not perfect (more about it below). - It depends on hacky way to modify state during view rendering. - Xcode preview shows text before scaling down.

How it looks in action

Example3: scaling with scaleEffect. It works, but please notice that static preview is broken.

Example4: resize with minimumScaleFactor

It works and is best!

SwiftUI gives us special modifier for situation like this - minimumScaleFactor(_:). We use it similar to scaleEffect. First we need to render text bigger and then apply miniumScaleFactor(_:). Then text view will be resized to fit the parent frame. However it will never be bigger than original font size and never smaller than font size × minimum scale factor.

struct Example4: View {
	@State private var frameWidth: CGFloat = 175
	@State private var frameHeight: CGFloat = 175
	
	var body: some View {
		FrameAdjustingContainer(frameWidth: $frameWidth, frameHeight: $frameHeight) {
			Text("text")
				.font(.system(size: 300))  // 1
				.minimumScaleFactor(0.01)  // 2
//				.lineLimit(1)  // 3
				.border(Color.blue, width: 1)
		}
	}
}
  1. Original font size bigger than expected final size.
  2. To make view most flexible, minimum scale factor is set to very small value.
  3. lineLimit(_:) is not used.

When using minimumScaleFactor(_:) all rules for breaking lines and words are preserved. For situation when you want to preserve text as 1 line, you can be tempted to use lineLimit(1) modifier. Unfortunately, it will cause bug in text resizing. It will work when width is limiting dimension but height will be ignored. Without lineLimit(1) it works as expected for both dimension. What is interesting, lineLimit(2) works as expected - limits line breaking for maximum 2 lines and resizing by both dimensions. I have found workaround for problem with lineLimit(1). It's presented later in layout examples.

How it looks in action

Example4 without .lineLimit. It works, however text can break for multiple lines
Example4 with .lineLimit. It resize text only when width is limiting dimension.

minimumScaleFactor vs. scaleEffect

What is special about minimumScaleFactor(_:) that it works on font-size, not rendered image. Therefore, text is scaled properly, as specified in font. Depends on a font and resize amount difference between minimumScaleFactor(_:) and scaleEffect can be big or tiny.

Image of difference when resizing with scaleEffect vs. minimumScaleFactor
Example of difference in final text. Red one was scaled by scaleEffect, black one with minimumScaleFactor

Layout

Mock layout

Let's try to use new knowledge to create fancy text layout. Goal is to make 3 rows of text: - middle row takes full width of provided space by parent view, - first row is shorter and aligned to the leading edge, - third one is the same length as first one but aligned to trailing edge.

As always with layout, it is good to start with simple rectangles and see how it looks and behave.

struct Layout1: View {
	@State private var frameWidth: CGFloat = 250
	@State private var frameHeight: CGFloat = 250
	
	var body: some View {
		FrameAdjustingContainer(frameWidth: $frameWidth, frameHeight: $frameHeight) {
			VStack {
				HStack {
					Color.gray
						.frame(width: frameWidth * 0.6)
					Spacer()
				}
				.border(Color.blue, width: 2)
				
				Color.gray
					.border(Color.blue, width: 1)
				
				HStack {
					Spacer()
					Color.gray
						.frame(width: frameWidth * 0.6)
				}
				.border(Color.blue, width: 2)
			}
			.border(Color.green, width: 1)
		}
	}
}
Mock layout

The same mock with text

We can take the mock layout from above and swap Color views with resizable text.

struct Layout2: View {
	@State private var frameWidth: CGFloat = 250
	@State private var frameHeight: CGFloat = 250
	
	var body: some View {
		FrameAdjustingContainer(frameWidth: $frameWidth, frameHeight: $frameHeight) {
			VStack {
				HStack {
					Text("01:")
					Spacer()
				}
				.border(Color.blue, width: 1)
				
				Text("12:34")
					.border(Color.blue, width: 1)
				
				HStack {
					Spacer()
					Text(".56")
				}
				.border(Color.blue, width: 1)
			}
			.border(Color.green, width: 1)
			.font(.system(size: 200, weight: .regular , design: .monospaced))
			.minimumScaleFactor(0.01)
		}
	}
}

Notice that font and minimumScaleFactor modifiers are environment modifiers. We can apply them for containers and all views inside will share the same value.

The same structure as before, but with resizable Text views. It's resizing but not as we want to.

Results

Text is resizing but it is not what we wanted to achieve. We hit these problems: 1. When height is limiting factor, then 1st and 3rd rows are wider then 2nd one (not aligned). 2. When width is limiting factor, then 1st and 3rd rows has much bigger font (different view aspect ratio). 3. When container is tall and narrow, then text is breaking in multiple lines.

To fix first problem we have to limit frame size for 1st and 3rd row.

To solve second and third roadblocks, we can think about reading font with transformEnvironment for 2nd row and apply the same font for the rest of rows. However, this approach doesn't work.

transformEnvironment reads value from environment. We can access original font size of 300 and minimum scale factor of 0.01. Final scaled down font size is not saved in environment. Therefore, we can get the same font size for all rows, only when all rows will have the same frame size.

Final layout

View preference helpers

To solve all problem mentioned below we need to retrieve view size of second row. There are two common way to do this. Both using geometry reader as a background view. However we can read and save the value directly or use view preferences to send the values up through the view tree structure. In this example I use view preference, as it seems to be more official way to do things. To save and retrieve preferences I will use these helpers:

public struct BoundsPreferenceData<Id> where Id: ViewId {
	let viewId: Id
	let bounds: CGRect
}

extension BoundsPreferenceData: Hashable {
	public func hash(into hasher: inout Hasher) {
		hasher.combine(viewId)
	}
}

public struct BoundsPreferenceKey<Id>: PreferenceKey where Id: ViewId{
	public static var defaultValue: Set<BoundsPreferenceData<Id>> { Set<BoundsPreferenceData<Id>>() }
	
	public static func reduce(value: inout Set<BoundsPreferenceData<Id>>, nextValue: () -> Set<BoundsPreferenceData<Id>>) {
		let nextValue = nextValue()
		value.formUnion(nextValue)
	}
}

extension View {
	public func getBounds<Id>(viewId: Id, coordinates: CoordinateSpace = .global, rect: Binding<CGRect>) -> some View where Id: ViewId {
		self
			.background(
				GeometryReader { geo in
					Color.clear.preference(key: BoundsPreferenceKey.self, value: [BoundsPreferenceData(viewId: viewId, bounds: geo.frame(in: coordinates))])
				}
			)
			.onPreferenceChange(BoundsPreferenceKey<Id>.self, perform: { value in
				
				guard let prefData = value.first(where: { $0.viewId == viewId }) else {
					print("View id not found")
					return
				}
				
				rect.wrappedValue = prefData.bounds
		})
	}
}

public protocol ViewId where Self: Hashable { }

If you are not familiar with using view preferences, please see knowledge sources section at the bottom of the article.

Best solution

struct Layout4: View {
	@State private var frameWidth: CGFloat = 250
	@State private var frameHeight: CGFloat = 250
	@State private var middleRowRect: CGRect = .zero
	
	var body: some View {
		FrameAdjustingContainer(frameWidth: $frameWidth, frameHeight: $frameHeight) {
			VStack {
				Text("01:")
					.frame(width: middleRowRect.width, alignment: .leading)  // 4
					.aspectRatio(middleRowRect.size, contentMode: .fit)  // 3
					.border(Color.blue, width: 1.5)
				Text("12:34")  // 1
					.getBounds(viewId: Id.middleRow, rect: $middleRowRect)  // 2
					.aspectRatio(middleRowRect.size, contentMode: .fit)  // 3
					.border(Color.blue, width: 1.5)
				Text(".56")
					.frame(width: middleRowRect.width, alignment: .trailing)  // 4
					.aspectRatio(middleRowRect.size, contentMode: .fit)  // 3
					.border(Color.blue, width: 1.5)
			}
			.border(Color.green, width: 1)
			.font(.system(size: 200, weight: .regular , design: .monospaced))
			.minimumScaleFactor(0.01)
		}
	}
	
	enum Id: ViewId {
		case middleRow
	}
}

No more HStack and Spacer, instead fixed frame with alignment and minimumScaleFactor. Most interesting are lines marked with comments: 1. Middle text row is "main" row, which dictates size for other rows. 2. Saves view bounds as middleRowRect property. 3. Sets fixed aspectRatio with saved view size of 2nd row. It has to purposes: - Prevents line break, without using linieLimit(1). - Sets the same hight for all rows. 4. Set the frame width for 1st and 3rd with the width of 2nd row. Additionally set proper alignment.

You can wonder why I use aspectRatio instead of setting the frame height of the row. It prevents the render loop. If we set frame hight, then VStack calculate hight based on children views, and children calculate their's height based on VStack height. Setting aspect ratio, solved that chicken-egg problem.

This layout solution works almost perfect. There is just one drawback. When height is limiting factor for VStack, then frames of text views are not pixel perfect the same. I assume that it is rounding error of calculating font size.

Final solution which resize text and keeps layout almost perfectly. Only problem is that, views are not pixel perfect the same size. You can see it as little stuttering when resizing

Alternative solution

We can achieve pixel perfect text sizes among all rows when using scaleEffect. However there are other problems: - Text is not resized as font perfect. - We can not use scaleEffect with dimension retrieved with view preference. It seems like scaling this way, doesn't force view preference update. - Static preview in Xcode shows wrong layout. Layout in live preview and simulator is fine.

struct Layout5: View {
	@State private var frameWidth: CGFloat = 250
	@State private var frameHeight: CGFloat = 250
	
	@State private var vstackSize = CGSize(width: 500, height: 500)
	@State private var middleRowSize = CGSize.zero
	
	private var scaleFactor: CGFloat {
		min(frameWidth/vstackSize.width, frameHeight/vstackSize.height)
	}
	
	var body: some View {
		FrameAdjustingContainer(frameWidth: $frameWidth, frameHeight: $frameHeight) {
			VStack {
				Text("01:")
					.fixedSize()
					.frame(width: middleRowSize.width, alignment: .leading)
					.border(Color.blue, width: 1)
				Text("12:34")
					.fixedSize()
					.background(  // using view preferences with scale effect doesn't work
						GeometryReader { geo -> Color in
							DispatchQueue.main.async {
								middleRowSize = geo.size
							}
							return .clear
						}
					)
					.border(Color.blue, width: 1)
				Text(".56")
					.fixedSize()
					.frame(width: middleRowSize.width, alignment: .trailing)
					.border(Color.blue, width: 1)
			}
			.border(Color.green, width: 1)
			.font(.system(size: 100, weight: .regular , design: .monospaced))
			.background(
				GeometryReader { geo -> Color in
					DispatchQueue.main.async {
						vstackSize = geo.size
					}
					return .clear
				}
			)
			.scaleEffect(scaleFactor)
		}
	}
}
Pixel perfect resizing, but not font perfect!

Knowledge sources

To research this topic I have read a lot tutorials and articles. This time most useful were amazing resources on SwiftUI Lab and Swift with Majid. Below is list of related articles from both webpages.

The SwiftUI Lab

Swift with Majid

Tagged with: