'Size a UILabel in SwiftUI via UIViewRepresentable like Text to wrap multiple lines

The goal is to get a UILabel integrated via UIViewRepresentable to size the same way Text does - use the available width and wrap to multiple lines to fit all the text thus increasing the height of the HStack it's in, instead of expanding in width infinitely. This is very similar to this question, though the accepted answer does not work for the layout I'm using involving a ScrollView, VStack, and HStack.

struct ContentView: View {
    var body: some View {
        ScrollView {
            VStack {
                HStack {
                    Text("Hello, World")
                    
                    Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla tempor justo quam, quis suscipit leo sollicitudin vel.")
                    
                    //LabelView(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla tempor justo quam, quis suscipit leo sollicitudin vel.")
                }
                HStack {
                    Text("Hello, World")
                    
                    Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla tempor justo quam, quis suscipit leo sollicitudin vel.")
                }
                
                Spacer()
            }
        }
    }
}

struct LabelView: UIViewRepresentable {
    var text: String

    func makeUIView(context: UIViewRepresentableContext<LabelView>) -> UILabel {
        let label = UILabel()
        label.text = text
        label.numberOfLines = 0
        return label
    }

    func updateUIView(_ uiView: UILabel, context: UIViewRepresentableContext<LabelView>) {
        uiView.text = text
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Using two Texts in the HStack results in this desired layout: Text and Text

Using the Text and LabelView results in this undesired layout: Text and LabelView

If you wrap the LabelView in GeometryReader and pass a width into LabelView to set the preferredMaxLayoutWidth, it's 0.0 for some reason. You can get a width if you move the GeometryReader outside the ScrollView, but then it's the scroll view width, not the width SwiftUI is proposing for the LabelView in the HStack.

If instead I specify label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) it works better, but still doesn't display all the text, it strangely truncates the LabelView at 3 lines and the second Text at 2 lines.



Solution 1:[1]

The problem here is in ScrollView which requires definite height, but representable does not provide it. The possible solution is to dynamically calculate wrapped text height and specify it explicitly.

Note: as height is calculated dynamically it is available only in run-time, so cannot be tested with Preview.

Tested with Xcode 12 / iOS 14

demo

struct LabelView: View {
    var text: String

    @State private var height: CGFloat = .zero

    var body: some View {
        InternalLabelView(text: text, dynamicHeight: $height)
            .frame(minHeight: height)
    }

    struct InternalLabelView: UIViewRepresentable {
        var text: String
        @Binding var dynamicHeight: CGFloat

        func makeUIView(context: Context) -> UILabel {
            let label = UILabel()
            label.numberOfLines = 0
            label.lineBreakMode = .byWordWrapping
            label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)

            return label
        }

        func updateUIView(_ uiView: UILabel, context: Context) {
            uiView.text = text

            DispatchQueue.main.async {
                dynamicHeight = uiView.sizeThatFits(CGSize(width: uiView.bounds.width, height: CGFloat.greatestFiniteMagnitude)).height
            }
        }
    }
}

backup

Solution 2:[2]

This is not a concrete answer.

I've accidentally found that the fixed size modifier can help with auto resizing the UILabel to its content size.

struct ContentView: View {
    let attributedString: NSAttributedString
    
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 10) {
                RepresentedUILabelView(attributedText: attributedString)
                    .frame(maxHeight: 300)
                    .fixedSize(horizontal: false, vertical: true)
                    .background(Color.orange.opacity(0.5))
            }
            .padding()
        }
    }
}
struct RepresentedUILabelView: UIViewRepresentable {
    typealias UIViewType = UILabel
    
    var attributedText: NSAttributedString
    
    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        
        label.numberOfLines = 0
     
        label.lineBreakMode = .byTruncatingTail

        label.textAlignment = .justified
        
        label.allowsDefaultTighteningForTruncation = true
        
        // Compression resistance is important to enable auto resizing of this view,
        // that base on the SwiftUI layout.
        // Especially when the SwiftUI frame modifier applied to this view.
        label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        label.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
        
        // Maybe this is not necessary.
        label.clipsToBounds = true
        
        return label
    }
    
    func updateUIView(_ uiView: UILabel, context: Context) {
        print(#fileID, #function)
        
        uiView.attributedText = attributedText
    }
    
}

Demo:

A paragraph

A couple words


In addition, if you do not wish to provide a max height. You can set the preferredMaxLayoutWidth to your screen width. And if you put it i the updateUIView method, whenever the screen orientation changes, this method also get called.

    func updateUIView(_ uiView: UILabel, context: Context) {
        
        uiView.attributedText = attributedText
        
        uiView.preferredMaxLayoutWidth = 0.9 * UIScreen.main.bounds.width
    }

Example, without max frame height.


Just for completeness, this is the attributed text setting i used to test the view. However, it should not affect how the view resizing works.

func makeAttributedString(fromString s: String) -> NSAttributedString {
    let content = NSMutableAttributedString(string: s)
    
    let paraStyle = NSMutableParagraphStyle()
    paraStyle.alignment = .justified
    paraStyle.lineHeightMultiple = 1.25
    paraStyle.lineBreakMode = .byTruncatingTail
    paraStyle.hyphenationFactor = 1.0
    
    content.addAttribute(.paragraphStyle,
                         value: paraStyle,
                         range: NSMakeRange(0, s.count))
    
    // First letter/word
    content.addAttributes([
        .font      : UIFont.systemFont(ofSize: 40, weight: .bold),
        .expansion : 0,
        .kern      : -0.2
    ], range: NSMakeRange(0, 1))
    
    return content
}

let coupleWords = "Hello, world!"

let multilineEN = """
    Went to the woods because I wished to live deliberately, to front only the essential facts of life, and see if I could not learn what it had to teach, and not, when I came to die, discover that I had not lived. I did not wish to live what was not life, living is so dear! Nor did I wish to practise resignation, unless it was quite necessary?"

    I went to the woods because I wished to live deliberately, to front only the essential facts of life, and see if I could.

    I went to the woods because I wished to live deliberately, to front only the essential facts of life, and see if I could not learn what it had to teach, and not.
    """

Solution 3:[3]

None of the solutions in the current answers are close enough to the essence of SwiftUI. Another way of thinking, since SwiftUI is good enough for Text, why not do something like this?

Text(yourstr)
 .foregroundColor(.clear) <<<<<<<--------?
 .multilineTextAlignment(.leading)
 .frame(maxWidth:.infinity,maxHeight: .infinity, alignment: .leading)

 .overlay(
         Your ActiveLabelStack() <<<<<<<--------?
                ,alignment: .center
            )

Simply put, let the Text tell you the height and be the base of the Label. After all, Text is the son of SwiftUI.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2
Solution 3 ???