How to scale text to fit parent view with SwiftUI? - swift

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()
}
}
}

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)
}
}

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)
}
}
}

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))
}
}

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:

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)
Source: Apple

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()

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)
)
The result is this:

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))
}
}

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.

Related

How can you make Text in SwiftUI appear sideways while also rotating the frame?

The goal is to have something that looks like this:
I am aware of .rotationEffect(), and the solution provided here.
However the problem with this solution is that it does not rotate the frame, it only rotates the text itself.
The image below is from the Canvas preview in Xcode and the code underneath it.
HStack(spacing: 10) {
Text("Distance")
.rotationEffect(.degrees(270))
.foregroundColor(.black)
.minimumScaleFactor(0.01)
Rectangle()
.foregroundColor(.black)
.frame(width: 3)
.padding([.top, .bottom])
Spacer()
}
As you can see, the text goes outside the frame and the frame keeps its original width, making it difficult to have the text hug the side of the view and have the vertical line hug the side of the text.
The only way I have been able to figure out how to get the desired result is to use the .offset(x:) modifier. But this feels messy and could lead to bugs down the road I think.
Is there any way to be able to rotate the frame along with the text?
If we talk about dynamic detection, then we need to measure text frame before rotation and apply to frame size of changed width/height
Note: of course in simplified variant frame width can be just hardcoded
Here is main part:
#State private var size = CGSize.zero
var body: some View {
HStack(spacing: 10) {
Text("Distance")
.fixedSize() // << important !!
.background(GeometryReader {
Color.clear
.preference(key: ViewSizeKey.self, value: $0.frame(in: .local).size)
})
.onPreferenceChange(ViewSizeKey.self) {
self.size = $0 // << here !!
}
.rotationEffect(.degrees(270))
.frame(width: size.height, height: size.width) // << here !!
Used preference key:
public struct ViewSizeKey: PreferenceKey {
public typealias Value = CGSize
public static var defaultValue = CGSize.zero
public static func reduce(value: inout Value, nextValue: () -> Value) {
}
}
Test module/dependecies on GitHub

SwiftUI layout in VStack where one child is offered a maxHeight but can use less than that height

I'm trying to build a layout inside a VStack that contains two children. The first child should take up all available space unused by the second child. The second child has a preferred size based on its own contents. I'd like to limit the height of the second child to a maximum height, but it should be able to take less than the maximum (when its own contents cannot make use of all the height). This should all be responsive to the root view size, which is the parent of the VStack (because the device can rotate).
My attempt uses the .frame(maxHeight: n) modifier, which seems to unconditionally takes up the entire n points of height, even when the view being modified doesn't use it. This results in whitespace rendered above and below the VStack's second child. This problem is shown in the Portrait preview below - the hasIdealSizeView only has a height of 57.6pts, but the frame that wraps that view has a height of 75pts.
import SwiftUI
struct StackWithOneLimitedHeightChild: View {
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
fullyExpandingView
hasIdealSizeView
.frame(maxHeight: geometry.size.height / 4)
}
}
}
var fullyExpandingView: some View {
Rectangle()
.fill(Color.blue)
}
var hasIdealSizeView: some View {
HStack {
Rectangle()
.aspectRatio(5/3, contentMode: .fit)
Rectangle()
.aspectRatio(5/3, contentMode: .fit)
}
// the following modifier just prints out the resulting height of this view in the layout
.overlay(alignment: .center) {
GeometryReader { geometry in
Text("Height: \(geometry.size.height)")
.font(.system(size: 12.0))
.foregroundColor(.red)
}
}
}
}
struct StackWithOneLimitedHeightChild_Previews: PreviewProvider {
static var previews: some View {
Group {
StackWithOneLimitedHeightChild()
.previewDisplayName("Portrait")
.previewLayout(PreviewLayout.fixed(width: 200, height: 300))
StackWithOneLimitedHeightChild()
.previewDisplayName("Landscape")
.previewLayout(PreviewLayout.fixed(width: 300, height: 180))
}
}
}
This observed result is consistent with how the .frame(maxHeight: n) modifier is described in the docs and online blog posts (the flow chart here is extremely helpful). Nonetheless, I can't seem to find another way to build this type of layout.
Related question: what are the expected use cases for .frame(maxHeight: n)? It seems to do the opposite of what I'd expect by unconditionally wrapping the view in a frame that is at least n points in height. It seems no different than .frame(height: n), using an explicit value for the offered height.
The behavior of .minHeight in this example is strange and far from intuitive. But I found a solution using a slightly different route:
This defines the minHeight for the expanding view (to get the desired layout in portrait mode), but adds a .layoutPriority to the second, making it define itself first and then give the remaining space to the upper view.
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
fullyExpandingView
.frame(minHeight: geometry.size.height / 4 * 3)
hasIdealSizeView
.layoutPriority(1)
}
}
}
There's probably a really short way to go about this but in the meantime here is what I did.
Firstly I created a struct for your hasIdealSizeView and I made it return a GeometryProxy, and with that i could return the height of the HStack, in this case, the same height you were printing on to the Text View. then with that I used the return proxy to check if the height is greater than the maximum, and if it is, i set it to the maximum, otherwise, set the height to nil, which basically allows the native SwiftUI flexible height:
//
// ContentView.swift
// Test
//
// Created by Denzel Anderson on 3/16/22.
//
import SwiftUI
struct StackWithOneLimitedHeightChild: View {
#State var viewHeight: CGFloat = 0
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
fullyExpandingView
.overlay(Text("\(viewHeight)"))
// GeometryReader { geo in
hasIdealSizeView { proxy in
viewHeight = proxy.size.height
}
.frame(height: viewHeight > geometry.size.height / 4 ? geometry.size.height / 4:nil)
}
.background(Color.green)
}
}
var fullyExpandingView: some View {
Rectangle()
.fill(Color.blue)
}
}
struct hasIdealSizeView: View {
var height: (GeometryProxy)->()
var body: some View {
HStack {
Rectangle()
.fill(.white)
.aspectRatio(5/3, contentMode: .fit)
Rectangle()
.fill(.white)
.aspectRatio(5/3, contentMode: .fit)
}
// the following modifier just prints out the resulting height of this view in the layout
.overlay(alignment: .center) {
GeometryReader { geometry in
Text("Height: \(geometry.size.height)")
.font(.system(size: 12.0))
.foregroundColor(.red)
.onAppear {
height(geometry)
}
}
}
}
}

