套路继续, .txt 小说阅读器功能开发

1, 解决一个 bug

正文结尾 (最后一行最后一个字)跟右边界, 有多余的空白间隔

套路继续, .txt 小说阅读器功能开发

Core Text 的渲染流程,就是富文本绘制

从流程上看,

感觉这一页的文字分配少了,给他加点字,就满了

// 拿到一个章节的富文本,计算出每一页的富文本,从哪里开始,哪里结束
// 得到一个范围的数组,就知道了每一页的文字
class func pagingRanges(attrString:NSAttributedString, rect:CGRect) ->[NSRange] {
        var rangeArray = [NSRange]()
        let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
        // 屏幕显示区域
        let path = CGPath(rect: rect, transform: nil)
        var range = CFRangeMake(0, 0)
        var rangeOffset = 0
        repeat{
            let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(rangeOffset, 0), path, nil)
            range = CTFrameGetVisibleStringRange(frame)
            rangeArray.append(NSMakeRange(rangeOffset, range.length))
            rangeOffset += range.length
        }while(rangeOffset < attrString.length)
        return rangeArray
    }

这样做,效果很不好

解决:

修改富文本属性, 怎么换行的


            // 行间距
            paragraphStyle.lineSpacing = lineSpacing
            // 加了这么一句
            paragraphStyle.lineBreakMode = .byCharWrapping
            // 段间距
            paragraphStyle.paragraphSpacing = paragraphSpacing
            
            // 对齐
            paragraphStyle.alignment = .justified

套路继续, .txt 小说阅读器功能开发

2, 复制一整行

套路继续, .txt 小说阅读器功能开发

基于 dengzemiao/DZMeBookRead

长按,可复制一整行

长按的时候,要找出这一行,

给这一行涂上颜色,

出来一个可复制的菜单


/// 长按事件
    @objc private func longAction(long:UILongPressGestureRecognizer) {
        
        // 触摸位置
        let point = long.location(in: self)

        // 触摸位置
        switch long.state {
        case .began:
            // 触摸开始 触摸中
            // 发送通知, 处理其他 UI
            // ...
        //      case .changed:
        default:
            // 触摸结束

            // 获得选中区域
            selectRange = CoreText.GetTouchLineRange(point: point, frameRef: frameRef)

            // 获得选中选中范围
            rects = CoreText.GetRangeRects(range: selectRange!, frameRef: frameRef, content: pagingModel.content?.string)

            // 显示光标
            cursor(isShow: true)


                // 显示菜单
            self.showMenu(isShow: true)
            

            // 重绘
            setNeedsDisplay()

            // 发送通知, 处理其他 UI
            // ...
        }
    }

找出这一行

长按手势,可以拿到一个点,

当前阅读界面,有一帧的文字 CTFrame

    
    /// 获得触摸位置那一行文字的Range
    ///
    /// - Parameters:
    ///   - point: 触摸位置
    ///   - frameRef: CTFrame
    /// - Returns: CTLine
    class func GetTouchLineRange(point:CGPoint, frameRef:CTFrame?) ->NSRange {
        
        var range:NSRange = NSMakeRange(NSNotFound, 0)
        
        let line = GetTouchLine(point: point, frameRef: frameRef)
        
        if line != nil {
            
            let lineRange = CTLineGetStringRange(line!)
            
            range = NSMakeRange(lineRange.location == kCFNotFound ? NSNotFound : lineRange.location, lineRange.length)
        }
        
        return range
    }
    
    
    
    
    /// 获得触摸位置在哪一行
    ///
    /// - Parameters:
    ///   - point: 触摸位置
    ///   - frameRef: CTFrame
    /// - Returns: CTLine
    class func GetTouchLine(point:CGPoint, frameRef:CTFrame?) ->CTLine? {
        
        var line:CTLine? = nil
        
        if frameRef == nil { return line }
        
        let frameRef:CTFrame = frameRef!
        
        let path:CGPath = CTFrameGetPath(frameRef)
        
        let bounds:CGRect = path.boundingBox
        // 获取全部行
        let lines:[CTLine] = CTFrameGetLines(frameRef) as! [CTLine]
        
        if lines.isEmpty { return line }
        
        let lineCount = lines.count
        
        let origins = malloc(lineCount * MemoryLayout<CGPoint>.size).assumingMemoryBound(to: CGPoint.self)
        
        CTFrameGetLineOrigins(frameRef, CFRangeMake(0, 0), origins)
        // 一行一行的往下翻
        for i in 0..<lineCount {
            
            let origin:CGPoint = origins[i]
            
            let tempLine:CTLine = lines[i]
            
            var lineAscent:CGFloat = 0
            
            var lineDescent:CGFloat = 0
            
            var lineLeading:CGFloat = 0
            
            CTLineGetTypographicBounds(tempLine, &lineAscent, &lineDescent, &lineLeading)
            
            let lineWidth:CGFloat = bounds.width
            
            let lineheight:CGFloat = lineAscent + lineDescent + lineLeading
            // 每一行的区域
            var lineFrame = CGRect(x: origin.x, y: bounds.height - origin.y - lineAscent, width: lineWidth, height: lineheight)
            
            lineFrame = lineFrame.insetBy(dx: -SPACE_5, dy: -SPACE_5)
            
            if lineFrame.contains(point) {
                
                line = tempLine
                
                break
            }
        }
        
        free(origins)
        
        return line
    }

