Fix line spacing in custom font in SwiftUI

7.3k Views Asked by At

I am using custom font (Catamaran) and it seems like it has big space between lines in it. For example I have this code:

Text("Example text that has big space between lines")
    .lineSpacing(0)
    .padding(.horizontal)
    .font(Font.custom(FontNameManager.Catamaran.bold, size: 24.0))
    .foregroundColor(.white)
    .multilineTextAlignment(.center)

and it looks like this:

Screenshot with text example from previous code

As you can see it doesn't have zero space between lines but it still gets too much space. I even tried to set negative numbers to lineSpacing method but it doesn't help. What can I do with this? How I can fix it? In UIKit I would probably use attributed string and I think I can use UILabel as UIViewRepresentable and then I can use attributed string in SwiftUI iOS 14. Is there some easier solution which can "fix" font for any usage? Do I have to edit original .ttf file? Why there is this space between lines in this font?

Thanks for any help

5

There are 5 best solutions below

1
On BEST ANSWER

SwiftUI might use values of hhea (Horizontal header table) to set the Text box height. In your case, Catamaran has an ascender of 1100 and a descender of 540. The box height will be calculated as the sum of these two values: 540 + 1100 = 1640. And the UPM (Units per Em) of the font is default 1000. Which means in SwiftUI, when .font(.custom(..., size: 1000)) is set, each line of Text() will have a frame whose height is 1640.

Text Box Height

In terms of .lineSpacing(), SwiftUI doesn't set the value for spacing between baselines, but spacing between two boxes instead. If you want to have no spacing between two boxes in the example below, unfortunately setting .lineSpacing() to -(1640-1000) = -640 is not allowed (negative values not acceptable).

Set vs. Expected Line Spacing

UPDATE: An UIViewRepresentable Method

However, you can use UILabel instead to reduce line height:

struct CustomText: UIViewRepresentable {
    let text: String
    let font: UIFont
    
    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        
        label.font = font
        label.numberOfLines = 0
        
        let attributedString = NSMutableAttributedString(string: text)
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.lineHeightMultiple = 0.6  // <- Reduce lineHeight with a <1 factor
        
        attributedString.addAttribute(NSAttributedString.Key.paragraphStyle,
                                      value: paragraphStyle,
                                      range: NSMakeRange(0, attributedString.length))
        
        label.attributedText = attributedString
        
        return label
    }
    
    func updateUIView(_ uiView: UILabel, context: Context) { }
}

Usage:

CustomText(text: "Foggy Days\nGo Nowhere",
           font: UIFont(name: "Catamaran", size: 1000)!)
0
On

I've been trying to find a solution to this problem over the past few weeks. I've tried everything I've seen on this site including the accepted answer in this post. Even though it works great to reduce line height, I wasn't able to set leading and trailing padding. Here's what worked for me (one caveat is that padding has to be applied to the CustomText view, it will not constrain to any padding set by any containers higher up).

Working off of the accepted answer, I created a CustomText view, but I used it as a container (thinking it should act as a UIView for the UILabel to constrain to):

struct CustomText: View {
    private let text: String
    private let font: UIFont
    private let textAlignment: NSTextAlignment?
    private let lineHeight: CGFloat
    private let padding: EdgeInsets?
    private let containerWidth: CGFloat?
    
    /// - Parameters
    ///     - text `(String)`: String to display.
    ///     - font `(UIFont)`: Applies font to UILabel.
    ///     - textAlignment `(NSTextAlignment)` Will apply text alignment to UILabel.
    ///     - lineHeight: `(CGFloat)` Sets min and max line height in UILabel's attributed string paragraph style.
    ///     - padding `(EdgeInsets?)` Used to apply padding to the container and calculate the width of the nested UILabel. Defaults to 0s for all values.
    ///     - containerWidth: `(CGFloat?)` containerWIdth - leading and trailing padding will be used to set the width of nested the UILabel. Defaults to screen width.
    init(
        text: String,
        font: UIFont,
        textAlignment: NSTextAlignment? = .left,
        lineHeight: CGFloat,
        padding: EdgeInsets? = nil,
        containerWidth: CGFloat? = nil
    ) {
        self.text = text
        self.font = font
        self.textAlignment = textAlignment
        self.lineHeight = lineHeight
        self.padding = padding
        self.containerWidth = containerWidth
    }
    
