2

When adding a custom UILabel to a List in SwiftUI, I get errors with cell reuse, where the label on some cells isn't visible at all, and on some cells it is placed in top-left without any regard for the cell's padding. It always renders perfectly on the initial cells.

The problem doesn't occur when using a ScrollView. Is this a known bug, and are there good workarounds?

GeometryReader { geometry in
    List {
        ForEach(self.testdata, id: \.self) { text in
            Group {
                AttributedLabel(attributedText: NSAttributedString(string: text), maxWidth: geometry.size.width - 40)
            }.padding(.vertical, 20)
        }
    }
}

struct AttributedLabel: UIViewRepresentable {

    let attributedText: NSAttributedString
    let maxWidth: CGFloat

    func makeUIView(context: UIViewRepresentableContext<Self>) -> UILabel {
        let label = UILabel()
        label.preferredMaxLayoutWidth = maxWidth
        label.attributedText = attributedText
        label.lineBreakMode = .byWordWrapping
        label.numberOfLines = 0
        label.backgroundColor = UIColor.red

        return label
    }

    func updateUIView(_ label: UILabel, context: UIViewRepresentableContext<Self>) {}

}

enter image description here

3
  • I've encountered what looks to be the same issue using a UILabel (to render an attributed string) wrapped in a UIViewRepresentable. I've only seen it happen inside List. Commented Mar 22, 2020 at 19:22
  • I am experiencing exactly the same issue! Commented Mar 24, 2020 at 11:51
  • @Chris Do you found solution to this problem I am also trying to place custom UIView from UIKit inside SwiftUI List via UIViewRepresentable. And I do not know this UIKit views are layout. I thought I do not need to specify any constraint and they just size itself inside available space of List item Commented Mar 24, 2020 at 11:59

3 Answers 3

0

It is not related to ScrollView or SwiftUI bug. I think You have a issue with your AttributedLabel Class. I tried using normal Text and it is working fine.

List {
        ForEach(self.testdata, id: \.self) { text in
            Group {
                  Text(student.name)
                    .background(Color.red)
            }.padding(.vertical, 20)
        }
    }
Sign up to request clarification or add additional context in comments.

1 Comment

Yes, it works fine with any SwiftUI component, such as Text. My AttributedLabel doesn't have anything weird in it that I can see (it's pretty simple), and it only breaks on reused table view cells – where the breakage isn't caused inside of the UILabel implemenation, but on it's positioning. When I log the generated frame, many get (0.0, 0.0, 0.0, 0.0) even though they have content.
0

There does seem to be a workaround for this.

  1. The first step is to get the model to first return an empty array of items and later return the actual update. This will force the view to update. And then, after a short pause, it can be followed by the actual update. For this case, this isn't quite enough. Doing that alone still leads to the layout issues. Somehow the list (presumably backed by a UITableView that is aggressively recycling its cells) still manages to keep the state the is somehow causing the trouble. And so...

  2. The second step is to get the view to offer something other than the List when there are no items. This is done using the SwiftUI if and else to use a different view depending on whether there are any items. With the changes to the model, as per step 1, this happens every update.

Doing steps (1) and (2) appears to workaround the issue. The sample code below also includes an .animation(.none) method on the View. This was necessary in my code, but in the example code below it doesn't seem to be needed.

A downside of this workaround is that you will lose animations. And clearly it is something of a hack that, if Apple make changes in the future, might not continue to work. (Still, maybe by then the bug will have been fixed.)

import SwiftUI

struct ContentView: View {

      @ObservedObject var model = TestData()

      var body: some View {

            VStack() {
                  GeometryReader { geometry in
                        // handle the no items case by offering up a different view
                        // this appears to be necessary to workaround the issues
                        // where table cells are re-used and the layout goes wrong
                        // Don't show the "No Data" message unless there really is no data,
                        // i.e. skip case where we're just delaying to workaround the issue.
                        if self.model.sampleList.isEmpty {
                              Text("No Data")
                                    .foregroundColor(self.model.isModelUpdating ? Color.clear : Color.secondary)
                                    .frame(width: geometry.size.width, height: geometry.size.height) // centre the text
                        }
                        else {
                              List(self.model.sampleList, id:\.self) { attributedString in
                                    AttributedLabel(attributedText: attributedString, maxWidth: geometry.size.width - 40)
                              }
                        }
                        }.animation(.none) // this MAY not be necessary for all cases
                  Spacer()
                  Button(action: { self.model.shuffle()} ) { Text("Shuffle") }.padding(20)
            }
      }
}

struct AttributedLabel: UIViewRepresentable {

      let attributedText: NSAttributedString
      let maxWidth: CGFloat

