从0到1实现小说阅读器(三、分析小说阅读器的实现)
上篇我们实现了一个简单的排版引擎,总结起来很简单,在一个自定义视图的drawRect:()
方法中绘制利用CoreText
的CTFrameDraw()
方法绘制CTFrameRef
,即:
- (void)drawRect:(CGRect)rect {
[super drawRect:rect];
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, 0, self.bounds.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
if (self.data) {
CTFrameDraw(self.data.ctFrame, context);
}
}
那么我们如何在此基础上将小说功能实现呢?首先来分析一下小说的功能:
- 单页的排版显示
- 多页的排版逻辑,可以左右翻页
- 设置功能(字体大小、切换背景/主题、翻页模式等)
- 目录
- 书签
- 注释(选中段落划线、添加注释)
今天我们主要来分析如何实现前两个,因为这两个是核心功能。目前我们的排版引擎可以做到单页排版的显示,多页的排版逻辑涉及视图和数据的处理逻辑。视图方面我们可以通过翻页控制器UIPageViewController
来实现,该对象还自带了翻页的动画效果UIPageViewControllerTransitionStylePageCurl
;简单介绍一下UIPageViewController
的使用,它和UITableView
一样,有自己的dataSource
和delegate
。
1. UIPageViewController 的使用
// 创建翻页视图控制器对象
- (instancetype)initWithTransitionStyle:(UIPageViewControllerTransitionStyle)style navigationOrientation:(UIPageViewControllerNavigationOrientation)navigationOrientation options:(nullable NSDictionary<NSString *, id> *)options;
上面的方法用来初始化UIPageViewController
对象,其中UIPageViewControllerTransitionStyle
用来设置翻页效果:
typedef NS_ENUM(NSInteger, UIPageViewControllerTransitionStyle) {
UIPageViewControllerTransitionStylePageCurl = 0, //类似于书本翻页效果
UIPageViewControllerTransitionStyleScroll = 1 // 类似于ScrollView的滑动效果
};
其中UIPageViewControllerNavigationOrientation
用来设置翻页方向:
typedef NS_ENUM(NSInteger, UIPageViewControllerNavigationOrientation) {
UIPageViewControllerNavigationOrientationHorizontal = 0,//水平翻页
UIPageViewControllerNavigationOrientationVertical = 1//竖直翻页
};
下面是UIPageViewController
常用的属性和方法:
//设置数据源
@property (nullable, nonatomic, weak) id <UIPageViewControllerDelegate> delegate;
//设置代理
@property (nullable, nonatomic, weak) id <UIPageViewControllerDataSource> dataSource;
//获取翻页风格
@property (nonatomic, readonly) UIPageViewControllerTransitionStyle transitionStyle;
//获取翻页方向
@property (nonatomic, readonly) UIPageViewControllerNavigationOrientation navigationOrientation;
//获取书轴类型
@property (nonatomic, readonly) UIPageViewControllerSpineLocation spineLocation;
//设置是否双面显示
@property (nonatomic, getter=isDoubleSided) BOOL doubleSided;
//设置要显示的视图控制器
- (void)setViewControllers:(nullable NSArray<UIViewController *> *)viewControllers direction:(UIPageViewControllerNavigationDirection)direction animated:(BOOL)animated completion:(void (^ __nullable)(BOOL finished))completion;
上面的spineLocation
属性有些难以理解,其枚举值如下:
typedef NS_ENUM(NSInteger, UIPageViewControllerSpineLocation) {
//对于SCrollView类型的滑动效果 没有书轴 会返回下面这个枚举值
UIPageViewControllerSpineLocationNone = 0,
//以左边或者上边为轴进行翻转 界面同一时间只显示一个View
UIPageViewControllerSpineLocationMin = 1,
//以中间为轴进行翻转 界面同时可以显示两个View
UIPageViewControllerSpineLocationMid = 2,
//以下边或者右边为轴进行翻转 界面同一时间只显示一个View
UIPageViewControllerSpineLocationMax = 3
};
UIPageViewControllerDataSource中的方法:
//向前翻页展示的ViewController(上一页)
- (nullable UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerBeforeViewController:(UIViewController *)viewController;
//向后翻页展示的ViewController(下一页)
- (nullable UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerAfterViewController:(UIViewController *)viewController;
//设置分页控制器的分页点数
- (NSInteger)presentationCountForPageViewController:(UIPageViewController *)pageViewController NS_AVAILABLE_IOS(6_0);
//设置当前分页控制器所高亮的点
- (NSInteger)presentationIndexForPageViewController:(UIPageViewController *)pageViewController NS_AVAILABLE_IOS(6_0);
UIPageViewControllerDelegate中的方法:
//翻页视图控制器将要翻页时执行的方法
- (void)pageViewController:(UIPageViewController *)pageViewController willTransitionToViewControllers:(NSArray<UIViewController *> *)pendingViewControllers NS_AVAILABLE_IOS(6_0);
//翻页动画执行完成后回调的方法
- (void)pageViewController:(UIPageViewController *)pageViewController didFinishAnimating:(BOOL)finished previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers transitionCompleted:(BOOL)completed;
//屏幕防线改变时回到的方法,可以通过返回值重设书轴类型枚举
- (UIPageViewControllerSpineLocation)pageViewController:(UIPageViewController *)pageViewController spineLocationForInterfaceOrientation:(UIInterfaceOrientation)orientation;
2.数据处理
现在我们已经有了翻页神器UIPageViewControlelr
,那么数据应该如何组织呢?在实际项目中,小说数据都是以加密的方式存储在服务器中,我们通过接口请求拿到数据后进行解密再使用。但是出于性能和部分章节需要付费考虑,都是按章节请求数据。我们这里简化一下,一部完整的加密的小说已经存在本地,我们只需要解密后就可以直接使用了,不需要考虑子线程请求章节的逻辑。上文提到我们的排版引擎可以实现单个页面的显示,然后我们只要把小说先分成章节,再分成页,然后通过UIPageViewController
来呈现出来就基本实现了小说阅读器的核心功能。
首先,将小说分成章节,这里使用了正则表达式:
+ (NSMutableArray *)separateChapterWithContent:(NSString *)content {
// 创建章节对象数组
NSMutableArray *chapters = @[].mutableCopy;
// 正则表达式条件
NSString *parten = @"第[0-9一二三四五六七八九十百千]*[章回].*";
NSError *error = NULL;
NSRegularExpression *reg = [NSRegularExpression regularExpressionWithPattern:parten options:NSRegularExpressionCaseInsensitive error:&error];
// 得到分割后的对象数组,对其遍历创建章节对象
NSArray *match = [reg matchesInString:content options:NSMatchingReportCompletion range:NSMakeRange(0, [content length])];
if (match.count != 0) {
__block NSRange lastRange = NSMakeRange(0, 0);
[match enumerateObjectsUsingBlock:^(NSTextCheckingResult * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSRange range = [obj range];
NSInteger local = range.location;
if (idx == 0) { // 第一章标题
HYChapterModel *model = [[HYChapterModel alloc] init];
model.title = @"开始";
NSUInteger len = local;
model.content = [content substringWithRange:NSMakeRange(0, len)];
[chapters addObject:model];
}
if (idx > 0) {
HYChapterModel *model = [[HYChapterModel alloc] init];
model.title = [content substringWithRange:lastRange];
NSUInteger len = local -lastRange.location;
model.content = [content substringWithRange:NSMakeRange(lastRange.location, len)];
[chapters addObject:model];
}
if (idx == match.count-1) { // 最后一章
HYChapterModel *model = [[HYChapterModel alloc] init];
model.title = [content substringWithRange:range];
model.content = [content substringWithRange:NSMakeRange(local, content.length - local)];
[chapters addObject:model];
}
lastRange = range;
}];
} else {
HYChapterModel *model = [[HYChapterModel alloc] init];
model.content = content;
[chapters addObject:model];
}
return chapters;
}
其次,将章节分成页,这里就用到了CoreText
的方法。我们拿到设置了attribute
的富文本字符串后,根据显示区域rect
可以得到CTFrameRef
,再通过CTFrameGetVisibleStringRange
方法可以得到当前可见字符串区域,遍历后可以得到每一页的区域range
,如此边完成了分页逻辑。
- (NSArray *)pagingContentWithAttributeStr:(NSAttributedString *)attributeStr pageSize:(CGSize)pageSize {
NSMutableArray<NSValue *> *resultRange = [NSMutableArray array]; // 返回结果数组
CGRect rect = CGRectMake(0, 0, pageSize.width, pageSize.height); // 每页的显示区域大小
NSUInteger curIndex = 0; // 分页起点,初始为第0个字符
while (curIndex < attributeStr.length) { // 没有超过最后的字符串,表明至少剩余一个字符
NSUInteger maxLength = MIN(1000, attributeStr.length - curIndex); // 1000为最小字体的每页最大数量,减少计算量
NSAttributedString * subString = [attributeStr attributedSubstringFromRange:NSMakeRange(curIndex, maxLength)]; // 截取字符串
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef) subString); // 根据富文本创建排版类CTFramesetterRef
UIBezierPath * bezierPath = [UIBezierPath bezierPathWithRect:rect];
CTFrameRef frameRef = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), bezierPath.CGPath, NULL); // 创建排版数据,第个参数的range.length=0表示放字符直到区域填满
CFRange visiableRange = CTFrameGetVisibleStringRange(frameRef); // 获取当前可见的字符串区域
NSRange realRange = {curIndex, visiableRange.length}; // 当页在原始字符串中的区域
[resultRange addObject:[NSValue valueWithRange:realRange]]; // 记录当页结果
curIndex += realRange.length; //增加索引
CFRelease(frameRef);
CFRelease(frameSetter);
};
return resultRange;
}
以上就是一个简易的小说阅读器的核心逻辑。代码实现分为4层:
- 交互层:处理小说的左右翻页逻辑和其他操作响应
- 逻辑层:数据请求、数据存储、数据转换和排版逻辑
- 数据层:小说模型、章节数据、排版设置数据
- 显示层:对排版结果进行渲染
类图如下: