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
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
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
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)
}
}
}
- Original font size bigger than expected final size.
- To make view most flexible, minimum scale factor is set to very small value.
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
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.
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)
}
}
}
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
andminimumScaleFactor
modifiers are environment modifiers. We can apply them for containers and all views inside will share the same value.
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.
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)
}
}
}
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
- GeometryReader to the Rescue
- Inspecting the View Tree – Part 1: PreferenceKey
- Inspecting the View Tree – Part 2: AnchorPreferences
- Inspecting the View Tree – Part 3: Nested Views
- Safely Updating The View State