如何优雅的做一个小说阅读功能

目标

  1. 使用 TextKit 快速分页
  2. 使用 UIPageViewController

支持平台

iOS, iPadOS
也许还支持 Mac Calalyst ?

使用语言

Swift

视图结构

|- UIViewController // 根视图, 可添加菜单显示, 手势操作等
    |- UIPageController // 章节视图, 一页对应一章
        | - UIPageController // 章节内容分页视图, 将单章内容进行分页显示
        |   | - UIViewController // 单页显示视图, 对应单页数据
        |   |   |- UITextView // 文字视图
        |   |
        |   | - UIViewController
        |   |   |- UITextView
        |   |
        |   | ...
        |
        | - UIPageController
        |   | - UIViewController
        |   |   |- UITextView
        |   |
        |   | - UIViewController
        |   |   |- UITextView
        |   |
        |   | ...
        |
        | ...

章节内容分页视图中, 只要在返回单页显示视图的代理中返回 nil, 即可实现章节内容翻到最后一页时, 继续翻页翻到下一章节的逻辑

分页实现

首先, 一定要先确定好 TextView 的大小与内容间距, 即文字显示区域的大小, 这将严重影响到分页后的数据能不能正常显示

其次, 首行缩进最好用空格代替, 而不是用 NSParagraphStylefirstLineHeadIndent 属性来实现, 否则会出现某段落从中间被分开, 下一页依然被缩进的情况

首行缩进的空格数量可用以下逻辑计算:

let normalWidth = "你好".size(font: textFont).width // 请根据内容语言改变文字
let speaceWidth = " ".size(font: textFont).width // 一个空格的宽
let speaceCount = Int(normalWidth / speaceWidth)
let speace = String(repeating: " ", count: speaceCount)

然后在每段前添加空格

let result = content.string.components(separatedBy: "\n").map { "\(speace)\($0)" }

这样就可以在每段首行添加一个合适的缩进了

接下来就是重点的分页了


第一步, 前期参数准备:

  1. 准备好处理完成的 NSAttributedString, 最好包含各种字体, 颜色, 格式等设置信息, 避免分页视图拿到数据后再次生成 NSAttributedString , 重复设置内容样式导致的分页不准的情况

  2. 准备好文字显示区域大小的参数


第二步, 开始分页:
准备数据:

// 创建 NSLayoutManager, 所有的分页逻辑开端
let layoutManager = NSLayoutManager()

// 如果没有给特定部分文字区域设置单独的布局, 可设置此项为 false, 以提高性能
layoutManager.allowsNonContiguousLayout = false

// 使用之前准备好的 NSAttributedString 进行初始化 NSTextStorage
let textStorage = NSTextStorage(attributedString: string)
textStorage.addLayoutManager(layoutManager)

// 设定文字显示区域参数
let viewSize: CGSize = CGSize(width: textAreaWidth, height:  textAreaHeight)

// 设定 textView 的内间距
let textInsets = UIEdgeInsets.zero
let textViewFrame = CGRect(x: 0, y: 0, width: viewSize.width, height: viewSize.height)

// 开始分页
var glyphRange: Int = 0
var numberOfGlyphs: Int = 0

分页循环:

var ranges: [NSRange] = []
repeat {
    let textContainer = NSTextContainer(size: viewSize)
    layoutManager.addTextContainer(textContainer)
    
    // 不断创建 textView 让 NSLayoutManager 进行内容分页
    let textView = UITextView(frame: textViewFrame, textContainer: textContainer)
    textView.isEditable = false
    textView.isSelectable = false
    textView.textContainerInset = textInsets
    textView.showsVerticalScrollIndicator = false
    textView.showsHorizontalScrollIndicator = false
    textView.isScrollEnabled = false // 禁止滑动, 否则计算结果将不再准确
    textView.bounces = false
    textView.bouncesZoom = false
    
    // 获取当前分页内容所在位置
    let range = layoutManager.glyphRange(for: textContainer)
    ranges.append(range)
    
    // 判定是否分页完成
    glyphRange = NSMaxRange(range)
    numberOfGlyphs = layoutManager.numberOfGlyphs
} while glyphRange < numberOfGlyphs - 1

至此, 就得到了带有格式的全文 NSAttributedString, 和分页区域的 ranges


第三步, 显示分页数据
章节内容分页视图中, 将单章的 NSAttributedString 和分到的 range 分配给每一个单页显示视图, 在 UITextView 中直接设置 attributedTextattributedString.attributedSubstring(from: range)

UITextView 的设置务必于分页循环时的 UITextView 保持一致

基本原理

NSLayoutManager 会根据加入的 NSTextContainer 不断分走文字, 直到分完为止, 这时候可以使用 layoutManager.glyphRange(for: textContainer) 获取 NSTextContainer 对应的文字范围 range, 之后就可以根据这个 range 进行文字分割

修改字色, 字体

改变字色

改变颜色不需要重新尽心分页操作, 直接操作 UITextViewattributedText 和原始 NSAttributedString 就行

let attributed = NSMutableAttributedString(attributedString: textView.attributedText!)
attributed.addAttribute(.foregroundColor, value: ChangeColor, range: .init(location: 0, length: attributed.length))
textView.attributedText = NSAttributedString(attributedString: attributed)

注意, 方法为 addAttribute, 而不是 setAttribute, 后者会导致其他信息被清空

改变字体

UITextViewattributedText 和原始 NSAttributedStringfont 设置为新字体, 再重新进行分页操作, 重新设置单页显示视图即可

注意事项与其他

UITextView 内间距

请通过 textContainerInset 设置间距, 与分页时的参数保持一致, 单独设置 contentInset 不保证显示正确

添加点击区域

直接在根视图添加点击手势, 设置代理后, 根据点击区域判断行为
这样可以避免 UIPageViewController 的翻页手势被遮挡

在 UIPageViewController 中添加 UISlider 等带有活动操作的视图

请自主做好手势冲突的处理, 不然就是一片乱

分页性能

由于分页流程主要在主线程上, 所以被分页的数据最好不要过大, 单章单章分页就刚刚好

分页后文字可能超出显示区域

每个 NSTextContainer 的 frame 值都是被 NSLayoutManager 粗略计算过的, 与你设置 NSTextContainer 的 size 值略有出入, 有时候大些, 有时候小些, 但误差绝度不会超过一个字符的高度. 所以, 苹果建议我们在设置 UITextView 的时候, 给这个 NSTextContainer 预留一定的高度......

还有字体问题, 因为系统有些字体对中文支持不太好, 可能会对文字的大小计算失误, 请尽量使用以下支持中文的字体, 或其他支持中文的自定义字体:

Heiti SC              黑体-简
Heiti TC              黑体-繁
PingFang TC           平方-简
PingFang HK           平方-繁
PingFang SC           平方-繁

快速翻页导致未分页完成就翻到下一章

可以添加分页中标记, 存在标识时, 下一页上一页代理中返回 nil

具体判断逻辑请根据自身项目调整

为何不直接使用分页循环中的 UITextView

可以尝试一下, 内存的飙升绝对酸爽, 我在模拟器上测试, 翻了几页直接飙到 150+ M, 目前的方案在模拟器上 App 整体内存占用最高稳定在 50 M 左右, 真机可以稳定在 20 M 左右

当然, 也有可能是我的方式有错误, 各位可以尝试各种方案, 但分页逻辑万变不离其宗

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,670评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,928评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,926评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,238评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,112评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,138评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,545评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,232评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,496评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,596评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,369评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,226评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,600评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,906评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,185评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,516评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,721评论 2 335