      func makeUIView(context: UIViewRepresentableContext<Self>) -> UILabel {
            let label = UILabel()
            label.preferredMaxLayoutWidth = maxWidth
            label.attributedText = attributedText
            label.lineBreakMode = .byWordWrapping
            label.numberOfLines = 0
            label.backgroundColor = UIColor.red
            return label
      }

      func updateUIView(_ label: UILabel, context: UIViewRepresentableContext<Self>) {
            // function required by protoocol - NO OP
      }

}

class TestData : ObservableObject {

      @Published var sampleList = [NSAttributedString]()
      @Published var isModelUpdating = false
      private var allSamples = [NSAttributedString]()


      func shuffle() {

            let filtered = allSamples.filter{ _ in Bool.random() }
            let shuffled = filtered.shuffled()

            // empty the sampleList - this will trigger the View that is
            // observing the model to update and handle the no items case
            self.sampleList = [NSAttributedString]()
            self.isModelUpdating = true

            // after a short delay update the sampleList - this will trigger
            // the view that is observing the model to update
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
                  self.sampleList = shuffled
                  self.isModelUpdating = false
            }
      }

      init() {
            generateSamples()
            shuffle()
      }

      func generateSamples() {

            DispatchQueue.main.async {
                  var samples = [NSAttributedString]()

                  samples.append("The <em>quick</em> brown fox <strong>boldly</strong> jumped over the <em>lazy</em> dog.".fromHTML)
                  samples.append("<h1>SwiftUI</h1><p>At the time of writing, still very much a <em>work in progress</em>. Normal and <em>italic</em>. And <strong>strong</strong> too.</p><p>At the time of writing, still very much a <em>work in progress</em>. Normal and <em>italic</em>. And <strong>strong</strong> too.</p><p>At the time of writing, still very much a <em>work in progress</em>. Normal and <em>italic</em>. And <strong>strong</strong> too.</p><p>At the time of writing, still very much a <em>work in progress</em>. Normal and <em>italic</em>. And <strong>strong</strong> too.</p><p>At the time of writing, still very much a <em>work in progress</em>. Normal and <em>italic</em>. And <strong>strong</strong> too.</p>".fromHTML)
                  samples.append("<h1>Test Cells</h1><p>Include cells that have different heights to demonstrate what is going on. Make some of them really quite long. If they are all showing the list is going to need to scroll at least on smaller devices.</p><p>Include cells that have different heights to demonstrate what is going on. Make some of them really quite long. If they are all showing the list is going to need to scroll at least on smaller devices.</p><p>Include cells that have different heights to demonstrate what is going on. Make some of them really quite long. If they are all showing the list is going to need to scroll at least on smaller devices.</p> ".fromHTML)
                  samples.append("<h3>List of the day</h3><p>And he said:<ul><li>Expect the unexpected</li><li>The sheep is not a creature of the air</li><li>Chance favours the prepared observer</li></ul>And now, maybe, some commentary on that quote.".fromHTML)
                  samples.append("Something that is quite short but that is more than just one line long on a phone maybe. This might do it.".fromHTML)

                  self.allSamples = samples
            }
      }
}

extension String {
      var fromHTML : NSAttributedString {
            do {
                  return try NSAttributedString(data: Data(self.utf8), options: [
                        .documentType: NSAttributedString.DocumentType.html,
                        .characterEncoding: String.Encoding.utf8.rawValue
                  ], documentAttributes: nil)
            }
            catch {
                  return NSAttributedString(string: self)
            }
      }
}

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

Comments

0

I had the similar problem and solved it by adding frame to UIViewRepresentable with getTextFrame(text),

GeometryReader { geometry in
    List {
        ForEach(self.testdata, id: \.self) { text in
            Group {
                AttributedLabel(attributedText: NSAttributedString(string: text), maxWidth: geometry.size.width - 40)
                // add this frame
                .frame(width: getTextFrame(text).width height: getTextFrame(text).height)
            }.padding(.vertical, 20)
        }
    }
}
func getTextFrame(for text: String, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> CGSize {
        let attributes: [NSAttributedString.Key: Any] = [
            .font: UIFont.preferredFont(forTextStyle: .body)
        ]
        let attributedText = NSAttributedString(string: text, attributes: attributes)
        let width = maxWidth != nil ? min(maxWidth!, CGFloat.greatestFiniteMagnitude) : CGFloat.greatestFiniteMagnitude
        let height = maxHeight != nil ? min(maxHeight!, CGFloat.greatestFiniteMagnitude) : CGFloat.greatestFiniteMagnitude
        let constraintBox = CGSize(width: width, height: height)
        let rect = attributedText.boundingRect(with: constraintBox, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil).integral
        return rect.size
    }

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.