从0到1实现小说阅读器(四、笔记功能)

上篇我们实现了单页文本排版和多页排版逻辑,但这仅仅是实现了基础的翻阅功能。除此之外,小说阅读器还一些设置功能,比如:切换背景、改变字体大小、切换章节、目录、笔记等等。这篇我们主要讲解笔记功能的实现,其他功能在我看来还是比较简单,直接看源码就好了。

首先让产品经理来描述一下笔记的过程:

小明正在看小说,对其中某句话感触颇深,于是长按该句话所在区域,这时小明长按的位置高亮显示,并标记默认选中两个字,所以小明拖动前后大头针刚好使得这句话高亮显示,然后点击笔记按钮,弹出输入框,小明输入笔记确定后,这句话显示一条下划线。再次点击这句话可以删除或修改笔记。

接着iOS开发来分析一下笔记的过程:

1.添加长按手势;
2.长按文本高亮显示、默认选中两个字;
3.拖动大头针,可以改变文本选中长度;
4.标记选中文本(添加下划线);
5.为标记的文本添加点击事件。

这里呢,先了解一下CTFrame,这是定位文本的基础逻辑。在CTFrame内部是由多个CTLine组成,每行CTLine又是由多个CTRun组成。每个CTRun代表一组风格一致的文本(CTlineCTRun的创建不需要我们管理),如图所示:

长按文本高亮显示、默认选中两个字

长按事件:

- (void)longPressAction:(UILongPressGestureRecognizer *)recognizer {
    // 获取长按手势所在视图的位置
    CGPoint point = [recognizer locationInView:self];
    NSUInteger state = recognizer.state;
    if (state == UIGestureRecognizerStateBegan || state == UIGestureRecognizerStateChanged) {
        // 传入point和frameRef,返回rect和selectRange
        CGRect rect = [HYReadParser parserRectWithPoint:point frameRef:_frameRef selectRange:&_selectRange];
        // 显示放大镜
        [self.magnifier show:point];
        // setNeedsDisplay异步执行的,它会自动调用drawRect方法
        if (!CGRectEqualToRect(rect, CGRectZero)) {
            _pathArray = @[NSStringFromCGRect(rect)];
            [self setNeedsDisplay];
        }
    }
    
    if (recognizer.state == UIGestureRecognizerStateEnded) {
        // 长按结束隐藏放大镜,显示菜单
        [self hideMagnifier];
        if (!CGRectEqualToRect(_menuRect, CGRectZero)) {
            [self showMenu];
        }
    }
}

传入pointframeRef,返回rectselectRange