How to zoom in into a SwiftUI Text Editor

I am building a SwiftUI text editor in which I need the user to be able to zoom in. However, I don't know how to zoom in to the SwiftUI text editor naturally -- I want to make the zooming function of the text editor smooth. In the code below, what I've tried is changing the font size. I've also tried the "scaleEffect" method, but it pixelates the text editor text
Here is a gif of what I essentially want to achieve:
Here is the gif of my current text editor:
Here is my current code:
ScrollView {
VStack {
ForEach(0 ..< wordPad.pages.count) { page in
TextEditor(text: $wordPad.pages[page])
.frame(width: 800 * scale, height: 800 * scale * sqrt(2))
.font(Font.custom(fontName, size: fontSize * scale))
.lineSpacing(10 * scale)
.clipShape(RoundedRectangle(cornerRadius: 5))
.padding(40 * scale)
.foregroundColor(.primary)
.multilineTextAlignment(.leading)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
.gesture(MagnificationGesture()
.onChanged { scale = $0 }
)
Changing the type size always leads to different line breaks, because Apple is changing the character spacing based on the display size.
The only workaround I found is to still use .scaleEffect but start with an "enlarged" version of all values (x4) and an initial scale of 0.25 – so it mainly scales down and doesn't pixelate as quickly:
struct ContentView: View {
#State private var wordPad = ""
#State private var scaleGesture: CGFloat = 1
#State private var scale: CGFloat = 0.25
var body: some View {
VStack {
VStack {
TextEditor(text: $wordPad)
.frame(width:1200, height: 1200)
.font(.system(size: 48))
.lineSpacing(40)
.clipShape(RoundedRectangle(cornerRadius: 20))
.multilineTextAlignment(.leading)
.allowsTightening(false)
}
.scaleEffect(scaleGesture * scale)
.gesture(
MagnificationGesture()
.onChanged {
scaleGesture = $0
print($0)
}
.onEnded { value in
print ( scale, value)
scale = scale * scaleGesture
scaleGesture = 1
}
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.gray)
.ignoresSafeArea()
}
}

Scaling down a text's font size to fit its length to another text in SwiftUI

Consider the following code:
VStack{
Text("A reference text")
Text("(Adjust the length of this line to match the one above)")
}
I want the second Text() to be scaled down in font size to be exactly the length of the sentence above. I've already tried:
Text("(Adjust the length of this line to match the one above)")
.scaledToFit()
.minimumScaleFactor(0.01)
but it seems to not work. How can I achieve this?
Here is possible simple approach (tested with Xcode 13.4 / iOS 15.5)
Update: found even more simpler, based on the same concept (works with dynamic sizes as well)
Text("A reference text")
.padding(.bottom) // << spacing can be tuned
.overlay(
Text("(Adjust the length of this line to match the one above)")
.scaledToFit()
.minimumScaleFactor(0.01)
, alignment: .bottom) // << important !!
Original:
VStack {
let ref = "A reference text"
let target = "(Adjust the length of this line to match the one above)"
Text(ref)
Text(ref).opacity(0)
.overlay(Text(target)
.scaledToFit()
.minimumScaleFactor(0.01)
)
}
You need to get the size of the reference view
struct ScaledTextView: View {
#State var textSize: CGSize = CGSize.zero
var body: some View {
VStack{
Text("A reference text")
.viewSize(viewSize: $textSize) //Get initial size and any changes
Text("(Adjust the length of this line to match the one above)")
//If you want it to be a single line set the limit
//.lineLimit(1)
//Set the dependent's view frame, you can just set the width if you want, height is optional
.frame(width: textSize.width, height: textSize.height)
.scaledToFit()
.minimumScaleFactor(0.01)
}
}
}
//Make it reusable and keep the view clean
extension View{
func viewSize(viewSize: Binding<CGSize>) -> some View{
return self.background(
//Get the size of the View
GeometryReader{geo in
Text("")
//detects changes such as landscape, etc
.onChange(of: geo.size, perform: {value in
//set the size
viewSize.wrappedValue = value
})
//Initial size
.onAppear(perform: {
//set the size
viewSize.wrappedValue = geo.size
})
}
)
}
}
This is a method which doesn't fix the smaller text's height.
struct ContentView: View {
#State private var width: CGFloat?
var body: some View {
VStack {
Text("A reference text")
.background(
GeometryReader { geo in
Color.clear.onAppear { width = geo.size.width }
}
)
Text("(Adjust the length of this line to match the one above)")
.scaledToFit()
.minimumScaleFactor(0.01)
}
.frame(width: width)
.border(Color.red)
}
}
Result:

Developing a SwiftUI algorithm based on screen size

so I have a slider and I have a little VStack frame with some text in it that I'd like to move when you touch the slider (I'd like the VStack to stay directly above where the slider is all the time). I wasn't sure if there was a better way to do this so I tried just adjusting the leading padding of the VStack by making it dependent on the value of the slider. Here's my code:
struct ViewName: View {
#State private var sliderValue: Double = 0.0
var body: some View {
VStack {
HStack {
VStack {
Text("t")
}.frame(height: 30).background(Color.white)
.padding(.leading, CGFloat(CGFloat(DayRangeValue) * (UIScreen.main.bounds.width * 0.7)/24) + ((UIScreen.main.bounds.width * 0.15) + 10))
//The above padding is the algorithm: the addition of (UIScreen.main.bounds.width * 0.15 + 10) essentially works; it tells the VStack the starting position, and works on diff device simulators.
Spacer()
}
Slider(value: $sliderValue, in: 0...23).frame(width: UIScreen.main.bounds.width * 0.7)
//Note: the 0...23 cannot be changed, and the width of the slider is a percentage of screen size because I figured it can't be some static value, as that would only work on one device
}
}
}
Long story short, I'd like the "t" to be above the slider the wherever it is; on whatever device. The above algorithm for padding currently works perfectly on one of the iPad simulators, but it doesn't work properly on the iPhone 8 simulator (for example). If someone could help me figure out a padding algorithm based on screen size that works across all devices, that would be amazing.
If I correctly understood your intention, here is possible solution (geometry-independent). Tested with Xcode 11.4 / iOS 13.4
let kKnobWidth = CGFloat(24)
struct DemoLabelAboveSlider: View {
var range: ClosedRange<Double> = 0...23
#State private var sliderValue: Double = 0.0
var body: some View {
VStack(alignment: .leading) {
Slider(value: self.$sliderValue, in: self.range)
.padding(.vertical)
.overlay(GeometryReader { gp in
Text("t")
.position(x: CGFloat(self.sliderValue) * (gp.size.width - kKnobWidth) / CGFloat(self.range.upperBound) + kKnobWidth / 2, y: 0)
})
}
.padding(.horizontal) // << this one is only for better demo
}
}