Kevin Renskers
Kevin Renskers

Reputation: 5912

Frame height problem with custom UIViewRepresentable UITextView in SwiftUI

I'm building a custom UITextView for SwiftUI, via UIViewRepresentable. It's meant to display NSAttributedString, and handle link presses. Everything works, but the frame height is completely messed up when I show this view inside of a NavigationView with an inline title.

import SwiftUI

struct AttributedText: UIViewRepresentable {
  class Coordinator: NSObject, UITextViewDelegate {
    var parent: AttributedText

    init(_ view: AttributedText) {
      parent = view
    }

    func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
      parent.linkPressed(URL)
      return false
    }
  }

  let content: NSAttributedString
  @Binding var height: CGFloat
  var linkPressed: (URL) -> Void

  public func makeUIView(context: Context) -> UITextView {
    let textView = UITextView()
    textView.backgroundColor = .clear
    textView.isEditable = false
    textView.isUserInteractionEnabled = true
    textView.delegate = context.coordinator
    textView.isScrollEnabled = false
    textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    textView.dataDetectorTypes = .link
    textView.textContainerInset = .zero
    textView.textContainer.lineFragmentPadding = 0
    return textView
  }

  public func updateUIView(_ view: UITextView, context: Context) {
    view.attributedText = content

    // Compute the desired height for the content
    let fixedWidth = view.frame.size.width
    let newSize = view.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude))

    DispatchQueue.main.async {
      self.height = newSize.height
    }
  }

  func makeCoordinator() -> Coordinator {
    Coordinator(self)
  }
}


struct ContentView: View {

  private var text: NSAttributedString {
    NSAttributedString(string: "Eartheart is the principal settlement for the Gold Dwarves in East Rift and it is still the cultural and spiritual center for its people. Dwarves take on pilgrimages to behold the great holy city and take their trips from other countries and the deeps to reach their goal, it use to house great temples and shrines to all the Dwarven pantheon and dwarf heroes but after the great collapse much was lost.\n\nThe lords of their old homes relocated here as well the Deep Lords. The old ways of the Deep Lords are still the same as they use intermediaries and masking themselves to undermine the attempts of assassins or drow infiltrators. The Gold Dwarves outnumber every other race in the city and therefor have full control of the city and it's communities.")
  }

  @State private var height: CGFloat = .zero

  var body: some View {
    NavigationView {
      List {
        AttributedText(content: text, height: $height, linkPressed: { url in print(url) })
          .frame(height: height)

        Text("Hello world")
      }
      .listStyle(GroupedListStyle())
      .navigationBarTitle(Text("Content"), displayMode: .inline)
    }
  }
}

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

When you run this code, you will see that the AttributedText cell will be too small to hold its content.

enter image description here

When you remove the displayMode: .inline parameter from the navigationBarTitle, it shows up fine.

enter image description here

But if I add another row to display the height value (Text("\(height)")), it again breaks.

enter image description here

Maybe it's some kind of race condition triggered by view updates via state changes? The height value itself is correct, it's just that the frame isn't actually that tall. Is there a workaround?

Using ScrollView with a VStack does solve the problem, but I'd really really prefer to use a List due to the way the content is shown in the real app.

Upvotes: 9

Views: 7181

Answers (5)

Daniel Marx
Daniel Marx

Reputation: 764