+ (CGRect)parserRectWithPoint:(CGPoint)point frameRef:(CTFrameRef)frameRef selectRange:(NSRange *)selectRange {
    CFIndex index = -1;
    
    CGPathRef pathRef = CTFrameGetPath(frameRef);
    CGRect bounds = CGPathGetBoundingBox(pathRef);
    
    CGRect rect = CGRectZero;
    // 获得CTLineRef数组
    NSArray *lines = (NSArray *)CTFrameGetLines(frameRef);
    
    if (!lines) {
        return rect;
    }
    
    NSUInteger linesCount = lines.count;
    // 为每一行起点开辟内存空间,可以理解成声明一个存放每行起点的数组
    CGPoint *origins = malloc(linesCount *sizeof(CGPoint));
    if (linesCount) {
        // 获得每行起点的数据
        CTFrameGetLineOrigins(frameRef, CFRangeMake(0, 0), origins);
        // 遍历起点数组找到point所在行
        for (NSInteger i = 0; i < linesCount; i++) {
            // 获得第i行起点坐标
            CGPoint baselineOrigin = origins[i];
            // 获得第i行CTLineRef对象
            CTLineRef lineRef = (__bridge CTLineRef)[lines objectAtIndex:i];
            // 声明字体上行高度和下行高度
            CGFloat ascent, descent;
            // 传入行对象,拿到行宽、上行高度、下行高度
            CGFloat lineWidth = CTLineGetTypographicBounds(lineRef, &ascent, &descent, NULL);
            // 获取行对象frame(注意:因为CoreText绘制的坐标系和布局坐标系的起点方向分别是左下角和左上角,所以lineFrame的y值不是baselineOrigin.y,而是CGRectGetHeight(bounds) - baselineOrigin.y - ascent)
            CGRect lineFrame = CGRectMake(baselineOrigin.x,
                                          CGRectGetHeight(bounds) - baselineOrigin.y - ascent,
                                          lineWidth,
                                          ascent + descent);
            // 通过判断point是否在lineFrame中来确定所在行
            if (CGRectContainsPoint(lineFrame, point)) {
                // 获得所在行的range
                CFRange stringRange = CTLineGetStringRange(lineRef);
                // 获得所在行的index
                index = CTLineGetStringIndexForPosition(lineRef, point);
                
                // 获取选中文本的rect
                CGFloat xStart = CTLineGetOffsetForStringIndex(lineRef, index, NULL);
                CGFloat xEnd;
                if (index > stringRange.location + stringRange.length - 2) {
                    // 超出右边界处理
                    xEnd = xStart;
                    xStart = CTLineGetOffsetForStringIndex(lineRef, index - 2, NULL);
                    (*selectRange).location = index - 2;
                } else {
                    xEnd = CTLineGetOffsetForStringIndex(lineRef, index + 2, NULL);
                    (*selectRange).location = index - 1;
                }
                // 默认选中两个单位
                (*selectRange).length = 2;
                
                rect = CGRectMake(origins[i].x + xStart,
                                  baselineOrigin.y - descent,
                                  fabs(xStart - xEnd),
                                  ascent + descent);
                break;
            }
        }
    }
    free(origins);
    return rect;
}

异步绘制:

- (void)drawRect:(CGRect)rect {
    [super drawRect:rect];
    if (!_frameRef) {
        return;
    }
    CGContextRef context = UIGraphicsGetCurrentContext();
    // 翻转坐标系
    CGContextSetTextMatrix(context, CGAffineTransformIdentity);
    CGContextTranslateCTM(context, 0, self.bounds.size.height);
    CGContextScaleCTM(context, 1.0, -1.0);
    
    CGRect leftDot, rightDot = CGRectZero;
    _menuRect = CGRectZero;
    // 绘制高亮区域
    [self drawSelectedPath:_pathArray leftDot:&leftDot rightDot:&rightDot];
    // 绘制frameRef
    CTFrameDraw(_frameRef, context);
    [self drawDotWithLeft:leftDot right:rightDot];
}

文本高亮显示:

- (void)drawSelectedPath:(NSArray *)array leftDot:(CGRect *)leftDot rightDot:(CGRect *)rightDot {
    CGMutablePathRef _path = CGPathCreateMutable();
    // 高亮颜色
    [[UIColor cyanColor] setFill];
    
    for (int i = 0; i < array.count; i++) {
        CGRect rect = CGRectFromString([array objectAtIndex:i]);
        CGPathAddRect(_path, NULL, rect);
        if (i == 0) {
            *leftDot = rect;
            _menuRect = rect;
        }
        if (i == array.count - 1) {
            *rightDot = rect;
        }
    }
    CGContextRef ctx = UIGraphicsGetCurrentContext();
    CGContextAddPath(ctx, _path);
    CGContextFillPath(ctx);
    CGPathRelease(_path);
}

方法执行路径:
- (void)longPressAction:
+ (CGRect)parserRectWithPoint: frameRef: selectRange:
- (void)drawRect:
- (void)drawSelectedPath: leftDot: rightDot:

拖动大头针,可以改变文本选中长度

添加一个拖拽事件:

- (void)panAction:(UIPanGestureRecognizer *)recognizer {
    CGPoint point = [recognizer locationInView:self];
    // 显示放大镜
    [self.magnifier show:point];
    if (CGRectContainsPoint(_rightRect, point) || CGRectContainsPoint(_leftRect, point)) {
        _direction = !CGRectContainsPoint(_leftRect, point);
    }
    // 将rect添加到数组中
    _pathArray = [HYReadParser parserRectsWithPoint:point frameRef:_frameRef selectRange:&_selectRange paths:_pathArray direction:_direction];
    [self setNeedsDisplay];
    
    if (recognizer.state == UIGestureRecognizerStateEnded) {
        [self hideMagnifier];
        if (!CGRectEqualToRect(_menuRect, CGRectZero)) {
            [self showMenu];
        }
    }
}

