swiftui

Reduce line height to actual characters used in SwiftUI Text element


Is there a way to force a SwiftUI Text element to have the height of the actual used characters instead of the font height?

Example:

    var body: some View {
        VStack {
            Text("Week")
                .font(.title2)
                .foregroundStyle(.secondary)
                .border(.red)
            Text("44")
                .font(.system(size: 200))
                .minimumScaleFactor(0.1)
                //.padding(.vertical, -20)
                .border(.red)
        }
    }

This will look like this:

Rectangle with two text elements with a distance

I would really like the text "Week" to be close to the large text "44". I can get there with a negative padding but that will not automatically adjust for changes in view size and will require manual tuning for all sizes.


Solution

  • The answer to the post Aligning SwiftUI text to the baseline and top of another text has a useful explanation of the way a font is sized. You can get this information from an instance of UIFont.

    It sounds like you want to set the height of the Text to the capHeight of the font. However, you also need to disable scaling-down by commenting out the modifier .minimumScaleFactor, otherwise when you set a smaller frame then the font shrinks.

    private func heightForFontSize(size: CGFloat) -> CGFloat {
        let font = UIFont.systemFont(ofSize: size)
        return font.capHeight
    }
    
    var body: some View {
        VStack {
            Text("Week")
                .font(.title2)
                .foregroundStyle(.secondary)
                .border(.red)
            Text("44")
                .font(.system(size: 200))
    //            .minimumScaleFactor(0.1)
                .frame(height: heightForFontSize(size: 200))
                .border(.red)
        }
    }
    

    Screenshot

    The gap could be eliminated completely by using spacing: 0 for your VStack.

    Another way to approach the problem would be to fix the height of the text and then use a font that exactly fits the height. The answer to How to make text fill the height of the container in SwiftUI shows how this is done.

    This approach could be used to fit the font to the size that .minimumScaleFactor was giving you in your original code. The footprint for the text can be established using a hidden version of the scaled text, then show the actual text in an overlay in a larger font.

    I have borrowed the function fontForHeight from the other answer to show this working (it was my answer):

    private func fontForHeight(targetHeight: CGFloat) -> Font {
        let approxFontSize = targetHeight * 1.4
        let refFont = UIFont.systemFont(ofSize: approxFontSize)
        let computedFontSize = (targetHeight / refFont.capHeight) * approxFontSize
        let tweakedFontSize = computedFontSize - (targetHeight * 0.05)
        return Font(UIFont.systemFont(ofSize: tweakedFontSize))
    }
    
    var body: some View {
        VStack {
            Text("Week")
                .font(.title2)
                .foregroundStyle(.secondary)
                .border(.red)
            Text("44")
                .font(.system(size: 200))
                .minimumScaleFactor(0.1)
                .hidden()
                .overlay {
                    GeometryReader { proxy in
                        Text("44")
                            .font(fontForHeight(targetHeight: proxy.size.height))
                            .lineLimit(1)
                            .fixedSize()
                            .frame(width: proxy.size.width, height: proxy.size.height)
                    }
                }
                .border(.red)
        }
    }
    

    Screenshot2

    As you can see, the danger of this approach is that the larger font might be too wide for the available width.


    EDIT Following from your comment, here is another possible approach. You were saying, it is important that the font can be scaled down to fit the available space. So I would suggest, you find the font size that matches the scaled font and then show this with its height constrained to capHeight.

    This will leave some height unused, so there would be space to show the "Week" label in the same overlay. This way, the final result will be vertically centered.

    Like this:

    private func uiFontForLineHeight(height: CGFloat) -> UIFont {
        let approxFontSize = height
        let refFont = UIFont.systemFont(ofSize: approxFontSize)
        let computedFontSize = (height / refFont.lineHeight) * approxFontSize
        return UIFont.systemFont(ofSize: computedFontSize)
    }
    
    var body: some View {
        VStack {
            Text("44")
                .font(.system(size: 200))
                .lineLimit(1)
                .minimumScaleFactor(0.1)
                .hidden()
                .overlay {
                    GeometryReader { proxy in
                        let uiFont = uiFontForLineHeight(height: proxy.size.height)
                        VStack(spacing: 0) {
                            Text("Week")
                                .font(.title2)
                                .foregroundStyle(.secondary)
                                .border(.red)
                            Text("44")
                                .font(Font(uiFont))
                                .lineLimit(1)
                                .fixedSize()
                                .frame(height: uiFont.capHeight)
                        }
                        .frame(height: proxy.size.height)
                    }
                }
                .border(.red)
        }
    }
    

    Screenshot3