iOS-小说阅读器功能拆分之长按选中文本

1,259 阅读3分钟

渲染

由于要渲染图文或者html支持,我这里选用的是DTCoreText,它对html格式的支持非常友好,而且可以自定义图片及特殊tag的样式定制

我用的是 DTAttributedLabel ,阅读页面整体是继承自DTAttributedLabel,手势添加也是在这个子类中实现

阅读器分页有一点比较奇怪,如果设置了段落首行缩进,在每一页的第一行都会认为是段落首行而有缩进,DTAttributedLabel的处理方式是,每一页都需要重新定制对应的 layoutFrame

contentView.attributedString = pageModel.content

contentView.contentRange = pageModel.contentRange

            contentView.attributedString = pageModel.chapterContent

            var rect = contentView.bounds

            let insets = contentView.edgeInsets

            rect.origin.x    += insets.left;

            rect.origin.y    += insets.top;

            rect.size.width  -= (insets.left + insets.right);

            rect.size.height -= (insets.top  + insets.bottom);

            let layoutFrame = contentView.layouter.layoutFrame(with: rect, range: pageModel.contentRange)

            contentView.layoutFrame = layoutFrame

长按选中

上面是简单介绍了渲染控件,那如何对内容长按,选中,添加笔记或者划线之类的功能呢?

在长按手势的事件处理中,主要做了如下事件:

  • 找到触摸点最近的游标位置
  • 根据游标位置找到对应的行或者段的命中range
  • 根据命中range计算所有需要选中的rect
  • 绘制选中底色和左右游标

找到游标位置

DTAttributedTextContentView提供了一个closestCursorIndexToPoint方法,只要将触摸点传过去,就可以拿到对应的cursorIndex

命中range

//    MARK: utils

    func locateParaRangeBy(index: Int) -> NSRange {

        var targetRange = NSRange()

        // 整行选中

        for line in layoutFrame.lines {

            let _line = line as! DTCoreTextLayoutLine

            let lineRange = _line.stringRange()

            if index >=  lineRange.location && index <= lineRange.location + lineRange.length {

                targetRange = lineRange

                break

            }

        }

        // 整段选中

//        for item in layoutFrame.paragraphRanges {

//            let paraRange: NSRange = item as! NSRange

//            if index >= paraRange.location && index < paraRange.location + paraRange.length {

//                //                找到hit段落

//                targetRange = paraRange

//                break

//            }

//        }

        return targetRange

    }

笔者做的时候尝试过精确到某一个字的选中,经过尝试后未能如愿,所以退而求其次,选择整行或者整段的选中了

计算选中的rects

然后就是根据命中的range计算要绘制的rects数组

func lineArrayFrom(range: NSRange) -> [CGRect] {

        if layoutFrame == nil {

            return []

        }

        var lineArray: [CGRect] = []

        var line = layoutFrame.lineContaining(UInt(range.location))

        let selectedMaxIndex = range.location + range.length

        var startIndex = range.location

        

        while line != nil, line!.stringRange().location < selectedMaxIndex {

            let lineMaxIndex = line!.stringRange().location + line!.stringRange().length

            let startX = line!.frame.origin.x + line!.offset(forStringIndex: startIndex)

            let lineEndOffset = lineMaxIndex <= selectedMaxIndex ? line?.offset(forStringIndex: lineMaxIndex) : line?.offset(forStringIndex: selectedMaxIndex)

            let endX = line!.frame.origin.x + lineEndOffset!

            let rect = CGRect(x: startX, y: line!.frame.origin.y, width: endX - startX, height: line!.frame.size.height)

            lineArray.append(rect)

            startIndex = lineMaxIndex

            line = layoutFrame.lineContaining(UInt(startIndex))

            if lineMaxIndex == selectedMaxIndex || line == nil {

                break

            }

            

        }

        

        return lineArray

    }

绘制选中色和左右游标

private func drawSelectedLines(context: CGContext?) -> Void {

        if selectedLineArray.isEmpty {

            return

        }

        let path = CGMutablePath()

        for item in selectedLineArray {

            path.addRect(item)

        }

        let color = WL_READER_SELECTED_COLOR

        

        context?.setFillColor(color.cgColor)

        context?.addPath(path)

        context?.fillPath()

    }
// MARK - 绘制左右游标

    private func drawLeftRightCursor(context:CGContext?) {

        if selectedLineArray.isEmpty {

            return

        }

        let firstRect = selectedLineArray.first!

        leftCursor = CGRect(x: firstRect.origin.x - 4, y: firstRect.origin.y, width: 4, height: firstRect.size.height)

        let lastRect = selectedLineArray.last!

        rightCursor = CGRect(x: lastRect.maxX, y: lastRect.origin.y, width: 4, height: lastRect.size.height)

        

        context?.addRect(leftCursor)

        context?.addRect(rightCursor)

        context?.addEllipse(in: CGRect(x: leftCursor.midX - 3, y: leftCursor.origin.y - 6, width: 6, height: 6))

        context?.addEllipse(in: CGRect(x: rightCursor.midX - 3, y: rightCursor.maxY, width: 6, height: 6))

        context?.setFillColor(WL_READER_CURSOR_COLOR.cgColor)

        context?.fillPath()

    }

长按选中到这里也就结束了,那么还有个长按之后拖动,并且选中范围也可以随着改变的功能该如何呢?

简而言之,就是在拖动过程中,实时地更新命中range和计算对应的选中rects,然后实时绘制即可

拖动

else if gesture.state == .changed {

            if touchIsValide {

                hideMenuItemView()

                showMagnifierView(point: hitPoint)

                self.magnifierView?.locatePoint = hitPoint

                self.updateHitRangeWith(point: hitPoint, touchIsLeft: touchLeft)

                selectedLineArray = self.lineArrayFrom(range: hitRange)

                self.setNeedsDisplay(self.bounds)

            }

            if hitPoint.y < 0 {

                showMenuItemView(toPreviousPage: true)

            }else if hitPoint.y > bounds.height {

                showMenuItemView(toNextPage: true)

            }

        }
func updateHitRangeWith(point: CGPoint, touchIsLeft: Bool) -> Void {

        let hitIndex = self.closestCursorIndex(to: point)

        if touchIsLeft {

            if hitIndex >= hitRange.location + hitRange.length {

                return

            }

            hitRange = NSRange.init(location: hitIndex, length: hitRange.location + hitRange.length - hitIndex)

        }else {

            if hitIndex <= hitRange.location {

                return

            }

            hitRange = NSRange.init(location: hitRange.location, length: hitIndex - hitRange.location + 1)

        }

    }