How to scale text to fit parent view with SwiftUI?
SwiftSwiftuiSwift Problem Overview
I'd like to create a text view inside a circle view. The font size should be automatically set to fit the size of the circle. How can this be done in SwiftUI? I tried scaledToFill and scaledToFit modifiers, but they have no effect on the Text view:
struct ContentView : View {
var body: some View {
ZStack {
Circle().strokeBorder(Color.red, lineWidth: 30)
Text("Text").scaledToFill()
}
}
}
Swift Solutions
Solution 1 - Swift
One possible "hack" is to use a big font size and a small scale factor so it will shrink itself:
ZStack {
Circle().strokeBorder(Color.red, lineWidth: 30)
Text("Text")
.padding(40)
.font(.system(size: 500))
.minimumScaleFactor(0.01)
}
}
Solution 2 - Swift
One can use GeometryReader
in order to make it also work in landscape mode.
It first checks if the width or the height is smaller and then adjusts the font size according to the smaller of these.
GeometryReader{g in
ZStack {
Circle().strokeBorder(Color.red, lineWidth: 30)
Text("Text")
.font(.system(size: g.size.height > g.size.width ? g.size.width * 0.4: g.size.height * 0.4))
}
}
Solution 3 - Swift
You want to allow your text to:
- shrink up to a certain limit
- on 1 (or several) line(s)
You choose this scale factor limit to suit your need. Typically you don't shrink beyond readable or beyond the limit that will make your design look bad
struct ContentView : View {
var body: some View {
ZStack {
Circle().strokeBorder(Color.red, lineWidth: 30)
Text("Text")
.scaledToFill()
.minimumScaleFactor(0.5)
.lineLimit(1)
}
}
}
Solution 4 - Swift
Here's a solution that hides the text resizing code in a custom modifier which can be applied to any View, not just a Circle, and takes a parameter specifying the fraction of the View that the text should occupy.
(I have to agree that while @szemian's solution is still not ideal, her method seems to be the best we can do with the current SwiftUI implementation because of issues inherent in the others. @simibac's answer requires fiddling to find a new magic number to replace 0.4 any time the text or its attributes--font, weight, etc.--are changed, and @giuseppe-sapienza's doesn't allow the size of the circle to be specified, only the font size of the text.)
struct FitToWidth: ViewModifier {
var fraction: CGFloat = 1.0
func body(content: Content) -> some View {
GeometryReader { g in
content
.font(.system(size: 1000))
.minimumScaleFactor(0.005)
.lineLimit(1)
.frame(width: g.size.width*self.fraction)
}
}
}
Using the modifier, the code becomes just this:
var body: some View {
Circle().strokeBorder(Color.red, lineWidth: 30)
.aspectRatio(contentMode: .fit)
.overlay(Text("Text")
.modifier(FitToWidth(fraction: fraction)))
}
Also, when a future version of Xcode offers SwiftUI improvements that obviate the .minimumScaleFactor
hack, you can just update the modifier code to use it. :)
If you want to see how the fraction parameter works, here's code to let you adjust it interactively with a slider:
struct ContentView: View {
@State var fraction: CGFloat = 0.5
var body: some View {
VStack {
Spacer()
Circle().strokeBorder(Color.red, lineWidth: 30)
.aspectRatio(contentMode: .fit)
.overlay(Text("Text")
.modifier(FitToWidth(fraction: fraction)))
Slider(value: $fraction, in:0.1...0.9, step: 0.1).padding()
Text("Fraction: \(fraction, specifier: "%.1f")")
Spacer()
}
}
}
and here's what it looks like:
Solution 5 - Swift
I did a mix of @Simibac's and @Anton's answers, only to be broken by iOS 14.0, so here's what I did to fix it. Should work on SwiftUI 1.0 as well.
struct FitSystemFont: ViewModifier {
var lineLimit: Int
var minimumScaleFactor: CGFloat
var percentage: CGFloat
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.font(.system(size: min(geometry.size.width, geometry.size.height) * percentage))
.lineLimit(self.lineLimit)
.minimumScaleFactor(self.minimumScaleFactor)
.position(x: geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY)
}
}
}
As you can see I used the geometry proxy's frame(in:)
method to get the local coordinate space, and then use .midX
and .midY
to center it properly, since proper centering is what broke for me on iOS 14.
Then I set up an extension on View
:
extension View {
func fitSystemFont(lineLimit: Int = 1, minimumScaleFactor: CGFloat = 0.01, percentage: CGFloat = 1) -> ModifiedContent<Self, FitSystemFont> {
return modifier(FitSystemFont(lineLimit: lineLimit, minimumScaleFactor: minimumScaleFactor, percentage: percentage))
}
}
So usage is like this:
Text("Your text")
.fitSystemFont()
Solution 6 - Swift
Building on @JaimeeAz answer. Added an option to specify the minimum font.
import SwiftUI
public struct FitSystemFont: ViewModifier {
public var lineLimit: Int?
public var fontSize: CGFloat?
public var minimumScaleFactor: CGFloat
public var percentage: CGFloat
public func body(content: Content) -> some View {
GeometryReader { geometry in
content
.font(.system(size: min(min(geometry.size.width, geometry.size.height) * percentage, fontSize ?? CGFloat.greatestFiniteMagnitude)))
.lineLimit(self.lineLimit)
.minimumScaleFactor(self.minimumScaleFactor)
.position(x: geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY)
}
}
}
public extension View {
func fitSystemFont(lineLimit: Int? = nil, fontSize: CGFloat? = nil, minimumScaleFactor: CGFloat = 0.01, percentage: CGFloat = 1) -> ModifiedContent<Self, FitSystemFont> {
return modifier(FitSystemFont(lineLimit: lineLimit, fontSize: fontSize, minimumScaleFactor: minimumScaleFactor, percentage: percentage))
}
}
Solution 7 - Swift
To achieve this you don't need the ZStack
. You can add a background to the Text
:
Text("Text text text?")
.padding()
.background(
Circle()
.strokeBorder(Color.red, lineWidth: 10)
.scaledToFill()
.foregroundColor(Color.white)
)
Solution 8 - Swift
I had fixed size button and this worked for me to autoshrink long text.
Text("This is a long label that will be scaled to fit:")
.lineLimit(1)
.minimumScaleFactor(0.5)
Solution 9 - Swift
I had the same problem for a Timer. Unfortunately a timer changes the text once a second, so the text was jumping around and scaling up and down all the time. My approach was to figure, what was the longest possible timer that could be displayed - in my case "-44:44:44" - and with 50pt size that would result in a 227.7pt big frame. 227 divided by 50 (point size I used before) a width of 4.5 (rounded down) per point size. Careful: with 1pt size it gave me a 5.3 point big frame - so the bigger the font the closer to the actual text size without the frame it gets.
As I was using a GeometryReader anyway I could simple set a fixed text size, using
Text("-44:44:44")
.font(.system(size: geometry.size.width / 4.5))
Works perfectly well, if there is no '-' or no hours shown I have some space to the left and right, but the text doesn't jump around.
this could be refined with different scale-factors, depending on the amount of digits shown there - so another scale factor for "-mm:ss". This would lead to a "jump" when the hours are shown or hidden - but that happens rarely for my need.