1, 解决一个 bug
正文结尾 (最后一行最后一个字)跟右边界, 有多余的空白间隔
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
2, 复制一整行
基于 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
}
将选中的一整行,涂上颜色
触发, 上面的手势代码中
selectRange
和 rects
, 赋值了。重绘,就好了
// 获得选中区域
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
基于 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, 框出每一行
有了前面的基础, 框出每一行就很简单
-(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);
}
// 渲染文字
// 其他照旧
// ...