Post

iOS NSAttributedString: Mastering NSTextList & NSTextTab for List Indentation

Discover how iOS developers leverage NSAttributedString with NSTextList and NSTextTab to replicate HTML-like OL/UL/LI list indentation, streamlining text formatting in Swift apps for clearer content structure.

iOS NSAttributedString: Mastering NSTextList & NSTextTab for List Indentation

点击这里查看本文章简体中文版本。

點擊這裡查看本文章正體中文版本。

This post was translated with AI assistance — let me know if anything sounds off!


[iOS] Exploring NSAttributedString: Using NSTextList or NSTextTab to Implement List Indentation

iOS Swift Using NSAttributedString’s NSTextList or NSTextTab to Implement Indentation Similar to HTML List OL/UL/LI Lists

Technical Background

Previously, while developing my open-source project “ZMarkupParser”, a library for converting HTML strings into NSAttributedString objects, I needed to study and implement different HTML components using only NSAttributedString. That was when I first encountered the .paragraphStyle: NSParagraphStyle attributes of NSAttributedString, specifically the textLists: [NSTextList] and tabStops: [NSTextTab] properties. These are two very obscure attributes with scarce information online.

When implementing HTML list indentation conversion, I found examples using these two attributes. First, let’s look at the nested tag structure of an HTML list indentation:

1
2
3
4
5
6
7
8
9
10
11
12
<ul>
    <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li>
    <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li>
    <li>
        ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.
        <ol>
            <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li>
            <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li>
            <li>ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.</li>
        </ol>
    </li>
</ul>

Display effect in the browser:

As shown in the image above, the list supports multiple nested levels and requires indentation according to the hierarchy.

At that time, there were many other HTML tag conversions to implement, so the workload was heavy; I quickly tried using NSTextList or NSTextTab to create list indentation without deep understanding. However, the results were unsatisfactory: spacing was too wide, no alignment, multi-line items misaligned, unclear nested structure, and spacing was uncontrollable. After some trial and error without a solution, I gave up and temporarily used a crude method for layout:

As shown in the image above, the effect is poor because it is manually formatted using spaces and the symbol , with no indentation effect. The only advantage is that the spacing is made of space characters, allowing you to control the size.

This matter was left unresolved, and after open-sourcing it for over a year, I didn’t make any special changes; until recently, I started receiving requests to improve List conversion through Issues, and a developer provided a solution PR. By referring to the use of NSParagraphStyle in that PR, I gained new insights; studying NSTextList or NSTextTab shows potential to perfectly implement indented list functionality!

Final Outcome

As usual, here is the final result image.

  • You can now perfectly convert HTML List Items to NSAttributedString objects in ZMarkupParser ~> v1.9.4 and above.

  • Supports line breaks while maintaining indentation

  • Supports custom indentation spacing size

  • Supports nested structure indentation

  • Supports different List Item Styles, such as Bullet, Disc, Decimal… and even custom symbols

The following is the main text.

Exploring Methods to Implement List Indentation with NSTextList or NSTextTab

It is “or,” not “and.” NSTextList and NSTextTab are not used together; each property can independently achieve list indentation.

Method (1) Exploring List Indentation Using NSTextList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let listLevel1ParagraphStyle = NSMutableParagraphStyle()
listLevel1ParagraphStyle.textLists = [textListLevel1]
        
let listLevel2ParagraphStyle = NSMutableParagraphStyle()
listLevel2ParagraphStyle.textLists = [textListLevel1, textListLevel2]
        
let attributedString = NSMutableAttributedString()
attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 1))\tList Level 1 - 1 StringStringStringStringStringStringStringStringStringStringStringString\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 2))\tList Level 1 - 2\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 3))\tList Level 1 - 3\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
attributedString.append(NSAttributedString(string: "\t\(textListLevel2.marker(forItemNumber: 1))\tList Level 2 - 1\n", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))
attributedString.append(NSAttributedString(string: "\t\(textListLevel2.marker(forItemNumber: 2))\tList Level 2 - 2 StringStringStringStringStringStringStringStringStringStringStringString\n", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))
attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 4))\tList Level 1 - 4\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))        

textView.attributedText = attributedString

Display Effect:

NSTextList provides very few Public APIs, and the controllable parameters are limited to these:

1
2
3
4
5
6
7
8
9
10
11
12
// Item display style
var markerFormat: NSTextList.MarkerFormat { get }

// Starting number for ordered items
var startingItemNumber: Int

// Whether it is an ordered numeric item (available only on iOS 16 and later, this API is still updated)
@available(iOS 16.0, *)
open var isOrdered: Bool { get }

// Returns the marker string; itemNumber is the item index and can be omitted if not ordered
open func marker(forItemNumber itemNumber: Int) -> String

NSTextList.MarkerFormat Style Reference:

  • To increase visibility, display at item list position 8.

Usage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Define an NSMutableParagraphStyle
let listLevel1ParagraphStyle = NSMutableParagraphStyle()
// Define List Item style, item starting position
let textListLevel1 = NSTextList(markerFormat: .decimal, startingItemNumber: 1)
// Assign NSTextList to textLists array
listLevel1ParagraphStyle.textLists = [textListLevel1]
//
NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 1))\Item One\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle])

// Add nested subitems:
// Define subitem List Item style, item starting position
let textListLevel2 = NSTextList(markerFormat: .circle, startingItemNumber: 1)
// Define subitem NSMutableParagraphStyle
let listLevel2ParagraphStyle = NSMutableParagraphStyle()
// Assign parent and child NSTextList to textLists array
listLevel1ParagraphStyle.textLists = [textListLevel1, textListLevel2]

NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 1))\Subitem One-One\n", attributes: [.paragraphStyle: listLevel2ParagraphStyle])

// For nested sub-subitems...
// Just continue appending NSTextList to the textLists array
  • Use \n to separate each list item.

  • Using \t for bullet points ensures that attributedString.string returns the list correctly as plain text.

  • \tBullet points\t will not be displayed, so any formatting after the bullet point will not show (e.g., adding a . will not affect the display).

Usage Issues:

  • Cannot control the left and right spacing of bullet points

  • Cannot customize bullet points, numbered items cannot include . -> 1.

  • It was found that if the parent list is unordered (e.g., .circle), and the child list is an ordered numeric list (e.g., .decimal), the child list’s startingItemNumber setting will not work.

NSTextList can do what was described above, but it is not very practical for real product development; the spacing is too wide, and numbered items lack a . which greatly reduces usability. Online, I only found changing spacing via TextKit NSTextStorage, but I think this method is too hard-coded, so I gave up. The only advantage is that you can easily add nested indented sublists by appending to the textLists array without calculating complex layout issues.

Method (2) Exploring List Indentation Using NSTextTab

NSTextTab allows us to set the position of the \t Tab placeholder, with a default spacing of 28.

We achieve a bullet list effect by setting NSMutableParagraphStyle’s tabStops + headIndent + defaultTabInterval.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
let textListLevel1 = NSTextList(markerFormat: .decimal, startingItemNumber: 1)
let textListLevel2 = NSTextList(markerFormat: .circle, startingItemNumber: 1)
        
let listLevel1ParagraphStyle = NSMutableParagraphStyle()
listLevel1ParagraphStyle.defaultTabInterval = 28
listLevel1ParagraphStyle.headIndent = 29
listLevel1ParagraphStyle.tabStops = [
  NSTextTab(textAlignment: .left, location: 8), // Corresponds to setting in image (1) Location
  NSTextTab(textAlignment: .left, location: 29), // Corresponds to setting in image (2) Location
]
        
let listLevel2ParagraphStyle = NSMutableParagraphStyle()
listLevel2ParagraphStyle.defaultTabInterval = 28
listLevel2ParagraphStyle.headIndent = 44
listLevel2ParagraphStyle.tabStops = [
    NSTextTab(textAlignment: .left, location: 29), // Corresponds to setting in image (3) Location
    NSTextTab(textAlignment: .left, location: 44), // Corresponds to setting in image (4) Location
]
        
let attributedString = NSMutableAttributedString()
attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 1)).\tList Level 1 - 1 StringStringStringStringStringStringStringStringStringStringStringString\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 2)).\tList Level 1 - 2\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 3)).\tList Level 1 - 3\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))
attributedString.append(NSAttributedString(string: "\t\(textListLevel2.marker(forItemNumber: 1))\tList Level 2 - 1\n", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))
attributedString.append(NSAttributedString(string: "\t\(textListLevel2.marker(forItemNumber: 2))\tList Level 2 - 2 StringStringStringStringStringStringStringStringStringStringStringString\n", attributes: [.paragraphStyle: listLevel2ParagraphStyle]))
attributedString.append(NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 4)).\tList Level 1 - 4\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle]))

textView.attributedText = attributedString
  • The tabStops array corresponds to each \t character in the text. NSTextTab can set the alignment direction and the location position (note that this is not setting the width, but the position within the text!).

  • headIndent sets the starting position of the second line and beyond, usually set to the location of the second \t, so that line breaks align with the bullet points.

  • defaultTabInterval sets the default interval spacing for \t. If there are other \t characters in the text, they will be spaced according to this setting.

  • location: Since NSTextTab specifies direction and position, you need to calculate the position yourself; you must calculate the bullet width (the number of digits also affects it) + spacing + the parent item’s indentation distance to achieve the effect shown in the above image.

  • Bullet points are fully customizable.

  • If the location is incorrect or cannot be matched, a direct line break will occur.

The above example simplifies the calculation process and directly provides the answer to help everyone understand the layout method of NSTextTab. For real-world use, you can refer to the complete code below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
let attributedStringFont = UIFont.systemFont(ofSize: UIFont.systemFontSize)
let iterator = ListItemIterator(font: attributedStringFont)
        
