19

Setting lineBreakMode to byWordWrapping and set numberOfLines to 0 does not seem to be sufficient:

struct MyTextView: UIViewRepresentable {
    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        label.lineBreakMode = .byWordWrapping
        label.numberOfLines = 0
        label.text = "Here's a lot of text for you to display. It won't fit on the screen."
        return label
    }

    func updateUIView(_ view: UILabel, context: Context) {
    }
}

struct MyTextView_Previews: PreviewProvider {
    static var previews: some View {
        MyTextView()
            .previewLayout(.fixed(width: 300, height: 200))
    }
}

The text does not wrap, regardless of which setting I use for lineBreakMode. The canvas preview and live preview both look like this:

Screenshot of the text not wrapping

The closest I've gotten is setting preferredMaxLayoutWidth, which does cause the text to wrap, but there doesn't seem to be a value that means "whatever size the View is".

LuLuGaGa
  • 13,089
  • 6
  • 49
  • 57
alltom
  • 3,162
  • 4
  • 31
  • 47
  • 1
    Could you include CardTextView code in your question please? – LuLuGaGa Oct 20 '19 at 10:00
  • Whoops, I'd meant to rename that MyTextView like everywhere else. The example is self-contained. The only part of the file I didn't include was `import SwiftUI`. – alltom Oct 20 '19 at 14:41

3 Answers3

27

Possible solution is to declare the width as a variable on MyTextView:

struct MyTextView: UIViewRepresentable {

    var width: CGFloat

    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        label.lineBreakMode = .byWordWrapping
        label.numberOfLines = 0
        label.preferredMaxLayoutWidth = width
        label.text = "Here's a lot of text for you to display. It won't fit on the screen."
        return label
    }

    func updateUIView(_ view: UILabel, context: Context) {
    }
}

and then use GeometryReader to findout how much space there is avaible and pass it into the intializer:

struct ExampleView: View {

    var body: some View {
        GeometryReader { geometry in
            MyTextView(width: geometry.size.width)
        }
    }
}
LuLuGaGa
  • 13,089
  • 6
  • 49
  • 57
  • 3
    Hello, just to have in mind, when you use a ScrollView, looks like this solution doesn't work, just put the ScrollView inside the GeometryReader like this: `var body: some View { GeometryReader { geometry in ScrollView { MarkDownText(width: geometry.size.width, string: self.documentationStr) .padding([.leading, .bottom, .trailing], 10.0) .multilineTextAlignment(.leading) } } }` – Mauricio Zárate Jan 07 '20 at 18:55
  • What about **.attributedText**. Does it work with it too? – Peter Kreinz May 05 '20 at 11:41
  • @MauricioZárate how can i do this with a system image beside it for both single and multi line – Di Nerd Apps Jul 01 '20 at 09:48
  • much appreciated, mate – Anthonius Oct 14 '20 at 05:30
23

Try to use this magic line in makeUIView() func

label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
Michał Ziobro
  • 10,759
  • 11
  • 88
  • 143
  • 5
    yes but it's truncating line ! I've tried it, but I have only one line truncates at the end. – theMouk Mar 30 '20 at 09:45
  • This solves a problem with my `UILabel` UIViewRepresentable when I use it to display only 1 line with `truncateTail`, if I don't use this line the label covers the width of the screen, now it uses the `preferredMaxLayoutWidth` as expected – JAHelia May 11 '20 at 14:00
  • 1
    This solution works for me! No need to provide `width` from GeometryReader as suggested by the accepted answer. You just need to set your label's numberOfLines to 0 and and this line. – Thanh-Nhon Nguyen May 18 '20 at 12:41
  • 1
    This was also the one for me. I agree with @Thanh-NhonNguyen – Gonçalo Gaspar Mar 25 '21 at 21:30
  • 2
    This and `numberOfLines = 0` does the trick. No need for geometry reader. – Aviel Gross Nov 04 '21 at 20:54
  • This works perfectly and is better than the accepted answer. – Casey Wagner Nov 09 '21 at 20:15
  • This unfortunately doesn't work in a `ScrollView`. The accepted answer however does work in a `ScrollView` – gokeji Jul 14 '23 at 23:28
4

I found a somehow "nasty" approach that allows a UILabel to properly wrap when used as a UIViewRepresentable (even when inside a ScrollView), without the need for GeometryReader:

Whenever creating your UILabel:

label.setContentCompressionResistancePriority(.defaultLow,
                                              for: .horizontal)
label.setContentHuggingPriority(.defaultHigh,
                                for: .vertical)

This ensures that:

  • the label will break line and not have an infinite width
  • the label will not add grow unnecessarily in height, which may happen in some circumstances.

Then...

  • Add a width property to your UIViewRepresentable that will be used to set the preferredMaxLayoutWidth
  • Use your UIViewRepresentable into a vanilla SwiftUI.View
  • Add a GeometryReader as an overlay to prevent expansion
  • Trigger the measurement after a soft delay, modifying some state to trigger a new pass.

i.e.:

    public var body: some View {
        MyRepresentable(width: $width,
                        separator: separator,
                        content: fragments)
            .overlay(geometryOverlay)
            .onAppear { shouldReadGeometry = true }
    }

    // MARK: - Private Props

    @State private var width: CGFloat?
    @State private var shouldReadGeometry = false

    @ViewBuilder
    var geometryOverlay: some View {
        if shouldReadGeometry {
            GeometryReader { g in
                SwiftUI.Color.clear.onAppear {
                    self.width = g.size.width
                }
            }
        }
    }

OLD ANSWER:

...

In your updateUIView(_:context:):

if let sv = uiView.superview, sv.bounds.width != 0 {
    let shouldReloadState = uiView.preferredMaxLayoutWidth != sv.bounds.width
    uiView.preferredMaxLayoutWidth = sv.bounds.width
    if shouldReloadState {
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
            self.stateToggle.toggle() // a Bool @State you can add in your struct
        }
    }
}

Disclaimer: I'm not a huge fan of main.async calls, particularly when they come in combination with some arbitrary delay, but this seems to get the work done in a consistent way.

mtzaquia
  • 61
  • 3
  • This does't seem to wrap past two lines – Richard Witherspoon Sep 30 '20 at 20:46
  • @RichardWitherspoon indeed, the previous solution didn't work for me under some extra scenarios after some testing. I've updated the answer with the current state of my workaround. – mtzaquia Oct 06 '20 at 13:28
  • 1
    If you add the `@ViewBuilder` attribute to the `geometryOverlay` var it would use the view builder conditional block and you could remove both the return statements and the `eraseToAnyView()`. – dktaylor Jun 23 '21 at 15:45
  • @dktaylor good point, tweaked! Thank you. – mtzaquia Sep 28 '21 at 07:15