返回

SwiftUI UITextView 布局问题: 禁用滚动解决方案

IOS

UIViewRepresentable 中 UITextView 的布局问题:禁用滚动时的解决方案

在 SwiftUI 中使用 UIViewRepresentable 封装 UITextView 时,如果禁用滚动(isScrollEnabled = false),可能会遇到布局失效的问题。 具体表现为, UITextView 无法正确地根据内容调整自身大小,导致显示异常甚至空白。这与 UITextView 的内部布局机制以及 SwiftUI 的布局方式有关。

问题分析:intrinsicContentSize失效的原因

isScrollEnabled 为 true 时,UITextView 会根据其 contentSize 来确定其自身大小。 但是当 isScrollEnabled 设置为 false 时,UITextView 不再滚动,它的内容会受到自身 frame 的约束。 这导致 intrinsicContentSize 的计算结果与预期不符,或者失效。 因为没有开启滚动条, 没有内容尺寸需要适配了,自身尺寸可能变为零, 导致外部 ScrollView 也无法正确渲染。

解决方案一: 强制更新 intrinsicContentSize

一种方案是在 updateUIView 中强制触发 UITextView 重新计算 intrinsicContentSize。 可以通过修改其 text 或其他属性来达到这个目的。 由于SwiftUI的生命周期原因,最好放到主线程异步执行。

struct SimpleTextView: UIViewRepresentable {
    @State private var attributedText: NSAttributedString?
    var text: String

    func makeUIView(context: Context) -> UITextView {
        let view = ContentTextView()
        view.setContentHuggingPriority(.required, for: .vertical)
        view.setContentHuggingPriority(.required, for: .horizontal)
        view.isSelectable = true
        view.isEditable = false
        view.isScrollEnabled = false // 禁用滚动
        view.isUserInteractionEnabled = true
        return view
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        guard let attributedText = attributedText else {
            generateAttributedText()
            return
        }
        uiView.attributedText = attributedText

        // 强制更新 intrinsicContentSize
        DispatchQueue.main.async {
            uiView.invalidateIntrinsicContentSize()
        }
    }

    private func generateAttributedText() {
        guard attributedText == nil else { return }
        DispatchQueue.main.async {
            self.attributedText = NSAttributedString(string: text, attributes: [:])
        }
    }
    
    private class ContentTextView: UITextView {
        override var canBecomeFirstResponder: Bool { false }
        
        override var intrinsicContentSize: CGSize {
            frame.height > 0 ? contentSize : super.intrinsicContentSize
        }
    }
}

操作步骤:

  1. UITextViewisScrollEnabled 属性设置为 false
  2. updateUIView 方法中使用 DispatchQueue.main.async 包装 uiView.invalidateIntrinsicContentSize() ,保证在主线程异步执行。

原理: invalidateIntrinsicContentSize() 会通知 UITextView 其内部状态已发生变化,需要重新计算 intrinsicContentSize。通过在主线程异步调用,避免阻塞UI线程,并且可以等待UI渲染完毕后再进行重新计算。

解决方案二: 手动计算内容高度并设置Frame

若强制更新 intrinsicContentSize 无效,可选择手动计算 UITextView 的内容高度,并设置其 frame。 这涉及到使用 sizeThatFits(_:) 方法来测量文本内容的尺寸。
该方法用于根据给定的尺寸约束来计算文本所需的最佳尺寸。

struct SimpleTextView: UIViewRepresentable {
    @State private var attributedText: NSAttributedString?
    var text: String

    func makeUIView(context: Context) -> UITextView {
        let view = ContentTextView()
        view.setContentHuggingPriority(.required, for: .vertical)
        view.setContentHuggingPriority(.required, for: .horizontal)
        view.isSelectable = true
        view.isEditable = false
        view.isScrollEnabled = false // 禁用滚动
        view.isUserInteractionEnabled = true
        return view
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        guard let attributedText = attributedText else {
            generateAttributedText()
            return
        }
        uiView.attributedText = attributedText

        // 手动计算内容高度
        let fixedWidth = uiView.frame.size.width
        let newSize = uiView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude))
        uiView.frame.size = CGSize(width: max(newSize.width, fixedWidth), height: newSize.height) // Fix auto layout error

        // 可以不需要手动 invalidate 了,因为 frame 已经改变
        //DispatchQueue.main.async {
        //   uiView.invalidateIntrinsicContentSize()
        //}

    }

    private func generateAttributedText() {
        guard attributedText == nil else { return }
        DispatchQueue.main.async {
            self.attributedText = NSAttributedString(string: text, attributes: [:])
        }
    }
    
    private class ContentTextView: UITextView {
        override var canBecomeFirstResponder: Bool { false }
        
        override var intrinsicContentSize: CGSize {
            //frame.height > 0 ? contentSize : super.intrinsicContentSize  // 注释掉这里,让系统自动计算
            return contentSize
        }
    }
}

操作步骤:

  1. updateUIView 方法中,首先获取 UITextView 的当前宽度。
  2. 使用 sizeThatFits(_:) 方法计算文本内容在给定宽度下的所需高度。 需要提供一个高度上限,例如 CGFloat.greatestFiniteMagnitude 表示不限制高度。
  3. 设置 UITextViewframe 为计算出的尺寸。确保宽度不小于初始宽度,以防止产生 Auto Layout 的警告或错误。
  4. ContentTextView 不需要覆写 intrinsicContentSize , 直接使用父类的contentSize

原理: sizeThatFits(_:) 方法会根据文本的属性(字体、字号等)以及给定的宽度约束来计算文本实际需要的尺寸。通过手动设置 frame,可以强制 UITextView 按照计算结果进行布局,绕过 isScrollEnabled = false 带来的限制。

安全建议

  • 在更新 UITextViewframe 之后,需要仔细测试布局效果,确保在不同设备和屏幕尺寸下都能正确显示。
  • 尽可能避免频繁地更新 frame,这可能会影响性能。 考虑使用缓存或其他优化策略来减少布局计算的次数。
  • 注意 UITextView 的其他属性,例如 textContainerInsetcontentInset,这些属性也可能影响布局结果, 需要根据实际情况进行调整。
  • 检查 constraints (约束),可能约束产生了影响。