George Garside Blog

A place of many ramblings about macOS and development. If you find something useful on here, it's probably an accident.

For my watchOS app Thomas Deacon Academy Enrichment, I wanted to display some text as large as possible. Since it's only a few words, I wanted to print one word on each line.

However, I want all the words to be the same size, such that the longest word fits and all the other words are the size of that longest word.

Equal scaling SwiftUI text example on Apple Watch

View setup

This view is going to take a string of text, split it into words and display the words individually in a vertical list. That's simple stuff we can set up initially.

SwiftUI watchOS Text VStack
import SwiftUI

struct WordsToFit: View {
	let text: String

	var body: some View {
		VStack(alignment: .leading) {
			ForEach(
				text.split(separator: " ")
					.map(String.init),
				id: \.self
			) { (word: String) in
				Text(word)
			}
		}
	}
}Code language: Swift (swift)

This produces a vertical stack of individual words from a sentence or similar provided to the view.

Fit to screen

Since the words should be as large as possible, the next step is to size the words to fit the words to the screen horizontally.

SwiftUI has the ability to scale text to fit some bounds automatically, which we can use with the bounds being the width of the screen. Let's look at the 4 view modifiers to apply first:

.font(.largeTitle)Making the text large to scale down later. This will be the largest that the text will be, even if there's more room to scale.
On watchOS, largeTitle is almost always too large, so it's perfect. If you were trying this on iOS, you might wish to use a system or custom font of size 1000.
.scaledToFit()Scale the text to fit within the bounds of the containing view.
Since this view does not have a parent with padding or insets horizontally, this will be the width of the display. This has no effect on its own to text.
.minimumScaleFactor(0.01)Specify the minimum that the text will scale to.
Usually, this would be a number just below 1, which would slightly scale text in normal scenarios where the bounds are slightly too small for some text. We're somewhat misusing this to dramatically scale the text to a size much smaller than it was originally.
.lineLimit(1)Just to be safe, limit each line of text to at most 1 line.
This should have no effect, but reinforces that each word occupies one line and must not be split or wrapped.
The four view modifiers to scale the Text view.

Together, this produces text that is scaled to fit horizontally on the display, up to the maximum of .largeTitle.

SwiftUI watchOS Text VStack ScaleToFit
Text(word)
	.font(.largeTitle)
	.scaledToFit()
	.minimumScaleFactor(0.01)
	.lineLimit(1)Code language: Swift (swift)

Determine size of longest word

Currently, each word is being scaled to fit individually. This makes each word a different size.

The size to make all the words should be the size of the longest word scaled to fit.

GeometryReader lets us read the size of views. It can be prevented from taking all the space by using it in an overlay or background view modifier to a view already given a layout.

….background(GeometryReader { geometry in … })Code language: Swift (swift)

SwiftUI's Color conforms to View. A clear colour won't appear on screen, but allows view modifiers to be attached (unlike EmptyView()). Using the preference(key:value:) modifier on the view inside the geometry reader lets us set a preference based on the geometry available.

…
.background(GeometryReader {
	Color.clear
		.preference(key: SizePreferenceKey.self, value: $0.size.height)
})Code language: Swift (swift)

SizePreferenceKey is defined as follows, inside WordsToFit.

private struct SizePreferenceKey: PreferenceKey {
	static var defaultValue: CGFloat = .zero
	static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
		value = min(value, nextValue())
	}
}Code language: Swift (swift)

The reduce function is called for each preference sibling, and calling min takes the minimum value of all the preference's values seen.

Therefore the resulting value of the preference is the smallest height of all the words.

Apply the size to all words

We can set the smallest height on all the words using this preference value.

To get the preference value after the reduce has taken place, onPreferenceChange lets us access the value.

VStack(…) {
	ForEach(…) {
	}
	…(….preference(…))
}
.onPreferenceChange(SizePreferenceKey.self, perform: { wordHeight = $0 })
Code language: Swift (swift)

wordHeight is State in our view.

@State private var wordHeight: CGFloat = 100Code language: Swift (swift)

Since all words are .scaledToFit(), they will all be resized to the same font size by setting the frame of the Text view inside the ForEach.

Text(word)
	.frame(maxHeight: wordHeight)
Code language: CSS (css)

Result

Equal scaling SwiftUI text example on Apple Watch
// https://georgegarside.com/blog/ios/swiftui-equal-scaling-text-size-to-fit/

import SwiftUI

struct WordsToFit: View {
	let text: String

	private struct SizePreferenceKey: PreferenceKey {
		static var defaultValue: CGFloat = .zero
		static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
			value = min(value, nextValue())
		}
	}

	@State private var wordHeight: CGFloat = 100

	var body: some View {
		VStack(alignment: .leading) {
			ForEach(text.split(separator: " ").map(String.init), id: \.self) { (word: String) in
				Text(word)
					.font(.largeTitle)
					.fontWeight(.bold)
					.scaledToFit()
					.minimumScaleFactor(0.01)
					.lineLimit(1)
					.background(GeometryReader {
						Color.clear.preference(key: SizePreferenceKey.self, value: $0.size.height)
					})
					.frame(maxHeight: wordHeight)
			}
		}
		.onPreferenceChange(SizePreferenceKey.self, perform: { wordHeight = $0 })
	}
}

struct WordsToFit_Previews: PreviewProvider {
	static var previews: some View {
		WordsToFit(text: "Aimée Bethany Charles")
		WordsToFit(text: "Foo Bar Baz")
		WordsToFit(text: "Value exponentially increasing")
		WordsToFit(text: "Lorem dolor sit amet")
	}
}
Code language: Swift (swift)

Leave a Reply

No comments