传入directionpointframeRef,返回rect数组和selectRange

+ (NSArray *)parserRectsWithPoint:(CGPoint)point frameRef:(CTFrameRef)frameRef selectRange:(NSRange *)selectRange paths:(NSArray *)paths direction:(BOOL)direction {
    CFIndex index = -1;
    // 获取行数据
    NSArray *lines = (NSArray *)CTFrameGetLines(frameRef);
    NSMutableArray *muArr = [NSMutableArray array];
    NSInteger lineCount = lines.count;
    // 为每一行起点开辟内存空间
    CGPoint *origins = malloc(lineCount * sizeof(CGPoint));
    // 获取滑动后文本所在index
    index = [self parserIndexWithPoint:point frameRef:frameRef];
    if (index == -1) {
        return paths;
    }
    if (direction) { // 从右侧滑动
        if (!(index > (*selectRange).location)) {
            (*selectRange).length = (*selectRange).location - index + (*selectRange).length;
            (*selectRange).location = index;
        } else{
            (*selectRange).length = index - (*selectRange).location;
        }
    } else { // 从左侧滑动
        if (!(index > (*selectRange).location + (*selectRange).length)) {
            (*selectRange).length = (*selectRange).location - index + (*selectRange).length;
            (*selectRange).location = index;
        }
    }
    if (lineCount) {
        CTFrameGetLineOrigins(frameRef, CFRangeMake(0, 0), origins);
        for (int i = 0; i < lineCount; i++){
            CGPoint baselineOrigin = origins[i];
            CTLineRef line = (__bridge CTLineRef)[lines objectAtIndex:i];
            CGFloat ascent,descent;
            CTLineGetTypographicBounds(line, &ascent, &descent, NULL);
            CFRange stringRange = CTLineGetStringRange(line);
            CGFloat xStart;
            CGFloat xEnd;
            NSRange drawRange = [self selectRange:NSMakeRange((*selectRange).location, (*selectRange).length) lineRange:NSMakeRange(stringRange.location, stringRange.length)];
            if (drawRange.length) {
                xStart = CTLineGetOffsetForStringIndex(line, drawRange.location, NULL);
                xEnd = CTLineGetOffsetForStringIndex(line, drawRange.location+drawRange.length, NULL);
                CGRect rect = CGRectMake(xStart, baselineOrigin.y-descent, fabs(xStart-xEnd), ascent+descent);
                if (rect.size.width == 0 || rect.size.height == 0) {
                    continue;
                }
                [muArr addObject:NSStringFromCGRect(rect)];
            }
        }
    }
    return muArr;
}

获取当前滑动后文本所在index

