从0到1实现小说阅读器(三、分析小说阅读器的实现)

上篇我们实现了一个简单的排版引擎,总结起来很简单,在一个自定义视图的drawRect:()方法中绘制利用CoreTextCTFrameDraw()方法绘制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一样,有自己的dataSourcedelegate

1. UIPageViewController 的使用

// 创建翻页视图控制器对象
- (instancetype)initWithTransitionStyle:(UIPageViewControllerTransitionStyle)style navigationOrientation:(UIPageViewControllerNavigationOrientation)navigationOrientation options:(nullable NSDictionary *)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  delegate;
//设置代理
@property (nullable, nonatomic, weak) id  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 *)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 *)pendingViewControllers NS_AVAILABLE_IOS(6_0);
//翻页动画执行完成后回调的方法
- (void)pageViewController:(UIPageViewController *)pageViewController didFinishAnimating:(BOOL)finished previousViewControllers:(NSArray *)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 *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层:

  • 交互层:处理小说的左右翻页逻辑和其他操作响应
  • 逻辑层:数据请求、数据存储、数据转换和排版逻辑
  • 数据层:小说模型、章节数据、排版设置数据
  • 显示层:对排版结果进行渲染

类图如下:


你可能感兴趣的:(从0到1实现小说阅读器(三、分析小说阅读器的实现))