Search code examples
swiftstringswiftuitext

Disable text wrapping for each single line of a Text in SwiftUI


I want to display a long string consisting of multiple lines in a widget with a Text element. I don't know the amount of lines or the max. line length. The lines should not be wrapped so that each line in the text element should be actually a new line.

Wanted behaviour:

enter image description here

Default behaviour:

Text("1foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo\n2foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo\n3foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")

enter image description here

Adding a lineLimit to the Text does not work as the limit applies to the entire text.

Splitting the string and creating for each line a new Text element with a linieLimit does not work for me because it would cause vertical overlapping in a widget as I don't know how many lines fit in the widget. So far, I have not been able to find a solution to clip the many Text elements with the help of GeometryReader to a fixed height & width.

Here is an example that shows the overlapping (line 1 and 9 is missing): enter image description here

import SwiftUI
import WidgetKit

struct ExampleWidgetView: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 3) {
            Text("1foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
            Text("2foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
            Text("3foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
            Text("4foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
            Text("5foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
            Text("6foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
            Text("7foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
            Text("8foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
            Text("9foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
        }
    }
}

struct ExampleWidgetView_Previews: PreviewProvider {
    static var previews: some View {
        ExampleWidgetView()
            .containerBackground(.clear, for: .widget)
            .previewContext(WidgetPreviewContext(family: .systemMedium))
    }
}

Using a horizontal ScrollView and therefore simply hiding the characters that are too much for the limited width does also not work as ScrollViews can't be used in widgets.


Solution

  • I would say, splitting the string on newline characters and then using .lineLimit(1) on these substrings is the right approach.

    You didn't explain how you wanted it to work when there are a lot of lines, but I am guessing you would like it to start with line 1 and any lines that don't fit in the vertical space are simply not shown.

    So here is a version that works in this way. The text is shown in an overlay over a clear background, because this gives more control over the vertical alignment. The modifier .minimumScaleFactor allows a small degree of shrinkage, if necessary. This gets used when lines are very long, or when there are a lot of lines. If you wanted to allow more content to be squeezed into the display then you could make the factor smaller.

    struct ExampleWidgetView: View {
    
        let veryLongStringWithLineBreaks = """
            1foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
            2foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
            3foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
            4foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
            5foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
            6foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
            7foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
            8foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
            9foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
            """
    
        var body: some View {
            Color.clear
                .overlay(alignment: .top) {
                    VStack(alignment: .leading, spacing: 3) {
                        ForEach(veryLongStringWithLineBreaks.split(separator: "\n"), id: \.self) { line in
                            Text(line)
                                .lineLimit(1)
                                .allowsTightening(true)
                                .minimumScaleFactor(0.8)
                        }
                    }
                }
        }
    }
    

    Widget


    EDIT Following up on your comment, a margin can be enforced at the bottom by using a GeometryReader to read the available height and clipping the content to this height. Like this:

    var body: some View {
        GeometryReader { proxy in
            Group {
                VStack(alignment: .leading, spacing: 3) {
                    ForEach(veryLongStringWithLineBreaks.split(separator: "\n"), id: \.self) { line in
                        Text(line)
                            .lineLimit(1)
                            .allowsTightening(true)
                            .minimumScaleFactor(0.8)
                    }
                }
                .frame(maxHeight: .infinity)
            }
            .frame(height: proxy.size.height, alignment: .top)
            .clipped()
        }
    }
    

    Clipped

    You will notice that the VStack is nested inside a Group. This is so that the lines of text are shown in the middle when there are only a few lines, instead of being pushed to the top.

    If you don't like the sharp cut-off either then one option would be to cover the bottom region with a gradient, so as to give a fade-out effect. Like this:

    var body: some View {
        GeometryReader { proxy in
            Group {
                // content as above
            }
            .frame(height: proxy.size.height + 10, alignment: .top)
            .clipped()
            .overlay(alignment: .bottom) {
                Rectangle()
                    .fill(
                        LinearGradient(
                            colors: [.clear, Color(UIColor.secondarySystemBackground)],
                            startPoint: .top,
                            endPoint: .bottom
                        )
                    )
                    .frame(height: 50)
            }
        }
    }
    

    FadeOut

    However, the gradient is always there, so if the number of lines is exactly right to fill the vertical space, the last line will probably be under the gradient.


    EDIT2 Here is one more workaround for you. You could calculate how many lines will fit and only show this number. A GeometryReader can be used to deliver the available height, but you will probably need to make do with an estimate of the line height. However, this estimate could be defined as a ScaledMetric, so that it adapts automatically to different text size settings. Also, you will probably need to disable the automatic scaling that .minimalScaleFactor gives you, by not including this modifier.

    Like this:

    @ScaledMetric(relativeTo: .body) private var estimatedLineHeight: CGFloat = 20.3
    private let spacing: CGFloat = 3
    
    var body: some View {
        GeometryReader { proxy in
            VStack(alignment: .leading, spacing: spacing) {
                ForEach(
                    Array(veryLongStringWithLineBreaks.split(separator: "\n").enumerated()),
                    id: \.offset
                ) { index, line in
                    if (CGFloat(index + 1) * estimatedLineHeight) + (CGFloat(index) * spacing) < proxy.size.height {
                        Text(line)
                            .lineLimit(1)
                            .allowsTightening(true)
                    }
                }
            }
            .frame(maxHeight: .infinity)
        }
    }
    

    LimitedLines