Search code examples
iosswiftuikituilabelnsattributedstring

One string with multiple paragraph styles


I want to have one string with different paragraphs styles. The goal is to customize the paragraph/line spacing for different parts of the string. I researched and found this answer but since I added multiple new line characters, not sure how to implement.

Design

This is my goal in terms of layout:

layout

Code

This is the code I have which makes it look like the left image above. Please see the comments Not working in the code. Notice how the spacing is set for the main string, but the other strings can't then set their own custom spacing:

struct BookModel: Codable {
    let main: String
    let detail: String
}

func createAttributedString(for model: BookModel) -> NSMutableAttributedString {
    let fullString = NSMutableAttributedString()
    
    let mainString = NSMutableAttributedString(string: model.main)
    let mainStringParagraphStyle = NSMutableParagraphStyle()
    mainStringParagraphStyle.alignment = .center
    mainStringParagraphStyle.lineSpacing = 10
    mainStringParagraphStyle.paragraphSpacing = 30
    let mainStringAttributes: [NSAttributedString.Key: Any] = [.paragraphStyle: mainStringParagraphStyle]
    
    let spacingAfterQuote = NSMutableAttributedString(string: "\n")
    
    let lineImageAttachment = NSTextAttachment(image: #imageLiteral(resourceName: "line-image"))
    let lineImageString = NSMutableAttributedString(attachment: lineImageAttachment)
    let lineParagraphStyle = NSMutableParagraphStyle()
    lineParagraphStyle.alignment = .left
    lineParagraphStyle.lineSpacing = 0 // Not working - instead of 0 it is 30 from `mainStringParagraphStyle`
    lineParagraphStyle.paragraphSpacing = 0 // Not working - instead of 0 it is 30 from `mainStringParagraphStyle`
    let lineAttributes: [NSAttributedString.Key: Any] = [.paragraphStyle: lineParagraphStyle]
    
    let spacingAfterSeparator = NSMutableAttributedString(string: "\n")
    let spacingAfterSeparatorParagraphStyle = NSMutableParagraphStyle()
    spacingAfterSeparatorParagraphStyle.alignment = .left
    spacingAfterSeparatorParagraphStyle.lineSpacing = 0 // Not working - instead of 0 it is 30 from `mainStringParagraphStyle`
    spacingAfterSeparatorParagraphStyle.paragraphSpacing = 5 // Not working - instead of 5 it is 30 from `mainStringParagraphStyle`
    let spacingAfterSeparatorAttributes: [NSAttributedString.Key: Any] = [.paragraphStyle: spacingAfterSeparatorParagraphStyle]
    
    let detailString = NSMutableAttributedString(string: model.detail)
    let detailStringAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 20)]
    
    fullString.append(mainString)
    fullString.append(spacingAfterQuote)
    fullString.append(lineImageString)
    fullString.append(spacingAfterSeparator)
    fullString.append(detailString)
    
    fullString.addAttributes(mainStringAttributes, range: fullString.mutableString.range(of: model.main))
    fullString.addAttributes(lineAttributes, range: fullString.mutableString.range(of: lineImageString.string))
    fullString.addAttributes(spacingAfterSeparatorAttributes, range: fullString.mutableString.range(of: spacingAfterSeparator.string))
    fullString.addAttributes(detailStringAttributes, range: fullString.mutableString.range(of: model.detail))
    
    return fullString
}

Any thoughts on how to achieve the image on the right?

Question Update 1

The code below is working! There is only one slight problem. When I add lineSpacing, there is extra space at the end of the last line in main string. Notice that I have this set to zero: mainStringParagraphStyle.paragraphSpacing = 0, but there is still space at the end because mainStringParagraphStyle.lineSpacing = 60.

The reason I ask this is to have more fine grain control of spacing. For example, have a perfect number between the line image and main string. Any thoughts on this?

I put code and picture below:

test

