Home iOS NSAttributedString 探究使用 NSTextList 或 NSTextTab 實現列表縮排
Post
Cancel

iOS NSAttributedString 探究使用 NSTextList 或 NSTextTab 實現列表縮排

[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 實現列表縮排方法探究

是「或」不是「與」 NSTextListNSTextTab 沒有一起使用的關係,兩個屬性個別都能實現列表縮排功能。

方法(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

我們透過設定 NSMutableParagraphStyletabStops + 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 的小工具,並且支援客製化樣式指定、客製化標籤功能。

參考資料

有任何問題及指教歡迎 與我聯絡

===

View the English version of this article here.

本文首次發表於 Medium ➡️ 前往查看


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

Plane.so Docker Self-Hosted 自架紀錄

遊記 2024 二訪九州 9 日自由行,經釜山→博多郵輪入境