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.
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.
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. |
Together, this produces text that is scaled to fit horizontally on the display, up to the maximum of .largeTitle
.
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 = 100
Code 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
// 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)