//
let listItem = ListItem(type: .decimal, text: "", subItems: [
  ListItem(type: .circle, text: "List Level 1 - 1 StringStringStringStringStringStringStringStringStringStringStringString", subItems: []),
  ListItem(type: .circle, text: "List Level 1 - 2", subItems: []),
  ListItem(type: .circle, text: "List Level 1 - 3", subItems: [
    ListItem(type: .circle, text: "List Level 2 - 1", subItems: []),
    ListItem(type: .circle, text: "List Level 2 - 2 fafasffsafasfsafasas\tfasfasfasfasfasfasfasfsafsaf", subItems: [])
  ]),
  ListItem(type: .circle, text: "List Level 1 - 4", subItems: []),
  ListItem(type: .circle, text: "List Level 1 - 5", subItems: []),
  ListItem(type: .circle, text: "List Level 1 - 6", subItems: []),
  ListItem(type: .circle, text: "List Level 1 - 7", subItems: []),
  ListItem(type: .circle, text: "List Level 1 - 8", subItems: []),
  ListItem(type: .circle, text: "List Level 1 - 9", subItems: []),
  ListItem(type: .circle, text: "List Level 1 - 10", subItems: []),
  ListItem(type: .circle, text: "List Level 1 - 11", subItems: [])
])
let listItemIndent = ListItemIterator.ListItemIndent(preIndent: 8, sufIndent: 8)
textView.attributedText = iterator.start(item: listItem, type: .decimal, indent: listItemIndent)



//
private extension UIFont {
    func widthOf(string: String) -> CGFloat {
        return (string as NSString).size(withAttributes: [.font: self]).width
    }
}

private struct ListItemIterator {
    let font: UIFont
    
    struct ListItemIndent {
        let preIndent: CGFloat
        let sufIndent: CGFloat
    }
    
    func start(item: ListItem, type: NSTextList.MarkerFormat, indent: ListItemIndent) -> NSAttributedString {
        let textList = NSTextList(markerFormat: type, startingItemNumber: 1)
        return item.subItems.enumerated().reduce(NSMutableAttributedString()) { partialResult, listItem in
            partialResult.append(self.iterator(parentTextList: textList, parentIndent: indent.preIndent, sufIndent: indent.sufIndent, item: listItem.element, itemNumber: listItem.offset + 1))
            return partialResult
        }
    }
    
    private func iterator(parentTextList: NSTextList, parentIndent: CGFloat, sufIndent: CGFloat, item: ListItem, itemNumber:Int) -> NSAttributedString {
        let paragraphStyle = NSMutableParagraphStyle()
        
        
        // e.g. 1.
        var itemSymbol = parentTextList.marker(forItemNumber: itemNumber)
        switch parentTextList.markerFormat {
        case .decimal, .uppercaseAlpha, .uppercaseLatin, .uppercaseRoman, .uppercaseHexadecimal, .lowercaseAlpha, .lowercaseLatin, .lowercaseRoman, .lowercaseHexadecimal:
            itemSymbol += "."
        default:
            break
        }
        
        // width of "1."
        let itemSymbolIndent: CGFloat = ceil(font.widthOf(string: itemSymbol))
        
        let tabStops: [NSTextTab] = [
            .init(textAlignment: .left, location: parentIndent),
            .init(textAlignment: .left, location: parentIndent + itemSymbolIndent + sufIndent)
        ]

        let thisIndent = parentIndent + itemSymbolIndent + sufIndent
        paragraphStyle.headIndent = thisIndent
        paragraphStyle.tabStops = tabStops
        paragraphStyle.defaultTabInterval = 28
        
        let thisTextList = NSTextList(markerFormat: item.type, startingItemNumber: 1)
        //
        return item.subItems.enumerated().reduce(NSMutableAttributedString(string: "\t\(itemSymbol)\t\(item.text)\n", attributes: [.paragraphStyle: paragraphStyle, .font: font])) { partialResult, listItem in
            partialResult.append(self.iterator(parentTextList: thisTextList, parentIndent: thisIndent, sufIndent: sufIndent, item: listItem.element, itemNumber: listItem.offset + 1))
            return partialResult
        }
    }
}

private struct ListItem {
    var type: NSTextList.MarkerFormat
    var text: String
    var subItems: [ListItem]
}

  • We declare a simple ListItem object to encapsulate sublist items, using recursion to combine and calculate the spacing and content of the item list.

  • NSTextList only uses the marker method to generate list symbols, but you can choose not to use it and implement your own instead.

  • To increase the spacing before and after bullet points, you can directly set preIndent and sufIndent.

  • Because position calculation requires using Font to measure width, the text must have .font set to ensure accurate calculation.

Completed

At first, I hoped to achieve this directly using NSTextList, but the results and customization options were very limited. In the end, I had to rely on a DIY approach with NSTextTab, controlling the position of \t to manually assemble the bullet points. It’s a bit troublesome, but the effect perfectly meets the requirements!

The goal was achieved, but I still haven’t fully mastered NSTextTab (for example: different directions? relative position of Location?). Official documentation and online resources are really scarce, so I’ll study it again if I get the chance.

Full Example Download

Business Information

A small tool that converts HTML strings into NSAttributedString, supporting custom style specifications and custom tag features.

References

If you have any questions or feedback, feel free to contact me.


Buy me a beer

This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.