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

ZhgChgLi
ZRealm Dev.
Published in
25 min readJun 1, 2024

--

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 列表縮排巢狀標籤結構:

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

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 非常稀少,能控制的參數也就這些:

// 項目顯示樣式
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 展示。

使用方式:

// 定義一個 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 來達成仿項目列表的效果。

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 排版的方式,因此直接簡化了計算加總過程,把答案寫上去,如果要用在實際場景可參考以下完整程式碼:

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

參考資料

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

--

--

ZhgChgLi
ZRealm Dev.

探索世界、求知若渴、教學相長;更愛電影、美劇、西音、運動、生活. www.zhgchg.li