+ (CFIndex)parserIndexWithPoint:(CGPoint)point frameRef:(CTFrameRef)frameRef {
    CFIndex index = -1;
    CGPathRef pathRef = CTFrameGetPath(frameRef);
    CGRect bounds = CGPathGetBoundingBox(pathRef);
    NSArray *lines = (__bridge NSArray *)CTFrameGetLines(frameRef);
    if (!lines) {
        return index;
    }
    NSInteger lineCount = [lines count];
    CGPoint *origins = malloc(lineCount * sizeof(CGPoint)); //给每行的起始点开辟内存
    if (lineCount) {
        CTFrameGetLineOrigins(frameRef, CFRangeMake(0, 0), origins);
        for (int i = 0; i

方法执行路径:
- (void)panAction:
+ (NSArray *)parserRectsWithPoint: frameRef: selectRange: paths: direction:
+ (CFIndex)parserIndexWithPoint: frameRef:
- (void)drawRect:
- (void)drawSelectedPath: leftDot: rightDot:

保存笔记,并添加下划线:

- (void)menuNote:(id)sender {
    [self hideMenu];
    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"笔记" message:[_content substringWithRange:_selectRange]  preferredStyle:UIAlertControllerStyleAlert];
    [alertController addTextFieldWithConfigurationHandler:^(UITextField * _Nonnull textField) {
       textField.placeholder = @"输入内容";
    }];
    UIAlertAction *cancel = [UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:nil];
    UIAlertAction *confirm = [UIAlertAction actionWithTitle:@"确认" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
        HYNoteModel *model = [[HYNoteModel alloc] init];
        model.content = [self.content substringWithRange:self.selectRange];
        model.note = alertController.textFields.firstObject.text;
        model.date = [NSDate date];
        HYRecordModel *record = [HYReadManager sharedManager].readModel.record;
        NSValue *value = record.chapterModel.pageArray[record.page];
        NSRange pageLocation = value.rangeValue;
        // 笔记在章节中所在的位置 = 选中范围起点 + 当前页的范围起点
        model.locationInChapterContent = self.selectRange.location + pageLocation.location;
        [[NSNotificationCenter defaultCenter] postNotificationName:LSYNoteNotification object:model];
        [self cancelSelected];
    }];
    [alertController addAction:cancel];
    [alertController addAction:confirm];
    for (UIView* next = [self superview]; next; next = next.superview) {
        UIResponder* nextResponder = [next nextResponder];
        if ([nextResponder isKindOfClass:[UIViewController class]]) {
            [(UIViewController *)nextResponder presentViewController:alertController animated:YES completion:nil];
            break;
        }
    }
}
- (void)addNotes:(NSNotification *)no {
    HYNoteModel *model = no.object;
    model.chapter = _readModel.record.chapter;
    [_readModel addNote:model];
    
    HYReadViewController *vc = [self readViewWithChapter:_readModel.record.chapter page:_page];
    [_pageViewController setViewControllers:@[vc] direction:UIPageViewControllerNavigationDirectionForward animated:NO completion:nil];
    [self updateReadModelWithChapter:_readModel.record.chapter page:_page];

    NSLog(@"保存笔记成功!");
}

修改创建CTFrameRef的方法:

+ (CTFrameRef)parserContent:(NSString *)content config:(HYReadConfig *)config bouds:(CGRect)bounds notes:(NSArray *)notes pageRange:(NSRange)pageRange {
    NSMutableAttributedString *attributeStr = [[NSMutableAttributedString alloc] initWithString:content];
    NSDictionary *attribute = [self parserAttribute:config];
    [attributeStr addAttributes:attribute range:NSMakeRange(0, content.length)];
    
    if (notes) {
        for (HYNoteModel *noteModel in notes) {
            NSRange range = NSMakeRange(noteModel.locationInChapterContent, noteModel.content.length);
            NSMutableDictionary *attibutes = [NSMutableDictionary dictionary];
            [attibutes setObject:@(NSUnderlinePatternSolid|NSUnderlineStyleSingle) forKey:NSUnderlineStyleAttributeName];
            [attibutes setObject:[UIColor redColor] forKey:NSUnderlineColorAttributeName];
            [attibutes setObject:[noteModel getNoteURL] forKey:NSLinkAttributeName];
            // 返回range交集
            NSRange intersectionRange = NSIntersectionRange(range, pageRange);
            // 实际需要划线的range
            NSRange actualRange = NSMakeRange(NSNotFound, 0);
            // 存在交集
            if (intersectionRange.location != NSNotFound && intersectionRange.length != 0) {
                // 交集在中间位置
                if (range.location > pageRange.location) {
                    actualRange.location = range.location - pageRange.location;
                } else {
                    actualRange.location = 0;
                }
                actualRange.length = intersectionRange.length;
            }
            if (actualRange.location != NSNotFound) {
                [attributeStr addAttributes:attibutes range:actualRange];
            }
        }
    }
    
    CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)attributeStr);
    CGPathRef path = CGPathCreateWithRect(bounds, NULL);
    CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
    
    CFRelease(framesetter);
    CFRelease(path);
    
    return frame;
}

你可能感兴趣的:(从0到1实现小说阅读器(四、笔记功能))