I recently refactored some code in our Application to SwiftUI and also found some similar approaches obviously found on Stackoverflow. After some research, try and errors I ended up with quite a simple solution which completely suites our purposes:

  • SwiftUI Text component which supports attributed strings
  • Support for HTML and clickable links
  • auto adjust height and no scrolling within the UITextView
  • supports iOS 13.0+
  • easy to use
  • (optional) non selectable
    import UIKit
    import SwiftUI
    
    protocol StringFormatter {
        func format(string: String) -> NSAttributedString?
    }
    
    struct AttributedText: UIViewRepresentable {
        typealias UIViewType = UITextView
        
        @State
        private var attributedText: NSAttributedString?
        private let text: String
        private let formatter: StringFormatter
        private var delegate: UITextViewDelegate?
        
        init(_ text: String, _ formatter: StringFormatter, delegate: UITextViewDelegate? = nil) {
            self.text = text
            self.formatter = formatter
            self.delegate = delegate
        }
        
        func makeUIView(context: Context) -> UIViewType {
            let view = ContentTextView()
            view.setContentHuggingPriority(.required, for: .vertical)
            view.setContentHuggingPriority(.required, for: .horizontal)
            view.contentInset = .zero
            view.textContainer.lineFragmentPadding = 0
            view.delegate = delegate
            view.backgroundColor = .clear
            return view
        }
        
        func updateUIView(_ uiView: UITextView, context: Context) {
            guard let attributedText = attributedText else {
                generateAttributedText()
                return
            }
            
            uiView.attributedText = attributedText
            uiView.invalidateIntrinsicContentSize()
        }
        
        private func generateAttributedText() {
            guard attributedText == nil else { return }
            // create attributedText on main thread since HTML formatter will crash SwiftUI
            DispatchQueue.main.async {
                self.attributedText = self.formatter.format(string: self.text)
            }
        }
        
        /// ContentTextView
        /// subclass of UITextView returning contentSize as intrinsicContentSize
        private class ContentTextView: UITextView {
            override var canBecomeFirstResponder: Bool { false }
            
            override var intrinsicContentSize: CGSize {
                frame.height > 0 ? contentSize : super.intrinsicContentSize
            }
        }
    }

Formatter


    import Foundation
    
    class HTMLFormatter: StringFormatter {
        func format(string: String) -> NSAttributedString? {
            guard let data = string.data(using: .utf8),
                  let attributedText = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil)
            else { return nil }
            
            return attributedText
        }
    }

Sample


    import SwiftUI
    
    struct AttributedTextListView: View {
        let html = """
                    <html>
                        <body>
                            <h1>Hello, world!</h1>
                            <span>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span>
                            <a href="https://example.org">Example</a>
                        </body>
                    </html>
                    """
        var body: some View {
            List {
                Group {
                    // delegate is optional
                    AttributedText(html, HTMLFormatter(), delegate: nil)
                    AttributedText(html, HTMLFormatter(), delegate: nil)
                    AttributedText(html, HTMLFormatter(), delegate: nil)
                }.background(Color.gray.opacity(0.1))
            }
            
        }
    }

Final Result

Upvotes: 9

devyhan93
devyhan93

Reputation: 59

To get the height of UIViewRepresentable View, it is placed in the background of text with the same height.

  private let text: String = "Eartheart is the principal settlement for the Gold Dwarves in East Rift and it is still the cultural and spiritual center for its people. Dwarves take on pilgrimages to behold the great holy city and take their trips from other countries and the deeps to reach their goal, it use to house great temples and shrines to all the Dwarven pantheon and dwarf heroes but after the great collapse much was lost.\n\nThe lords of their old homes relocated here as well the Deep Lords. The old ways of the Deep Lords are still the same as they use intermediaries and masking themselves to undermine the attempts of assassins or drow infiltrators. The Gold Dwarves outnumber every other race in the city and therefor have full control of the city and it's communities."


...

            Text(text)
                .font(.system(size: 12))
                .fixedSize(horizontal: false, vertical: true)
                .opacity(0)
                .background(
                    CustomUIViewRepresentableTextView(text: text)
                    // same font size 
                )

Upvotes: 2

Xaxxus
Xaxxus

Reputation: 1809

So I had this exact issue.

The solution isnt pretty but I found one that works:

Firstly, you need to subclass UITextView so that you can pass its content size back to SwiftIU:

public class UITextViewWithSize: UITextView {
    @Binding var size: CGSize
    
