[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 的應用範例,其中也有介紹列表、表格功能的實現。
有任何問題及指教歡迎 與我聯絡 。
===
View the English version of this article here.
本文首次發表於 Medium ➡️ 前往查看