【问题标题】:Frame height problem with custom UIViewRepresentable UITextView in SwiftUISwiftUI 中自定义 UIViewRepresentable UITextView 的框架高度问题
【发布时间】:2020-02-27 15:50:57
【问题描述】:

我正在通过 UIViewRepresentable 为 SwiftUI 构建自定义 UITextView。它旨在显示NSAttributedString,并处理链接按下。一切正常,但是当我在带有内联标题的 NavigationView 中显示此视图时,框架高度完全混乱了。

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()
  }
}

当您运行此代码时,您会看到AttributedText 单元格太小而无法容纳其内容。

当您从navigationBarTitle 中删除displayMode: .inline 参数时,它显示正常。

但如果我添加另一行来显示高度值 (Text("\(height)")),它会再次中断。

也许这是某种由视图更新通过状态更改触发的竞争条件? height 值本身是正确的,只是框架实际上并没有那么高。有解决办法吗?

使用ScrollViewVStack 确实可以解决问题,但由于内容在真实应用中的显示方式,我真的更喜欢使用List

【问题讨论】:

    标签: ios swiftui


    【解决方案1】:

    如果你不改变文字,你可以计算宽度和高度,即使没有绑定也可以将它们用作框架。

    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
        }
    

    带扩展:

    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
        }
    }
    

    【讨论】:

      【解决方案2】:

      所以我遇到了这个确切的问题。

      解决方案并不漂亮,但我找到了一个可行的解决方案:

      首先,你需要继承 UITextView 以便你可以将它的内容大小传回给 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))
          }
      }
      

      完成此操作后,您需要为您的自定义 UITextView 创建一个 UIViewRepresentable:

      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()
          }
      }
      

      现在我们可以轻松访问视图的内容大小,我们可以使用它来强制视图适合该大小的容器。出于某种原因,仅在视图上使用 .frame 是行不通的。视图只是忽略了它给定的框架。但将其放入几何阅读器时,它似乎按预期增长。

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

      【讨论】:

        【解决方案3】:

        我设法找到了最有效的AttributedText 视图版本。

        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)
          }
        }
        

        在某些情况下,您可以看到视图突然变大,但在我使用它的几乎所有屏幕中,这是一个巨大的改进。不过,在 SwiftUI 中自动调整 UITextView 的大小仍然是一个令人头疼的问题,任何改善这一点的答案都将不胜感激:)

        【讨论】:

          【解决方案4】:

          我最近将我们应用程序中的一些代码重构为 SwiftUI,并且在 Stackoverflow 上也发现了一些类似的方法。经过一些研究、尝试和错误,我最终得到了一个非常简单的解决方案,完全符合我们的目的:

          • 支持属性字符串的 SwiftUI 文本组件
          • 支持 HTML 和可点击链接
          • 在 UITextView 内自动调整高度且不滚动
          • 支持 iOS 13.0+
          • 易于使用
          • (可选)不可选
              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
                  }
                  
                  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
                      }
                  }
              }
          

          格式化程序

          
              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
                  }
              }
          
          

          样本

          
              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))
                      }
                      
                  }
              }
          
          

          【讨论】:

          • 这应该是公认的答案,因为这是我唯一一次看到在没有高度绑定黑客的情况下实现了这一点。非常感谢。
          【解决方案5】:

          获取UIViewRepresentableView的高度,将其放置在等高文本的背景中。

            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 
                          )
          

          【讨论】:

            猜你喜欢
            • 2020-09-29
            • 2020-05-07
            • 2020-03-23
            • 1970-01-01
            • 1970-01-01
            • 2021-09-30
            • 1970-01-01
            • 2013-01-24
            • 1970-01-01
            相关资源
            最近更新 更多