Home Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString in iOS
Post
Cancel

Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString in iOS

[iOS] Exploring the Use of NSTextList or NSTextTab for List Indentation with NSAttributedString

Implementing list indentation similar to HTML List OL/UL/LI using NSTextList or NSTextTab with NSAttributedString in iOS Swift

Technical Background

Previously, while developing my open-source project “ZMarkupParser,” a library for converting HTML strings into NSAttributedString objects, I needed to research and implement the use of NSAttributedString to handle various HTML components. During this process, I came across the .paragraphStyle: NSParagraphStyle attribute of NSAttributedString Attributes, specifically the textLists: [NSTextList] and tabStops: [NSTextTab] properties. These are two very obscure attributes with limited online resources.

When initially implementing HTML list indentation conversion, I found examples showing that these two attributes could be used to achieve this. Let’s first take a look at the nested tag structure of 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 above image, the list supports multiple layers of nested structures and needs to be indented according to the level.

At that time, there were many other HTML tag conversion tasks that needed to be implemented, which was a lot of work. I quickly attempted to use NSTextList or NSTextTab to create the list indentation without delving deep into understanding. However, the results were not as expected - the spacing was too large, there was no alignment, multiple lines were misaligned, the nested structure was not clear, and spacing could not be controlled. After playing around with it for a while without finding a solution, I abandoned it and temporarily used a makeshift layout:

The above image effect is very poor because it was actually manually formatted using spaces and the symbol -, without any indentation effect. The only advantage is that the spacing is composed of blank symbols, and the size can be controlled manually.

This matter was left unresolved, and I didn’t particularly work on it even after being open-sourced for over a year. It wasn’t until recently that I started receiving Issues requesting improvements to list conversion, and a developer provided a solution PR. By referencing the usage of NSParagraphStyle in that PR, I was inspired once again. Researching NSTextList or NSTextTab could potentially allow for the perfect implementation of indented list functionality!

Final Result

As usual, let’s start with the final result image.

  • Now, in ZMarkupParser ~> v1.9.4 and above versions, HTML List Items can be perfectly converted into NSAttributedString objects.
  • Supports maintaining indentation when line breaks occur.
  • Supports customizing the size of indentation spacing.
  • Supports nested structure indentation.
  • Supports different List Item Styles, such as Bullet, Disc, Decimal… and even custom symbols.

The main text begins below.

Exploring Methods of Achieving List Indentation with NSTextList or NSTextTab

It’s “or” not “and” in the relationship between NSTextList and NSTextTab, meaning that these two attributes are not used together. Each of them can achieve list indentation independently.

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:

The Public API provided by NSTextList is very limited, and the parameters that can be controlled are as follows:

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 in iOS >= 16, surprisingly this API has been updated)
@available(iOS 16.0, *)
open var isOrdered: Bool { get }

// Returns the item symbol string, with itemNumber as the item number. It can be omitted if it is a non-ordered numeric item
open func marker(forItemNumber itemNumber: Int) -> String

NSTextList.MarkerFormat Styles:

  • To increase visibility, displayed at position 8 of the item list.

Usage:

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

// Adding nested sub-items:
// Define sub-item List Item style, starting position of items
let textListLevel2 = NSTextList(markerFormat: .circle, startingItemNumber: 1)
// Define sub-item NSMutableParagraphStyle
let listLevel2ParagraphStyle = NSMutableParagraphStyle()
// Assign parent and child NSTextList to the textLists array
listLevel1ParagraphStyle.textLists = [textListLevel1, textListLevel2]

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

// Sub-items of nested sub-items...
Continue appending NSTextList to the textLists array as needed
  • Use \n to differentiate each list item.
  • Use \tItem symbol\t to allow access to the list result when accessing the attributedString.string as plain text.
  • \tItem symbol\t will not be displayed, so any processing done after the item symbol will not be visible (e.g., adding . after the item number will not affect the display).

Issues with usage:

  • Unable to control the left and right margins of the item symbol.
  • Unable to customize the item symbol, and inability to add . to numeric items -> 1..
  • Found that if the parent item list is non-ordered (e.g., .circle), and the child items are ordered numeric items (e.g., .decimal), the startingItemNumber setting for child items will be ignored.

What NSTextList can do and what it can be used for is as described above. However, it is not very user-friendly in practical product development applications; the spacing is too wide, numeric items lack ., greatly reducing usability. Online, I only found a way to change the spacing through TextKit NSTextStorage, which I think is too hard-coding, so I abandoned it. The only benefit is that it allows for simple nesting of indented sub-item lists by appending textLists arrays, without the need for complex layout calculations.

Method (2) Exploring List Indentation Using NSTextTab

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

We achieve a list-like effect by setting tabStops + headIndent + defaultTabInterval in NSMutableParagraphStyle.

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), // Corresponding settings as shown in figure (1) Location
  NSTextTab(textAlignment: .left, location: 29), // Corresponding settings as shown in figure (2) Location
]
        
let listLevel2ParagraphStyle = NSMutableParagraphStyle()
listLevel2ParagraphStyle.defaultTabInterval = 28
listLevel2ParagraphStyle.headIndent = 44
listLevel2ParagraphStyle.tabStops = [
    NSTextTab(textAlignment: .left, location: 29), // Corresponding settings as shown in figure (3) Location
    NSTextTab(textAlignment: .left, location: 44), // Corresponding settings as shown in figure (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 symbol in the text. NSTextTab can be set with Alignment direction and Location position (please note that it is not setting the width, but the position in the text!).
  • headIndent sets the position from the starting point for the second line, usually set to the Location of the second \t, so that line breaks align with the item symbol.
  • defaultTabInterval sets the default interval spacing for \t. If there are other \t in the text, they will be spaced according to this setting.
  • location: Because NSTextTab specifies direction and position, you need to calculate the position yourself. You need to calculate the width of the item symbol (the number of digits also affects) + spacing + indentation distance within the parent item to achieve the effect shown in the figure above.
  • Item symbols can be fully customized.
  • If the location is incorrect or cannot be met, there will be direct line breaks.

The example above is simplified to help you understand the layout of NSTextTab. The calculation and summarization process is simplified, and the answer is written directly. If you want to use it in a real scenario, you can refer to the following complete code:

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 sub-list items, combining them recursively and calculating the spacing and content of the item list.
  • NSTextList only uses the marker method to generate list symbols, but it can also be implemented independently without using it.
  • To widen the space before and after the item symbol, you can directly set preIndent and sufIndent.
  • Since position calculation requires the use of Font to calculate width, make sure to set .font for the text to ensure accurate calculation.

Conclusion

Initially, we hoped that we could achieve the desired effect directly using NSTextList, but the result and customization level were both poor. In the end, we had to rely on a makeshift solution with NSTextTab, controlling the position of \t to manually combine item symbols. It’s a bit cumbersome, but the effect perfectly meets the requirements!

The goal has been achieved, but I still haven’t fully mastered the knowledge of NSTextTab (for example, different directions? Relative positions of Location?). The official documentation and online resources are too scarce. I’ll study it further if I have the chance.

Download Full Example of This Document

Commerce

A tool to help you convert HTML strings to NSAttributedStrings, with support for custom style assignment and custom tag functionality.

Reference Material

  • ObjC String Rendering This article contains a complete example of NSAttributedString application, including an introduction to the implementation of lists and tables functionality.

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

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



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

Plane.so Docker Self-Hosted Setup Record

Travelogue 2024 Second Visit to Kyushu 9-Day Free and Easy Trip, Entering Japan via Busan→Fukuoka Cruise