iOS NSAttributedString 列表缩排实作|NSTextList 与 NSTextTab 巢状排版解析
针对 iOS 开发者解决 NSAttributedString 列表缩排难题,解析 NSTextList 与 NSTextTab 两种实现方式,优化巢状列表对齐与间距,提升多层列表排版精准度,完整示范客制化符号与缩排控制技巧。
Click here to view the English version of this article.
點擊這裡查看本文章正體中文版本。
基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。
[iOS] NSAttributedString 探究使用 NSTextList 或 NSTextTab 实现列表缩排
iOS Swift 使用 NSAttributedString 的 NSTextList 或 NSTextTab 实现类似 HTML List OL/UL/LI 列表缩排功能
技术背景
之前在开发我的开源专案「 ZMarkupParser 」一个用于转换 HTML String 成 NSAttributedString 物件的 Library,需要研究、实现单纯使用 NSAttributedString 实现不同 HTML 组件,那时候才接触到 NSAttributedString Attributes
的 .paragraphStyle: NSParagraphStyle
中的 textLists: [NSTextList]
与 tabStops: [NSTextTab]
属性,是两个非常冷门的属性,网路资料稀少。
当初在实现 HTML 列表缩排转换时,就查到范例可以使用这两个属性来达成,先来看一下 HTML 列表缩排巢状标签结构:
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>
在浏览器中的显示效果:
如上图所示,列表支援多层巢状结构,并且需要照层级缩排。
那时候因为还有许多其他 HTML 标签转换的工作需要实现,工作量很大;只快速尝试用 NSTextList or NSTextTab 组合出列表缩排,没有深入了解;但结果不如预期,间距过大、没有对齐、多行会跑掉、巢状结构不明显、无法控制间距,稍微玩了一下试不出解答就放弃,暂时用土炮方式排版:
如上图效果很差,因为其实是自己用空白跟符号 -
手动排版, 无缩排效果 ,唯一好处只有间距是空白符号组成,大小可以自己控制。
这件事就这样不了了之了,开源了一年多也没特别去改他;直到最近陆续收到希望能完善 List 转换的 Issues 并且有开发者提供 解法 PR ,参考该 PR 中的 NSParagraphStyle
使用方式,才让我又重新有了新的启发;研究好 NSTextList 或 NSTextTab 是有机会完美实现缩排列表功能的!
最终成果
照惯例先上最终成果图。
现在已可以在 ZMarkupParser ~>
v1.9.4
以上版本,完美转换 HTML List Item 成 NSAttributedString 物件。支持换行保持缩排
支持自订缩排间距大小
支持巢状结构缩排
支持不同 List Item Style 列表样式,如 Bullet, Disc, Decimal…甚至客制化符号
以下正文开始。
NSTextList 或 NSTextTab 实现列表缩排方法探究
是「或」不是「与」 NSTextList
和 NSTextTab
没有一起使用的关系,两个属性个别都能实现列表缩排功能。
方法(1) 使用 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
显示效果:
NSTextList
提供的 Public API 非常稀少,能控制的参数也就这些:
1
2
3
4
5
6
7
8
9
10
11
12
// 项目显示样式
var markerFormat: NSTextList.MarkerFormat { get }
// 有序项目数字起始,从几开始
var startingItemNumber: Int
// 是否为有序数字项目 (iOS >= 16 才可用,这 API 居然有在更新)
@available(iOS 16.0, *)
open var isOrdered: Bool { get }
// 回传项目符号字串,itemNumber 带入项目编号,如果为非有序数字项目则可省略
open func marker(forItemNumber itemNumber: Int) -> String
NSTextList.MarkerFormat 样式对照:
- 为增加识别度,以项目列表位置 8 展示。
使用方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 定义一个 NSMutableParagraphStyle
let listLevel1ParagraphStyle = NSMutableParagraphStyle()
// 定义 List Item 样式, 项目起始位置
let textListLevel1 = NSTextList(markerFormat: .decimal, startingItemNumber: 1)
// 赋予 NSTextList 到 textLists Array
listLevel1ParagraphStyle.textLists = [textListLevel1]
//
NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 1))\项目一\n", attributes: [.paragraphStyle: listLevel1ParagraphStyle])
// 增加巢状子项目:
// 定义子项目 List Item 样式, 项目起始位置
let textListLevel2 = NSTextList(markerFormat: .circle, startingItemNumber: 1)
// 定义子项目 NSMutableParagraphStyle
let listLevel2ParagraphStyle = NSMutableParagraphStyle()
// 赋予 母,子 NSTextList 到 textLists Array
listLevel1ParagraphStyle.textLists = [textListLevel1, textListLevel2]
NSAttributedString(string: "\t\(textListLevel1.marker(forItemNumber: 1))\项目一之一\n", attributes: [.paragraphStyle: listLevel2ParagraphStyle])
// 巢状子项目的子项目...
继续 append NSTextList 到 textLists array 即可
使用
\n
区别每个列表项目使用
\t项目符号\t
,目的是让attributedString.string
存取纯文字字串时也能得到列表结果。\t项目符号\t
不会显示出来,因此在项目符号后做什么加工都不会显示 (e.g. 例如加上.
,并不会影响显示)
使用上的问题:
无法控制项目符号左右间距大小
无法客制化项目符号、数字项目无法加上
.
->1.
有发现若母项目列表是非有序项目 (如:
.cicrle
),子项目是有序数字项目 (如:.decimal
) 时,子项目的startingItemNumber
设定会失效
NSTextList 能做的、可以做的就如同上述,在实际产品开发应用上并不是那么的好用;间距太宽、数字项目没有 .
大大减少实用性,网路上也只找到 透过 TextKit NSTextStorage 改变间距 的方式,我觉得这方式太 hard-coding 了,放弃;唯一好处是可以间单的透过 Append textLists array 增加巢状缩排子项目列表,不需要计算复杂的排版问题。
方法(2) 使用 NSTextTab 实现列表缩排方法探究
NSTextTab 可以让我们设定 \t
Tab 的占位 位置 ,预设间隔为 28
。
我们透过设定 NSMutableParagraphStyle
的 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), // 对应设定如图 (1) Location
NSTextTab(textAlignment: .left, location: 29), // 对应设定如图 (2) Location
]
let listLevel2ParagraphStyle = NSMutableParagraphStyle()
listLevel2ParagraphStyle.defaultTabInterval = 28
listLevel2ParagraphStyle.headIndent = 44
listLevel2ParagraphStyle.tabStops = [
NSTextTab(textAlignment: .left, location: 29), // 对应设定如图 (3) Location
NSTextTab(textAlignment: .left, location: 44), // 对应设定如图 (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
tabStops
Array 会对应文本中的每一个\t
符号,NSTextTab
可设定 Alignment 方向、Location 位置 ( 请注意不是设定宽度,是文本中中的位置! )headIndent
设定第二行开始距离起始点的位置,通常设为第二个\t
的 Location,这样换行才能对齐项目符号。defaultTabInterval
设定预设\t
的 Interval 间距,如果文字中还有其他\t
会依照此设定拉开间距。location:
因为 NSTextTab 是指定方向与位置的,因此需要自行计算出位置;要计算项目符号宽度(位数也会影响) +间距+母项目内缩的距离,才能排出如上图的效果。项目符号完全可自订
如果
location
有误或无法符合,会出现直接的断行
上面的范例为了让大家理解 NSTextTab
排版的方式,因此直接简化了计算加总过程,把答案写上去,如果要用在实际场景可参考以下完整程式码:
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]
}
我们宣告一个简单的
ListItem
物件封装子列表项目,透过递回组合、计算出项目列表间距与内容。NSTextList
只使用marker
方法产生列表符号,也可以不使用改成自行实现要加宽项目符号前后宽度可直接透过设置
preIndent
,sufIndent
达成。因为需要计算位置,要使用
Font
来计算宽度,所以文字需要设定.font
确保计算正确
完成
一开始奢望可以直接使用 NSTextList 就能达成,但效果跟客制化程度都很差;最后还是只能靠土炮 NSTextTab 用控制 \t
位置的方式自行组合项目符号来达成,有点麻烦,不过效果可以完美符合需求!
目的达成了,但依然没有完全掌握
NSTextTab
的知识(例如: 不同方向?Location 的相对位置?);官方文件、网路资料实在太少,有缘再来研究了。
本文完整范例下载
工商
一个帮你把 HTML String 转换成 NSAttributedString 的小工具,并且支援客制化样式指定、客制化标签功能。
参考资料
- ObjC String Rendering / ObjC 中国 — 字符串渲染 这篇文章有完整 NSAttributedString 的应用范例,其中也有介绍列表、表格功能的实现。
有任何问题及指教欢迎 与我联络 。
本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。