渲染
由于要渲染图文或者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)
}
}