上篇我们实现了单页文本排版和多页排版逻辑,但这仅仅是实现了基础的翻阅功能。除此之外,小说阅读器还一些设置功能,比如:切换背景、改变字体大小、切换章节、目录、笔记等等。这篇我们主要讲解笔记功能的实现,其他功能在我看来还是比较简单,直接看源码就好了。
首先让产品经理来描述一下笔记的过程:
小明正在看小说,对其中某句话感触颇深,于是长按该句话所在区域,这时小明长按的位置高亮显示,并标记默认选中两个字,所以小明拖动前后大头针刚好使得这句话高亮显示,然后点击笔记按钮,弹出输入框,小明输入笔记确定后,这句话显示一条下划线。再次点击这句话可以删除或修改笔记。
接着iOS开发来分析一下笔记的过程:
1.添加长按手势;
2.长按文本高亮显示、默认选中两个字;
3.拖动大头针,可以改变文本选中长度;
4.标记选中文本(添加下划线);
5.为标记的文本添加点击事件。
这里呢,先了解一下CTFrame
,这是定位文本的基础逻辑。在CTFrame
内部是由多个CTLine
组成,每行CTLine
又是由多个CTRun
组成。每个CTRun
代表一组风格一致的文本(CTline
和CTRun
的创建不需要我们管理),如图所示:
长按文本高亮显示、默认选中两个字
长按事件:
- (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];
}
}
}
传入point
和frameRef
,返回rect
和selectRange
:
+ (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];
}
}
}
传入direction
、point
和frameRef
,返回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;
}