1

I have two strings that can contain HTML strings.

  • HTML string can only contain simple text formatting like bold, italic etc.
  • I need to combine these in an attributed string and show in a list.
  • Every list item may have a leading or a trailing item or both at the same time.

I am using a UILabel wrapper to have a wrapping text view. I am using this wrapper to show this attributed string. This wrapper only works correctly if I set preferredMaxLayoutWidth value. So I need to give a width to wrapper. when the text view is the only item on horizontal axis it is working fine because I am giving it a constant width:

constant width case image

In case the list with attributed strings, the text view randomly having extra padding at the top and bottom:

items with extra padding at the top and bottom

This is how I am generating attributed string from HTML string:

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

And this is my UILabel wrapper:

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

I am using boundingRect function to calculate the height and passing it to the parent view to set the text view height correctly. Other vise text view getting random height values between 300 to 1000.

This approach only calculates single line height precisely. For multi line texts, text goes out of the calculated bound:

improved approach

And this is my item component:

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

Can you see what is the problem? Or do you have an other approach?

Olcay Ertaş
  • 5,987
  • 8
  • 76
  • 112

0 Answers0