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

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

请问问题出在哪里?或者有没有其他可行的实现方案?


解决方案

问题根源

  1. HTML解析的默认样式残留:通过HTML生成的富文本自带默认段落样式(如上下边距),手动设置的样式未完全覆盖,导致额外内边距。
  2. boundingRect计算偏差:该方法仅返回文本理想边界,未考虑UILabel实际渲染时的font leading、lineHeightMultiple等因素,多行文本误差明显。
  3. 跨框架布局不同步: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ş

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.02 21:31:08