将选中的一整行,涂上颜色

触发, 上面的手势代码中

selectRangerects, 赋值了。重绘,就好了

             // 获得选中区域
            selectRange = ...

            // 获得选中范围
            rects = ...

            // 重绘
            setNeedsDisplay()

触发绘制


    /// 绘制
    override func draw(_ rect: CGRect) {
        
        if (frameRef == nil) {return}
        
        let ctx = UIGraphicsGetCurrentContext()
        
        ctx?.textMatrix = CGAffineTransform.identity
        
        ctx?.translateBy(x: 0, y: bounds.size.height)
        
        ctx?.scaleBy(x: 1.0, y: -1.0)
        
        if selectRange != nil , !rects.isEmpty {
        // 渲染,选中行的背景
            let path = CGMutablePath()
            
            READ_COLOR_MAIN.withAlphaComponent(0.5).setFill()
            
            path.addRects(rects)
            
            ctx?.addPath(path)
            
            ctx?.fillPath()
            // 先把选中行的背景涂色,再把文字渲染出来,很科学
        }
        // 渲染文字
        CTFrameDraw(frameRef!, ctx!)
    }
点击复制,相关

复制,就是调用剪贴板 UIPasteboard

简单


/// 复制事件
    @objc private func clickCopy() {
        
        if let range = selectRange{
            let tempContent = pagingModel.content
            
            DispatchQueue.global().async {
                
                UIPasteboard.general.string = tempContent?.string.substring(range)
            }
            
            // 重置状态
            // ...
        }
    }
其余
  • 光标的显示

光标就是两个控件,显示光标,简单

  • 选择选中区域

更改选中区域,就是光标的拖拽,逻辑与前文类似,

效果是整行整行的复制

3, 灵活的复制。上面只是定位到某一行,现在定位到那一行的那个字

从 Swift 变到了 Objective-C

套路继续, .txt 小说阅读器功能开发

基于 GGGHub/Reader

也是通过长按手势触发

渲染出来,老三步

  • 拿到一个点击位置,一个点 CGPoint

  • 计算出当前区域 rect

  • 渲染出来

-(void)longPress:(UILongPressGestureRecognizer *)longPress{
    // 拿到一个点
    CGPoint point = [longPress locationInView:self];
    // ...
    // 处理 UI 状态
    if (longPress.state == UIGestureRecognizerStateBegan || longPress.state == UIGestureRecognizerStateChanged) {
        CGRect rect = [LSYReadParser parserRectWithPoint:point range:&_selectRange frameRef:_frameRef];
        // ...
        // 处理 UI 状态
        if (!CGRectEqualToRect(rect, CGRectZero)) {
            _pathArray = @[NSStringFromCGRect(rect)];
            
            // 触发绘制
            [self setNeedsDisplay];
        }
    }
    else if (longPress.state == UIGestureRecognizerStateEnded) {
        // ...
        // 恢复 UI 状态
    }
}

同上文的逻辑一致,

拿到一个点,一帧文字,

得到选中文字的范围 selectRange,通过传参获取,

得到选中文字的区域,CGRect, 通过返回值获取

+(CGRect)parserRectWithPoint:(CGPoint)point range:(NSRange *)selectRange frameRef:(CTFrameRef)frameRef
{
    CFIndex index = -1;
    CGPathRef pathRef = CTFrameGetPath(frameRef);
    CGRect bounds = CGPathGetBoundingBox(pathRef);
    CGRect rect = CGRectZero;
    
    // 拿到每一行
    NSArray *lines = (__bridge NSArray *)CTFrameGetLines(frameRef);
    if (!lines) {
        return rect;
    }
    NSInteger lineCount = [lines count];
    CGPoint *origins = malloc(lineCount * sizeof(CGPoint)); //给每行的起始点开辟内存
    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,linegap; //声明字体的上行高度和下行高度和行距
            CGFloat lineWidth = CTLineGetTypographicBounds(line, &ascent, &descent, &linegap);
            CGRect lineFrame = CGRectMake(baselineOrigin.x, CGRectGetHeight(bounds)-baselineOrigin.y-ascent, lineWidth, ascent+descent+linegap+[LSYReadConfig shareInstance].lineSpace);   
            //没有转换坐标系左下角为坐标原点 字体高度为上行高度加下行高度
            if (CGRectContainsPoint(lineFrame,point)){
                // 定位到行了
                CFRange stringRange = CTLineGetStringRange(line);
                // 定位到字
                index = CTLineGetStringIndexForPosition(line, point);
                CGFloat xStart = CTLineGetOffsetForStringIndex(line, index, NULL);
                CGFloat xEnd;
                //默认选中两个单位
                if (index > stringRange.location+stringRange.length-2) {
                    xEnd = xStart;
                    xStart = CTLineGetOffsetForStringIndex(line,index-2,NULL);
                    (*selectRange).location = index-2;
                }
                else{
                    xEnd = CTLineGetOffsetForStringIndex(line,index+2,NULL);
                    (*selectRange).location = index;
                }
                // 选中的 2 个字
                (*selectRange).length = 2;
                rect = CGRectMake(origins[i].x+xStart,baselineOrigin.y-descent,fabs(xStart-xEnd), ascent+descent);
                
                break;
            }
        }
    }
    free(origins);
    return rect;
}


