How do I have achieve a combined line limit for two Text views in a VStack in SwiftUI?

124 Views Asked by At

I have a design that I want to achieve in SwiftUI, and there are three examples shown below.

enter image description here

It's a VStack that contains an Image, and two Text views.

  1. It should show no more than three lines of text, ie the container view should not grow in height if both Text views would have enough text for two rows
  2. It should not shrink in height when there is only one line of text for each text view.
  3. It should support dynamic font sizes
5

There are 5 best solutions below

4
son On

There is a trick to accomplish this. I'm assuming the title and subTitle will have a maximum of three lines. The idea is to create a Text with a placeholder that has up to three lines. Then add the overlay of your two title and subTitle Views to this placeholder.

struct TextWrapper: View {
    private let maxLines = 3

    let title: String
    let titleFont: Font
    let subTitle: String
    let subTitleFont: Font

    var body: some View {
        Text(" ")
            .font(titleFont)
            .lineLimit(3, reservesSpace: true) //Autofill the height with x lines
            .frame(maxWidth: .infinity)
            .hidden()
            .overlay(alignment: .top) {
                VStack {
                    if title != "" {
                        Text(title)
                            .font(.headline)
                            .lineLimit(maxLines)
                            .layoutPriority(2) //Title has higher priority
                    }
                    
                    if subTitle != "" {
                        Text(subTitle)
                            .font(.subheadline)
                            .lineLimit(maxLines)
                            .layoutPriority(1)
                    }
                }
            }
    }
}

This is the output when titleFont = .title3 and subTitleFont = .subheadline:

enter image description here

Updated: As @Benzy pointed out in his comment, the placeHolder string could be sufficient with title, subTitle, and extra new line (\n). However, I decided to keep it as a dummy text cause there are cases when either title or subTitle are empty, even both of them.

Updated 2: Removed placeholder stuff since @miltenkot gave idea on reservesSpace.

2
Stoic On

You're looking for .layoutPriority() !

This view modifier basically shrinks the least prioritized views before the more prioritized views when resizing is necessary.

This is how you could use it:

VStack(alignment: .leading) {
    Text("Title")
        .lineLimit(2)
        .layoutPriority(1)
    
    Text("Subtitle")
        .lineLimit(2)
        // The default layout priority is 0 so you don't need to set it explicitly
}
4
Mahi Al Jawad On

The part of the implementation should go like this which will guarantee your conditions I think.

import SwiftUI

struct ContentView: View {
    @State var title = "Title text"
    @State var subTitle = "Subtitle text"
    
    var isSubtitleLarger: Bool {
        subTitle.count > title.count
    }
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(title)
                .lineLimit(isSubtitleLarger ? 1 : 2)
            
            Text(subTitle)
                .lineLimit(isSubtitleLarger ? 2 : 1)
        }
        .frame(minHeight: 150) // Add minimum height to meet minimum size, so that the view doesn't shrink when texts are small 
    }
}

This is important to guarantee there is always 3 lines at max.

3
Benzy Neez On

This exercise is all about restricting the height of the combined text so that there is only space for three lines. Actually, if you don't want the height to shrink when there are less than 3 lines (ref. your second requirement) then there must always be space for exactly three lines.

One way to do this is to use a hidden placeholder, as son has tried to show with his answer.

Another way would be to calculate the height for the text block, based on the font size. Unfortunately, Font does not let us read the font properties, but a UIFont does. And a Font can be created from a UIFont.

Regarding the third requirement:

  1. It should support dynamic font sizes

When a Font is created from a UIFont, it does not adapt automatically to changes to the text size in the settings. As a workaround, a ScaledMetric can be used to adjust the chosen font sizes.

So here is how it can be solved using a computed height for the text block:

struct ThreeLineText: View {
    let title: String
    let subtitle: String
    let textBlockSpacing: CGFloat = 4
    @ScaledMetric(relativeTo: .body) private var fontRefSize: CGFloat = 100

    private var titleFont: UIFont {
        .systemFont(ofSize: 20 * fontRefSize / 100, weight: .bold)
    }

    private var subtitleFont: UIFont {
        .systemFont(ofSize: 15 * fontRefSize / 100)
    }

    private var maxTextBlockHeight: CGFloat {
        (titleFont.lineHeight * 2) + textBlockSpacing + subtitleFont.lineHeight
    }

    var body: some View {
        VStack(alignment: .leading, spacing: textBlockSpacing) {
            Text(title)
                .font(Font(titleFont))
                .lineLimit(2)
                .fixedSize(horizontal: false, vertical: true)

            Text(subtitle)
                .font(Font(subtitleFont))
        }
        .frame(height: maxTextBlockHeight, alignment: .top)
    }
}

Trying it with your example cases:

struct ContentView: View {

    private func imageCard(title: String, subtitle: String) -> some View {
        VStack(alignment: .leading) {
            Color.white
                .frame(height: 150)
                .clipShape(RoundedRectangle(cornerRadius: 6))
            ThreeLineText(title: title, subtitle: subtitle)
        }
        .frame(width: 150)
        .padding()
        .background {
            Color(white: 0.85)
                .clipShape(RoundedRectangle(cornerRadius: 8))
        }
    }

    var body: some View {
        HStack(alignment: .top, spacing: 20) {
            imageCard(
                title: "Title that wraps to two lines",
                subtitle: "Subtitle wrapped to two lines"
            )
            imageCard(
                title: "Title is short",
                subtitle: "Subtitle wrapped to two lines"
            )
            imageCard(
                title: "Title is short",
                subtitle: "Subtitle is short"
            )
        }
    }
}

Screenshot

2
miltenkot On

regarding the idea with the placeholder, you can use native solutions such as .lineLimit(3, reservesSpace: true)