SwiftUI中HTML转富文本列表显示异常问题排查与方案咨询
问题描述
我有两段可能包含HTML字符串的文本:
- HTML字符串仅包含加粗、斜体等简单文本格式。
- 需要将它们合并为富文本并在列表中展示。
- 每个列表项可能带有左侧元素、右侧元素,或同时包含两者。
我用封装了UILabel的组件实现可换行文本视图来展示富文本,这个组件必须设置preferredMaxLayoutWidth才能正常工作,所以得指定宽度:当文本视图是水平轴唯一元素时,设置固定宽度能正常显示:
但在包含富文本的列表中,文本视图会随机出现上下额外内边距:
HTML转富文本的实现代码
func htmlToAttributedString( fontName: String = AppFonts.hebooRegular.rawValue, fontSize: CGFloat = 12.0, color: UIColor? = nil, lineHeightMultiple: CGFloat = 1 ) -> NSAttributedString? { guard let data = data(using: .unicode, allowLossyConversion: true) else { return nil } do { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineHeightMultiple = lineHeightMultiple paragraphStyle.alignment = .left paragraphStyle.lineBreakMode = .byWordWrapping paragraphStyle.lineSpacing = 0 let attributedString = try NSMutableAttributedString( data: data, options: [ .documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue ], documentAttributes: nil ) attributedString.addAttribute( NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, attributedString.length) ) if let font = UIFont(name: fontName, size: fontSize) { attributedString.addAttribute( NSAttributedString.Key.font, value: font, range: NSMakeRange(0, attributedString.length) ) } if let color { attributedString.addAttribute( NSAttributedString.Key.foregroundColor, value: color, range: NSMakeRange(0, attributedString.length) ) } return attributedString } catch { return nil } }
UILabel封装组件代码
public struct AttributedLabel: UIViewRepresentable { public func makeUIView(context: Context) -> UIViewType { let label = UIViewType() label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping let result = items.map { item in let result = item.text.htmlToAttributedString( fontName: item.fontName, fontSize: item.fontSize, color: item.color, lineHeightMultiple: lineHeightMultiple ) guard let result else { return NSAttributedString(string: "") } return result }.reduce(NSMutableAttributedString(string: "")) { result, text in if result.length > 0 { result.append(NSAttributedString(string: " ")) } result.append(text) return result } let height = result.boundingRect( with: .init(width: width, height: .infinity), options: [ .usesFontLeading ], context: nil ) debugPrint("Calculated height: \(height)") onHeightCalculated?(height.height) label.attributedText = result label.preferredMaxLayoutWidth = width label.textAlignment = .left return label } public func updateUIView(_ uiView: UIViewType, context: Context) { uiView.sizeToFit() } public typealias UIViewType = UILabel public let items: [AttributedItem] public let width: CGFloat public let lineHeightMultiple: CGFloat public let onHeightCalculated: ((CGFloat) -> Void)? public struct AttributedItem { public let color: UIColor? public var fontName: String public var fontSize: CGFloat public var text: String public init( fontName: String, fontSize: CGFloat, text: String, color: UIColor? = nil ) { self.fontName = fontName self.fontSize = fontSize self.text = text self.color = color } } public init( items: [AttributedItem], width: CGFloat, lineHeightMultiple: CGFloat = 1, onHeightCalculated: ((CGFloat) -> Void)? = nil ) { self.items = items self.width = width self.lineHeightMultiple = lineHeightMultiple self.onHeightCalculated = onHeightCalculated } }
我用boundingRect计算高度并传给父视图,否则文本视图高度会在300到1000之间随机变化,但这个方法只对单行文本高度计算精准,多行文本会超出计算边界:
列表项组件代码
private func ItemView(_ item: AppNotification) -> some View { HStack(alignment: .top, spacing: 10) { Left(item) SingleAxisGeometryReader( axis: .horizontal, alignment: .topLeading ) { width in AttributedLabel( items: [ .init( fontName: AppFonts.hebooRegular.rawValue, fontSize: 16, text: item.data?.message ?? "", color: UIColor(hexString: "#222222") ), .init( fontName: AppFonts.hebooRegular.rawValue, fontSize: 16, text: item.timeAgo ?? "", color: UIColor(hexString: "#727783") ) ], width: width, lineHeightMultiple: 0.8, onHeightCalculated: { textHeight = $0 } ) .frame(alignment: .topLeading) .onTapGesture { if let deepLink = item.data?.deepLink { viewStore.send(.openDeepLink(deepLink)) } } } .frame(maxHeight: textHeight) .background(Color.yellow) Right(item) } .padding(.bottom, 16) }
请问问题出在哪里?或者有没有其他可行的实现方案?
解决方案
问题根源
- HTML解析的默认样式残留:通过HTML生成的富文本自带默认段落样式(如上下边距),手动设置的样式未完全覆盖,导致额外内边距。
boundingRect计算偏差:该方法仅返回文本理想边界,未考虑UILabel实际渲染时的font leading、lineHeightMultiple等因素,多行文本误差明显。- 跨框架布局不同步:SwiftUI与UIKit的布局周期不一致,手动传递高度设置
maxHeight的方式容易出现滞后或错误。
修复方案
1. 清理HTML解析的默认样式
修改htmlToAttributedString,先移除原有段落样式再应用自定义配置:
func htmlToAttributedString( fontName: String = AppFonts.hebooRegular.rawValue, fontSize: CGFloat = 12.0, color: UIColor? = nil, lineHeightMultiple: CGFloat = 1 ) -> NSAttributedString? { guard let data = data(using: .unicode, allowLossyConversion: true) else { return nil } do { let attributedString = try NSMutableAttributedString( data: data, options: [ .documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue ], documentAttributes: nil ) // 移除原有段落样式 attributedString.removeAttribute(.paragraphStyle, range: NSRange(location: 0, length: attributedString.length)) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineHeightMultiple = lineHeightMultiple paragraphStyle.alignment = .left paragraphStyle.lineBreakMode = .byWordWrapping paragraphStyle.lineSpacing = 0 paragraphStyle.paragraphSpacing = 0 paragraphStyle.paragraphSpacingBefore = 0 attributedString.addAttribute( .paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: attributedString.length) ) if let font = UIFont(name: fontName, size: fontSize) { attributedString.addAttribute( .font, value: font, range: NSRange(location: 0, length: attributedString.length) ) } if let color { attributedString.addAttribute( .foregroundColor, value: color, range: NSRange(location: 0, length: attributedString.length) ) } return attributedString } catch { return nil } }
2. 用UIKit原生布局计算高度
修改AttributedLabel,让UILabel自身计算真实高度,替代boundingRect:
public struct AttributedLabel: UIViewRepresentable { public func makeUIView(context: Context) -> UIViewType { let label = UIViewType() label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping label.textAlignment = .left return label } public func updateUIView(_ uiView: UIViewType, context: Context) { let result = items.map { item in item.text.htmlToAttributedString( fontName: item.fontName, fontSize: item.fontSize, color: item.color, lineHeightMultiple: lineHeightMultiple ) ?? NSAttributedString(string: "") }.reduce(NSMutableAttributedString(string: "")) { result, text in if result.length > 0 { result.append(NSAttributedString(string: " ")) } result.append(text) return result } uiView.attributedText = result uiView.preferredMaxLayoutWidth = width uiView.layoutIfNeeded() onHeightCalculated?(uiView.intrinsicContentSize.height) } // 其余代码保持不变 }
3. 优化SwiftUI布局逻辑
移除手动设置maxHeight的代码,让SwiftUI自动适配UIKit视图的固有尺寸:
private func ItemView(_ item: AppNotification) -> some View { HStack(alignment: .top, spacing: 10) { Left(item) SingleAxisGeometryReader( axis: .horizontal, alignment: .topLeading ) { width in AttributedLabel( items: [ .init( fontName: AppFonts.hebooRegular.rawValue, fontSize: 16, text: item.data?.message ?? "", color: UIColor(hexString: "#222222") ), .init( fontName: AppFonts.hebooRegular.rawValue, fontSize: 16, text: item.timeAgo ?? "", color: UIColor(hexString: "#727783") ) ], width: width, lineHeightMultiple: 0.8 ) .frame(minHeight: 0, maxHeight: .infinity, alignment: .topLeading) .onTapGesture { if let deepLink = item.data?.deepLink { viewStore.send(.openDeepLink(deepLink)) } } } .background(Color.yellow) Right(item) } .padding(.bottom, 16) }
4. 替代方案:SwiftUI原生解析HTML(iOS15+)
使用SwiftUI自带的AttributedString解析HTML,彻底避免跨框架布局问题:
extension String { func toSwiftUIAttributedString( fontName: String = AppFonts.hebooRegular.rawValue, fontSize: CGFloat = 12.0, color: Color? = nil, lineHeightMultiple: CGFloat = 1 ) -> AttributedString? { guard let data = data(using: .utf8) else { return nil } do { var attributedString = try AttributedString( data: data, options: .init(documentType: .html), locale: .current ) let font = Font.custom(fontName, size: fontSize) attributedString.font = font if let color { attributedString.foregroundColor = color } var paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineHeightMultiple = lineHeightMultiple paragraphStyle.lineSpacing = 0 paragraphStyle.paragraphSpacing = 0 paragraphStyle.paragraphSpacingBefore = 0 attributedString.paragraphStyle = paragraphStyle return attributedString } catch { return nil } } } // 列表项使用示例 private func ItemView(_ item: AppNotification) -> some View { HStack(alignment: .top, spacing: 10) { Left(item) VStack(alignment: .leading, spacing: 4) { if let message = item.data?.message, let attrMsg = message.toSwiftUIAttributedString( fontName: AppFonts.hebooRegular.rawValue, fontSize: 16, color: Color("#222222"), lineHeightMultiple: 0.8 ) { Text(attrMsg) } if let time = item.timeAgo, let attrTime = time.toSwiftUIAttributedString( fontName: AppFonts.hebooRegular.rawValue, fontSize: 16, color: Color("#727783"), lineHeightMultiple: 0.8 ) { Text(attrTime) } } .frame(maxWidth: .infinity, alignment: .topLeading) .background(Color.yellow) .onTapGesture { if let deepLink = item.data?.deepLink { viewStore.send(.openDeepLink(deepLink)) } } Right(item) } .padding(.bottom, 16) }
内容的提问来源于stack exchange,提问作者Olcay Ertaş
相关产品推荐
相关产品推荐

