SwiftUI ExpandableText组件文本更新异常及Binding初始化问题
解决ExpandableText组件文本更新同步及Binding初始化问题
问题核心
- 初始代码中
text是普通私有变量,无法响应外部状态变更,导致onChange能捕获新值但组件内text仍保留旧值。 - 改用
@Binding后,直接将Binding<String>赋值给State<String>的_shrinkText会触发类型不匹配错误。 - 文本更新后,未重新执行截断计算逻辑,导致旧的收缩文本和状态残留。
修复方案
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
相关产品推荐
相关产品推荐