    var body: some View {
        /// Putting the UILabel in a VStack mimics putting in a UIView which gives it something to constrain to
        VStack {
            CustomTextRepresentable(
                text: text,
                font: font,
                textAlignment: textAlignment ?? .left,
                lineHeight: lineHeight,
                containerWidth: containerWidth ?? UIScreen.main.bounds.width,
                padding: padding ?? EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)
            )
            /// scaledToFit prevents the UILabel from expanding vertically
            .scaledToFit()
        }
        /// Applying padding to the outside creates the constraints for the wrapper which the UILabel must constrain to
        .padding(padding ?? EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
    }
}

There are some comments there but part of the solution was to wrap the CustomTextRepresentable inside a VStack and apply padding to the VStack. When doing this however, the UILabel via UIViewRepresentable will be pushed left or right by its container's padding. So, I had to modify the UILabel width to fit within the padded parent container:

struct CustomTextRepresentable: UIViewRepresentable {
    let text: String
    let font: UIFont
    let textAlignment: NSTextAlignment
    let lineHeight: CGFloat
    let containerWidth: CGFloat
    let padding: EdgeInsets
    
    func makeUIView(context: Context) -> UILabel {
        return UILabel()
    }
    
    func updateUIView(_ uiLabel: UILabel, context: Context) {
        /// Resizing the label width prevents it from getting shifted in either direction when padding is applied to its container
        let widthWithPadding: CGFloat = containerWidth - (padding.leading + padding.trailing)
        let attributedString = NSMutableAttributedString(string: text)
        let paragraphStyle = NSMutableParagraphStyle()
        
        uiLabel.font = font
        uiLabel.numberOfLines = 0
        uiLabel.lineBreakMode = .byWordWrapping
        uiLabel.translatesAutoresizingMaskIntoConstraints = false
        uiLabel.preferredMaxLayoutWidth = widthWithPadding        
        
        paragraphStyle.alignment = textAlignment
        paragraphStyle.minimumLineHeight = lineHeight
        paragraphStyle.maximumLineHeight = lineHeight
        
        attributedString.addAttribute(
            NSAttributedString.Key.paragraphStyle,
            value: paragraphStyle,
            range: NSRange(location: 0, length: attributedString.length)
        )
        
        uiLabel.attributedText = attributedString
    }
}

Example usage:

CustomText(
    text: "Hey this is some text for a demo", 
    font: .someFont,
    lineHeight: 10,
    padding: EdgeInsets(top: 10, leading: 24, bottom: 14, trailing: 24)
)

I'd love to have a version of this that will just resize to its parent without using GeometryReader because that was causing some other issues for me when using multiple CustomText in a VStack, but this is working OK for my needs.

I hope this is helpful to someone. I'm relatively new to SwiftUI and have somewhat limited experience with Swift and iOS development outside of React Native and a year spent building iOS apps with Swift and Interface Builder back in 2015. If anyone can make this solution better and/or more elegant please feel free to contribute!

0
On

.leading seems like a thing you need (and is typographically the correct way to talk about 'line spacing').

Use:

Font.system(size: 16, weight: .regular, design: .rounded)
.leading(.tight)
0
On

iOS 17+

let paragraphStyle = NSMutableParagraphStyle()
// iOS 15/16 seems not working
paragraphStyle.maximumLineHeight = 21
paragraphStyle.minimumLineHeight = 21

var attributedString = AttributedString(text)
attributedString.paragraphStyle = paragraphStyle

Text(attributedString)
1
On

lineSpacing does not accept negative values, but you have a hidden API _lineHeightMultiple. It says it's deprecated but the suggested one does not works!

var body: some View {
  let font = UIFont.systemFont(ofSize: 12)
  let fontLineHeight = font.lineHeight
  let desiredLineHeight = 24.0

  Text("text")
    .font(SwiftUI.Font(font))
    ._lineHeightMultiple(desiredLineHeight / fontLineHeight)
}