Code:

    func createAttributedString(for model: BookModel) -> NSMutableAttributedString {
        let fullString = NSMutableAttributedString()
        
        let mainStringParagraphStyle = NSMutableParagraphStyle()
        mainStringParagraphStyle.alignment = .center
        mainStringParagraphStyle.paragraphSpacing = 0 // The space after the end of the paragraph
        mainStringParagraphStyle.lineSpacing = 60 // NOTE: This controls the spacing after the last line instead of just `paragraphSpacing`
            
        let mainString = NSAttributedString(string: "\(model.main)\n",
                                            attributes: [.paragraphStyle: mainStringParagraphStyle, .font: UIFont.systemFont(ofSize: 24)])
        
        let lineImageStringParagraphStyle = NSMutableParagraphStyle()
        lineImageStringParagraphStyle.alignment = .center
        
        let lineImageAttachment = NSTextAttachment(image: #imageLiteral(resourceName: "line-view"))
        let lineImageString = NSMutableAttributedString(attachment: lineImageAttachment)
        lineImageString.addAttribute(.paragraphStyle, value: lineImageStringParagraphStyle, range: NSRange(location: 0, length: lineImageString.length))
        
        let detailStringParagraphStyle = NSMutableParagraphStyle()
        detailStringParagraphStyle.alignment = .center
        detailStringParagraphStyle.paragraphSpacingBefore = 5 // The distance between the paragraph’s top and the beginning of its text content
        detailStringParagraphStyle.lineSpacing = 0
        
        let detailString = NSAttributedString(string: "\n\(model.detail)",
                                              attributes: [.paragraphStyle: detailStringParagraphStyle, .font: UIFont.systemFont(ofSize: 12)])
        
        fullString.append(mainString)
        fullString.append(lineImageString)
        fullString.append(detailString)
        
        return fullString
    }

Solution

  • Updated answer:

    Here's a new example. I set the spacing at the top and at the bottom of the paragraph with the image. This allows line breaks to be used in model.main and model.detail if needed. Also, instead of lineSpacing, I used lineHeightMultiple. This parameter affects the indentation between lines without affecting the last line:

    func createAttributedString(for model: BookModel) -> NSAttributedString {
        let fullString = NSMutableAttributedString()
        
        let mainStringParagraphStyle = NSMutableParagraphStyle()
        mainStringParagraphStyle.alignment = .center
        mainStringParagraphStyle.lineHeightMultiple = 2 // Note that this is a multiplier, not a value in points
        
        let mainString = NSAttributedString(string: "\(model.main)\n", attributes: [.paragraphStyle: mainStringParagraphStyle, .font: UIFont.systemFont(ofSize: 24)])
        
        let lineImageStringParagraphStyle = NSMutableParagraphStyle()
        lineImageStringParagraphStyle.alignment = .center
        lineImageStringParagraphStyle.paragraphSpacingBefore = 10 // The space before image
        lineImageStringParagraphStyle.paragraphSpacing = 20 // The space after image
        
        let lineImageAttachment = NSTextAttachment(image: #imageLiteral(resourceName: "line-image"))
        let lineImageString = NSMutableAttributedString(attachment: lineImageAttachment)
        lineImageString.addAttribute(.paragraphStyle, value: lineImageStringParagraphStyle, range: NSRange(location: 0, length: lineImageString.length))
        
        let detailStringParagraphStyle = NSMutableParagraphStyle()
        detailStringParagraphStyle.alignment = .center
        
        let detailString = NSAttributedString(string: "\n\(model.detail)", attributes: [.paragraphStyle: detailStringParagraphStyle, .font: UIFont.systemFont(ofSize: 12)])
        
        fullString.append(mainString)
        fullString.append(lineImageString)
        fullString.append(detailString)
        
        return fullString
    }
    

    enter image description here

    Also have a look at my library StringEx. It allows you to create a NSAttributedString from the template and apply styles without having to write a ton of code:

    import StringEx
    
    ...
    
    func createAttributedString(for model: BookModel) -> NSAttributedString {
        let pattern = "<main />\n<image />\n<detail />"
        let ex = pattern.ex
        
        ex[.tag("main")]
            .insert(model.main)
            .style([
                .aligment(.center),
                .lineHeightMultiple(2),
                .font(.systemFont(ofSize: 24))
            ])
        
        let lineImageAttachment = NSTextAttachment(image: #imageLiteral(resourceName: "line-image"))
        let lineImageString = NSAttributedString(attachment: lineImageAttachment)
        
        ex[.tag("image")]
            .insert(lineImageString)
            .style([
                .aligment(.center),
                .paragraphSpacingBefore(10),
                .paragraphSpacing(20)
            ])
        
        ex[.tag("detail")]
            .insert(model.detail)
            .style([
                .aligment(.center),
                .font(.systemFont(ofSize: 12))
            ])
        
        return ex.attributedString
    }
    

    Old answer:

    I think you can just set the spacing at the end of the first paragraph (main string) and the spacing at the beginning of the last paragraph (detail string):

    func createAttributedString(for model: BookModel) -> NSMutableAttributedString {
        let fullString = NSMutableAttributedString()
        
        let mainStringParagraphStyle = NSMutableParagraphStyle()
        mainStringParagraphStyle.alignment = .center
        mainStringParagraphStyle.paragraphSpacing = 30 // The space after the end of the paragraph
        
        let mainString = NSAttributedString(string: "\(model.main)\n", attributes: [.paragraphStyle: mainStringParagraphStyle])
        
        let lineImageStringParagraphStyle = NSMutableParagraphStyle()
        lineImageStringParagraphStyle.alignment = .center
        
        let lineImageAttachment = NSTextAttachment(image: #imageLiteral(resourceName: "line-image"))
        let lineImageString = NSMutableAttributedString(attachment: lineImageAttachment)
        lineImageString.addAttribute(.paragraphStyle, value: lineImageStringParagraphStyle, range: NSRange(location: 0, length: lineImageString.length))
        
        let detailStringParagraphStyle = NSMutableParagraphStyle()
        detailStringParagraphStyle.alignment = .center
        detailStringParagraphStyle.paragraphSpacingBefore = 5 // The distance between the paragraph’s top and the beginning of its text content
        
        let detailString = NSAttributedString(string: "\n\(model.detail)", attributes: [.paragraphStyle: detailStringParagraphStyle])
        
        fullString.append(mainString)
        fullString.append(lineImageString)
        fullString.append(detailString)
        
        return fullString
    }
    

    Results