将选中的 2 个字,涂上颜色

触发

	    // 赋值
            _pathArray = ...
            // 触发绘制
            [self setNeedsDisplay];

绘制


-(void)drawRect:(CGRect)rect
{
    if (!_frameRef) {
        return;
    }

    CGContextRef ctx = UIGraphicsGetCurrentContext();
    CGContextSetTextMatrix(ctx, CGAffineTransformIdentity);
    CGContextTranslateCTM(ctx, 0, self.bounds.size.height);
    CGContextScaleCTM(ctx, 1.0, -1.0);
    CGRect leftDot,rightDot = CGRectZero;
    _menuRect = CGRectZero;
    // 绘制选中区域
    [self drawSelectedPath:_pathArray LeftDot:&leftDot RightDot:&rightDot];
    
    // 绘制文字
    CTFrameDraw(_frameRef, ctx);

    if (_imageArray.count) {
        // ...
        // 有图片,绘制图片
    }
    // 绘制左右光标
    // ...
}

// 绘制选中区域
// 有两个返回值, 通过参数返回
// leftDot, 顶部行的区域
// rightDot, 底部行的区域
-(void)drawSelectedPath:(NSArray *)array LeftDot:(CGRect *)leftDot RightDot:(CGRect *)rightDot{
    // ...
    // 处理其他 UI 状态
    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;
            // ...
            // 处理其他 UI 状态
        }
        if (i == [array count]-1) {
            *rightDot = rect;
        }
       
    }
    // 涂色
    CGContextRef ctx = UIGraphicsGetCurrentContext();
    CGContextAddPath(ctx, _path);
    CGContextFillPath(ctx);
    CGPathRelease(_path);
    
}


调整选中区域

-(void)pan:(UIPanGestureRecognizer *)pan
{
   
    CGPoint point = [pan locationInView:self];
    // ...
    // 处理其他 UI 状态
    if (pan.state == UIGestureRecognizerStateBegan || pan.state == UIGestureRecognizerStateChanged) {
        [self showMagnifier];
        self.magnifierView.touchPoint = point;
        if (CGRectContainsPoint(_rightRect, point)||CGRectContainsPoint(_leftRect, point)) {
            if (CGRectContainsPoint(_leftRect, point)) {
                _direction = NO;   //从左侧滑动
            }
            else{
                _direction=  YES;    //从右侧滑动
            }
            _selectState = YES;
        }
        if (_selectState) {
            // 计算出来,当前选中区域
            NSArray *path = [LSYReadParser parserRectsWithPoint:point range:&_selectRange frameRef:_frameRef paths:_pathArray direction:_direction];
            _pathArray = path;
            // 去渲染
            [self setNeedsDisplay];
        }
       
    }
    if (pan.state == UIGestureRecognizerStateEnded) {
        // ...
        // 处理其他 UI 状态
    }
    
}

计算出来,当前选中区域的逻辑,与上文的代码,差不多

4, 框出每一行

有了前面的基础, 框出每一行就很简单

套路继续, .txt 小说阅读器功能开发

-(void)drawRect:(CGRect)rect
{
    if (!_frameRef) {
        return;
    }
    // 框出每一行
    
  
    CGPathRef pathRef = CTFrameGetPath(_frameRef);
    CGRect bounds = CGPathGetBoundingBox(pathRef);
    
    NSArray *lines = (__bridge NSArray *)CTFrameGetLines(_frameRef);
    if (lines) {
        NSInteger lineCount = [lines count];
        CGPoint *origins = malloc(lineCount * sizeof(CGPoint)); 
        //给每行的起始点开辟内存
        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,linegap; 
            //声明字体的上行高度和下行高度和行距
            CGFloat lineWidth = CTLineGetTypographicBounds(line, &ascent, &descent, &linegap);

            // 如果是空行,不用管
            if (lineWidth > 1){
                CGRect lineFrame = CGRectMake(baselineOrigin.x, CGRectGetHeight(bounds)-baselineOrigin.y-ascent, lineWidth, ascent+descent+linegap);
                //没有转换坐标系左下角为坐标原点 字体高度为上行高度加下行高度
                UIBezierPath * path = [UIBezierPath bezierPathWithRect: lineFrame];
                [UIColor.orangeColor setStroke];
                [path stroke];
            }
            
        }
        free(origins);
    }
    
    // 渲染文字
    
    // 其他照旧
    // ...

github repo

相关博客 iOS: .txt 小说阅读器功能开发的 5 个老套路

上一篇:C++ SDL2中SDL_Renderer使用


下一篇:童年记忆第二弹!!! 如何用Python写一个植物大战僵尸