You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI ExpandableText组件文本更新异常及Binding初始化问题

解决ExpandableText组件文本更新同步及Binding初始化问题

问题核心

  1. 初始代码中text是普通私有变量,无法响应外部状态变更,导致onChange能捕获新值但组件内text仍保留旧值。
  2. 改用@Binding后,直接将Binding<String>赋值给State<String>的_shrinkText会触发类型不匹配错误。
  3. 文本更新后,未重新执行截断计算逻辑,导致旧的收缩文本和状态残留。

修复方案

1. 正确初始化@State与@Binding

将text改为@Binding类型,初始化_shrinkText时取Binding的wrappedValue(当前绑定的实际值):

init(_ text: Binding<String>, lineLimit: Int, font: UIFont = UIFont(name: "Manrope-Regular", size: 16)!) {
    self._text = text
    self.lineLimit = lineLimit
    // 用Binding的当前值初始化shrinkText
    self._shrinkText = State(wrappedValue: text.wrappedValue)
    self.font = font
}

2. 监听文本变化,重置状态并重新计算

添加onChange(of: text)回调,在文本更新时重置展开状态、截断标记,并重新触发收缩文本计算:

.onChange(of: text) { newValue in
    expanded = false
    truncated = false
    shrinkText = newValue
    // 延迟执行确保视图布局完成,容器宽度准确
    DispatchQueue.main.async {
        calculateShrinkText()
    }
}

3. 抽离截断计算逻辑为独立方法

将原background中的几何计算逻辑抽成方法,方便在onAppear、文本变化、容器宽度变化时复用:

private func calculateShrinkText(containerWidth: CGFloat? = nil) {
    guard let containerWidth = containerWidth else { return }
    
    let lineHeight = font.lineHeight
    let maxAllowedHeight = lineHeight * CGFloat(lineLimit)
    let size = CGSize(width: containerWidth, height: .greatestFiniteMagnitude)
    let attributes: [NSAttributedString.Key: Any] = [.font: font]
    
    // 先判断原文本是否需要截断
    let fullText = NSAttributedString(string: text, attributes: attributes)
    let fullTextRect = fullText.boundingRect(with: size, options: .usesLineFragmentOrigin, context: nil)
    
    guard fullTextRect.height > maxAllowedHeight else {
        truncated = false
        shrinkText = text
        return
    }
    
    truncated = true
    var low = 0
    var high = text.count
    var mid = high
    var finalPrefixCount = 0
    
    while (high - low) > 1 {
        mid = (low + high) / 2
        let testText = String(text.prefix(mid)) + "..." + moreLessText
        let attributedTestText = NSAttributedString(string: testText, attributes: attributes)
        let testRect = attributedTestText.boundingRect(with: size, options: .usesLineFragmentOrigin, context: nil)
        
        if testRect.height <= maxAllowedHeight {
            finalPrefixCount = mid
            low = mid
        } else {
            high = mid
        }
    }
    
    shrinkText = finalPrefixCount > 0 ? String(text.prefix(finalPrefixCount)) + "..." : text
}

4. 调整背景视图的触发逻辑

在background中监听容器宽度变化(如屏幕旋转),并在onAppear和宽度变更时调用计算方法:

.background(
    Text(text).lineLimit(lineLimit)
        .background(GeometryReader { visibleTextGeometry in
            Color.clear.onAppear {
                calculateShrinkText(containerWidth: visibleTextGeometry.size.width)
            }
            .onChange(of: visibleTextGeometry.size.width) { newWidth in
                calculateShrinkText(containerWidth: newWidth)
            }
        })
        .hidden()
)

完整修复代码

import SwiftUI

struct ExpandableText: View {
    @State private var expanded: Bool = false
    @State private var truncated: Bool = false
    @State private var shrinkText: String
    @Binding var text: String

    let font: UIFont
    let lineLimit: Int
    
    private var moreLessText: String {
        truncated ? (expanded ? "Read Less" : "Read More") : ""
    }
    
