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.
点击这里查看本文章简体中文版本。
點擊這裡查看本文章正體中文版本。
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 thatattributedString.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’sstartingItemNumber
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 themarker
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
andsufIndent
.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
- ObjC String Rendering / ObjC China — String Rendering
This article offers full examples of NSAttributedString usage, including list and table implementations.
If you have any questions or feedback, feel free to contact me.
This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.