    public init(size: Binding<CGSize>) {
        self._size = size
        
        super.init(frame: .zero, textContainer: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    public override func layoutSubviews() {
        super.layoutSubviews()
        self.size = sizeThatFits(.init(width: frame.width, height: 0))
    }
}

Once you have done this, you need to create a UIViewRepresentable for your custom UITextView:

public struct HyperlinkTextView: UIViewRepresentable {
    public typealias UIViewType = UITextViewWithSize
    
    private var text: String
    private var font: UIFont?
    private var foreground: UIColor?
    @Binding private var size: CGSize
    
    public init(_ text: String, font: UIFont? = nil, foreground: UIColor? = nil, size: Binding<CGSize>) {
        self.text = text
        self.font = font
        self.foreground = foreground
        self._size = size
    }
    
    public func makeUIView(context: Context) -> UIViewType {
        
        let view = UITextViewWithSize(size: $size)
        
        view.isEditable = false
        view.dataDetectorTypes = .all
        view.isScrollEnabled = false
        view.text = text
        view.textContainer.lineBreakMode = .byTruncatingTail
        view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        view.setContentCompressionResistancePriority(.required, for: .vertical)
        view.textContainerInset = .zero
        
        if let font = font {
            view.font = font
        } else {
            view.font = UIFont.preferredFont(forTextStyle: .body)
        }
        
        if let foreground = foreground {
            view.textColor = foreground
        }
        
        view.sizeToFit()
        
        return view
    }
    
    public func updateUIView(_ uiView: UIViewType, context: Context) {
        uiView.text = text
        uiView.layoutSubviews()
    }
}

Now that we have easy access to the view's content size, we can use that to force the view to fit into a container of that size. For some reason, simply using a .frame on the view doesnt work. The view just ignores the frame its given. But when putting it into a geometry reader, it seems to grow as expected.

GeometryReader { proxy in
    HyperlinkTextView(bio, size: $bioSize)
        .frame(maxWidth: proxy.frame(in: .local).width, maxHeight: .infinity)
}
.frame(height: bioSize.height)

Upvotes: 4

user12208004
user12208004

Reputation: 1988

If you will not change text, you can calculate width and height and use them as frame even without binding.

List {
        // you don't need binding height
        AttributedText(content: text, linkPressed: { url in print(url) })
          .frame(height: frameSize(for: text).height)

        Text("Hello world")
      }
func frameSize(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
    }

With Extension:

extension String {
    func frameSize(maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> CGSize {
        let attributes: [NSAttributedString.Key: Any] = [
            .font: UIFont.preferredFont(forTextStyle: .body)
        ]
        let attributedText = NSAttributedString(string: self, 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
    }
}

Upvotes: 4

Kevin Renskers
Kevin Renskers

Reputation: 5912

I managed to find a version of my AttributedText View that mostly works.

struct AttributedText: UIViewRepresentable {
  class HeightUITextView: UITextView {
    @Binding var height: CGFloat

    init(height: Binding<CGFloat>) {
      _height = height
      super.init(frame: .zero, textContainer: nil)
    }

    required init?(coder: NSCoder) {
      fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
      super.layoutSubviews()
      let newSize = sizeThatFits(CGSize(width: frame.size.width, height: CGFloat.greatestFiniteMagnitude))
      if height != newSize.height {
        height = newSize.height
      }
    }
  }

  class Coordinator: NSObject, UITextViewDelegate {
    var parent: AttributedText

    init(_ view: AttributedText) {
      parent = view
    }

    func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
      parent.linkPressed(URL)
      return false
    }
  }

  let content: NSAttributedString
  @Binding var height: CGFloat
  var linkPressed: (URL) -> Void

  public func makeUIView(context: Context) -> UITextView {
    let textView = HeightUITextView(height: $height)
    textView.attributedText = content
    textView.backgroundColor = .clear
    textView.isEditable = false
    textView.isUserInteractionEnabled = true
    textView.delegate = context.coordinator
    textView.isScrollEnabled = false
    textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    textView.dataDetectorTypes = .link
    textView.textContainerInset = .zero
    textView.textContainer.lineFragmentPadding = 0
    return textView
  }

  public func updateUIView(_ textView: UITextView, context: Context) {
    if textView.attributedText != content {
      textView.attributedText = content

      // Compute the desired height for the content
      let fixedWidth = textView.frame.size.width
      let newSize = textView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude))

      DispatchQueue.main.async {
        self.height = newSize.height
      }
    }
  }

  func makeCoordinator() -> Coordinator {
    Coordinator(self)
  }
}

In certain cases you can see the view suddenly grow in size, but in almost all my screens where I am using this, it's a massive improvement. Auto-sizing UITextView in SwiftUI is still a huge headache though, and any answers that improve this would be greatly appreciated :)

Upvotes: 2

Related Questions