    init(_ text: Binding<String>, lineLimit: Int, font: UIFont = UIFont(name: "Manrope-Regular", size: 16)!) {
        self._text = text
        self.lineLimit = lineLimit
        self._shrinkText = State(wrappedValue: text.wrappedValue)
        self.font = font
    }
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(expanded ? text : shrinkText)
                .allowsTightening(true)
                .fixedSize(horizontal: false, vertical: true)
                .font(Font(font))
                .dynamicTypeSize(...DynamicTypeSize.large)

            if truncated {
                Button {
                    withAnimation(.easeInOut) {
                        expanded.toggle()
                    }
                } label: {
                    Text(moreLessText)
                        .font(Font(font.withWeight(.semibold)))
                        .dynamicTypeSize(...DynamicTypeSize.large)
                }
            }
        }
        .lineLimit(expanded ? nil : lineLimit)
        .onChange(of: text) { newValue in
            expanded = false
            truncated = false
            shrinkText = newValue
            DispatchQueue.main.async {
                calculateShrinkText()
            }
        }
        .background(
            Text(text).lineLimit(lineLimit)
                .background(GeometryReader { visibleTextGeometry in
                    Color.clear.onAppear {
                        calculateShrinkText(containerWidth: visibleTextGeometry.size.width)
                    }
                    .onChange(of: visibleTextGeometry.size.width) { newWidth in
                        calculateShrinkText(containerWidth: newWidth)
                    }
                })
                .hidden()
        )
        .font(Font(font))
        .dynamicTypeSize(...DynamicTypeSize.large)
    }
    
    private func calculateShrinkText(containerWidth: CGFloat? = nil) {
        guard let containerWidth = containerWidth else { return }
        
        let lineHeight = font.lineHeight
        let maxAllowedHeight = lineHeight * CGFloat(lineLimit)
        let size = CGSize(width: containerWidth, height: .greatestFiniteMagnitude)
        let attributes: [NSAttributedString.Key: Any] = [.font: font]
        
        let fullText = NSAttributedString(string: text, attributes: attributes)
        let fullTextRect = fullText.boundingRect(with: size, options: .usesLineFragmentOrigin, context: nil)
        
        guard fullTextRect.height > maxAllowedHeight else {
            truncated = false
            shrinkText = text
            return
        }
        
        truncated = true
        var low = 0
        var high = text.count
        var mid = high
        var finalPrefixCount = 0
        
        while (high - low) > 1 {
            mid = (low + high) / 2
            let testText = String(text.prefix(mid)) + "..." + moreLessText
            let attributedTestText = NSAttributedString(string: testText, attributes: attributes)
            let testRect = attributedTestText.boundingRect(with: size, options: .usesLineFragmentOrigin, context: nil)
            
            if testRect.height <= maxAllowedHeight {
                finalPrefixCount = mid
                low = mid
            } else {
                high = mid
            }
        }
        
        shrinkText = finalPrefixCount > 0 ? String(text.prefix(finalPrefixCount)) + "..." : text
    }
}

// 使用示例
struct ExpandableTextDemo: View {
    @State private var demoText = "这是一段很长的测试文本,用来验证ExpandableText组件的功能,当文本超过指定行数时,会自动截断并显示Read More按钮,点击后可以展开查看全部内容。"
    
    var body: some View {
        VStack(spacing: 20) {
            ExpandableText($demoText, lineLimit: 2)
            
            Button("更新文本") {
                demoText = "这是更新后的新文本,同样是一段较长的内容,用来测试组件是否能正确响应外部文本的变化,并重新计算截断后的显示内容。"
            }
        }
        .padding()
    }
}

struct ExpandableText_Previews: PreviewProvider {
    static var previews: some View {
        ExpandableTextDemo()
    }
}

关键优化点

  • 用@Binding实现组件与外部状态的双向同步,确保文本更新实时生效。
  • 抽离截断逻辑,适配视图初始化、文本更新、容器宽度变化等多种场景。
  • 修复State与Binding的类型不匹配问题,正确初始化内部状态。
  • 添加动画过渡,提升交互体验。

内容的提问来源于stack exchange,提问作者amelia

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.14 01:25:07