Post

iOS NSAttributedString 列表缩排实作|NSTextList 与 NSTextTab 巢状排版解析

针对 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 实现列表缩排方法探究

是「或」不是「与」 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 的小工具,并且支援客制化样式指定、客制化标签功能。

参考资料

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

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