Android 电子书功能实现、长按选中、高亮显示。 TXT
序
近期公司有一个电子书需求的开发,功能除了电子书的基本功能之外,还有长按选中,可以滑动高亮显示等等。最初是准备使用FBReader,但是发现不太优化,之前用过FBReader。然后就网上找demo,发现对于选中高亮显示真的是有点尴尬。之后就参考一些博客,然后就自己搞了一下。
image.png功能主要包含:
-
长按选中高亮显示
-
滑动绘制高亮显示
-
滑动绘制之后弹窗
-
句或者段落后面出现标识
-
绘制虚线
电子书解析绘制翻页主线功能我用的是
https://github.com/spuermax/WeYueReader github上的项目。然后其实他的功能就自己改里面的东西,添加新的业务需求代码。总之,收益很大。在此建议不要为完成功能而写代码。
涉及到的知识:
Canvas(drawText drawRect drawLine )、 Path (moveTo lintTo)、 Point (存字符的位置信息)、 Rect(绘制高亮) 、 事件分发 View刷新(postInvalidate、invalidate)。
绘制电子书的核心实现(分页、绘制、动画等等)不做太多的解释,可以自己了解一下现有的Demo。总体的说有几个难点
-
第一个是分页的逻辑,我看过几个项目,有一个是对每个字进行计算,然后用屏幕宽高加上分辨率来计算一行所需的字符个数,如果是单个字遍历,就直接追加就可以,如果是字符串使用截取,我用的是第二种;计算完行所需的数量,在根据屏幕高计算页所需的行数;段跟段之间的分割,大部分用的是"\n",或者是用一些特殊字符,比如“\u00”等等。
-
第二个是缓存,无论是页缓存或者章节缓存,这里的页缓存不是Android的PageCache,是绘制电子书的每一页,可以按照ViewPage的缓存三页,当前页和上一下和下一页;章节缓存可以使用File文件,对于章节缓存,如果单纯的电子书显示,建议采用章节缓存(可以使用RxJava)
Observable.concat(chapterContentBeans)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(resultMessage -> {
ChapterContentModel model = new Gson().fromJson(new Gson().toJson(resultMessage.getData()), ChapterContentModel.class);
StringBuffer stringBuffer = converGson(model);
BookSaveUtils.getInstance().saveChapterInfo(model.getBookCode(), model.getSentence(), stringBuffer.toString());
getView().chapterContent();
title = titles.poll();
}
, throwable -> Log.i("Flowable", "throwable = " + throwable.getMessage())
, () -> {
}
, disposable -> {
});
- 第三个是View的事件处理,在自定义显示View中,一个屏幕被划分为,中间区域和上一页下一页区域;如果在加上绘制标注和高亮绘制,会更加麻烦,不过只要了解事件的分发流程,这些都是代码量的问题。
总体思路:
无论是长按选中高亮显示,还是滑动绘制高亮显示,最关键是位置信息(x,y)值。我们需要将一个章节划分页,页里面划分行,行里面划分每一个字符,字符里面包含各自的位置信息。 OK。然后思路基本就出来了。 在你进行滑动绘制高亮的时候,使用MOVE事件的X、Y值,每次刷新FirstShowChar 和LastShowChar ,调用自定义View的postInvalidate,刷新onDraw方法。
滑动绘制的四种模式:
public enum Mode {
Normal,
PressSelectText,//按下滑动模式
SelectMoveForward,//向前滑动模式
SelectMoveBack//向后滑动模式
}
四种模式分别对应在滑动绘制的时候的不同状态,长按进行绘制单个字符的高亮,在单个字符高亮显示之后,按下左右两个Icon滑动,分别是向前滑动和向后滑动模式。
单个字符的Model:
public class ShowChar {
public char charData;
public boolean isSelected;
public Point TopLeftPosition = null;
public Point TopRightPosition = null;
public Point BottomLeftPosition = null;
public Point BottomRightPosition = null;
public float charWidth = 0;
}
每页的Model:
public class TxtPage {
public int position;
public String title;
public int titleLines; //当前 lines 中为 title 的行数。
public List<String> lines;
public List<String> linesChange;
public List<ShowLine> showLines;// 当前页的行数
public List<NotationBean> notationList;// 页面标注的信息
public String sentence;//章节第一句
}
上述的字、行、页的Model,贯穿绘制显示的电子书页面和一些别的扩展功能。
在这里姑且认为你已经开完电子书页面绘制的逻辑,直接开怼,其实绘制高亮的逻辑,跟绘制电子书的逻辑车没有太大的关联。
在复杂的功能也是一步一步走流程的。
1.长按绘制单个字的高亮
在Down事件中自定你长按事件,得到按下的X、Y值,根据xy值去查找对应的区域坐标,drawPath。
自定义事件
timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
((Activity) getContext()).runOnUiThread(new Runnable() {
@Override
public void run() {
if (currentMode == Mode.Normal) {
isLongClick = true;
currentMode = Mode.PressSelectText;
mPageLoader.setMode(Mode.PressSelectText);// 设置Mode
mPageLoader.setDown_x(x);
mPageLoader.setDown_y(y);
postInvalidate();
}
}
});
}
}, LONG_CLICK_DURATION);
主要是赋值点下DOWN事件的X、Y值,然后执行postInvalidate()。之后在onDraw方法里,绘制高亮。
private void drawSelectText() {
if (mCurrentMode == PageView.Mode.PressSelectText) {
drawPressSelectText();
} else if (mCurrentMode == PageView.Mode.SelectMoveForward) {
drawMoveSelectText();
} else if (mCurrentMode == PageView.Mode.SelectMoveBack) {
drawMoveSelectText();
}
}
分三种模式,长按高亮和向前滑动和向后滑动。先来看绘制高亮
private void drawPressSelectText() {
ShowChar showChar = searchPressShowChar(Down_x, Down_y);
if (showChar != null) {
FirstSelectShowChar = LastSelectShowChar = showChar;
mSelectTextPath.reset();
mSelectTextPath.moveTo(showChar.TopLeftPosition.x, showChar.TopLeftPosition.y);
mSelectTextPath.lineTo(showChar.TopRightPosition.x, showChar.TopRightPosition.y);
mSelectTextPath.lineTo(showChar.BottomRightPosition.x, showChar.BottomRightPosition.y + 10);
mSelectTextPath.lineTo(showChar.BottomLeftPosition.x, showChar.BottomLeftPosition.y + 10);
canvas.drawPath(mSelectTextPath, mSelectBgPaint);
//绘制两个Icon
drawBorderPoint();
Down_x = -1;
Down_y = -1;
}
}
根据DOWN事件的XY值,来确定所选定的字,定位一页内容的字。
public ShowChar searchPressShowChar(float down_X2, float down_Y2) {
TxtPage curPage = getCurPage(getPagePos());
List<ShowLine> showLines = curPage.showLines;
for (ShowLine l : showLines) {
for (ShowChar showChar : l.CharsData) {
if (down_Y2 > showChar.BottomLeftPosition.y) {
break;// 说明是在下一行
}
if (down_Y2 <= showChar.BottomLeftPosition.y && down_X2 >= showChar.BottomLeftPosition.x && down_X2 <= showChar.BottomRightPosition.x) {
return showChar;
}
}
}
return null;
}
可能会有疑问,怎么拿到每个字的位置。在绘制的当前的页的内容时候,是每一行,每一行绘制上去的,也就是drawText。
for (int n = 0; n < str.length(); n++) {
ShowChar showChar = new ShowChar();
showChar.charData = str.charAt(n);
showChar.id = i;
showChar.x = w;
showChar.y = top + 10;
//--------------------------保存位置--------------------------------
rightPosition = leftPosition + mTextPaint.measureText(str) / str.length();
Point topLeftPoint = new Point();
showChar.TopLeftPosition = topLeftPoint;
topLeftPoint.x = (int) leftPosition;
topLeftPoint.y = (int) (bottomPosition - mTextPaint.getTextSize());
Point bottomLeftPoint = new Point();
showChar.BottomLeftPosition = bottomLeftPoint;
bottomLeftPoint.x = (int) leftPosition;
bottomLeftPoint.y = (int) bottomPosition;
Point topRightPoint = new Point();
showChar.TopRightPosition = topRightPoint;
topRightPoint.x = (int) rightPosition;
topRightPoint.y = (int) (bottomPosition - mTextPaint.getTextSize());
Point bottomRightPoint = new Point();
showChar.BottomRightPosition = bottomRightPoint;
bottomRightPoint.x = (int) rightPosition;
bottomRightPoint.y = (int) bottomPosition;
leftPosition = rightPosition;
showCharList.add(showChar);
}
在drawContent中其实很多逻辑 ,这里只是把每个字赋值位置单独拿出来。使用的是Point。每个字,你可以把它当做一个矩形,矩形的四个点上下左右划分四个Point,保存在ShowChar中。
OK,回到上一步的定位到字之后绘制单个字的高亮,其中有两个FirstSelectShowChar和LastSelectShowChar字段,比较重要,只要理解他的作用,滑动绘制基本就很随意了。
重点解释一下这两个含义:
具体的业务需求是:长按选中对应坐标下的字,背景绘制为高亮,左右两边绘制ICON,接下来的按下操作如果是在左右两边的ICON区域内,左边的话,对应的是FirstSelectShowChar,在判断是否向上区域滑动;右边的话,对应的是LastSelectShowChar,在判断是否是向下区域滑动,否则就去下高亮绘制。需求不太明白的可以用掌阅或者书旗试一下。
我们第一次是长按,然后选中的是一个字,这时候,FirstSelectShowChar=LastSelectShowChar = 当前选中的字,也就是“,”;当前的Mode是PressSelectText。 然后看上面的黄色区域,和下面的绿色区域,和逗号两边的蓝色小框框。第二次点击,需要判断一下是否在左右的两边蓝色小框框,左边的话更新Mode模式为SelectMoveForward,右边的话更新模式为SelectMoveBack。
判断是否在左右两边的小框框:
public boolean checkIfSelectRegionMove(float x, float y) {
if (FirstSelectShowChar == null && LastSelectShowChar == null) {
return false;
}
float flx, frx, fty, fby;
flx = FirstSelectShowChar.TopLeftPosition.x - 40;
frx = FirstSelectShowChar.TopLeftPosition.x + 10;
fty = FirstSelectShowChar.TopLeftPosition.y;
fby = FirstSelectShowChar.BottomLeftPosition.y + 20;
float llx, lrx, lty, lby;
llx = LastSelectShowChar.TopRightPosition.x - 10;
lrx = LastSelectShowChar.TopRightPosition.x + 40;
lty = LastSelectShowChar.TopRightPosition.y;
lby = LastSelectShowChar.BottomRightPosition.y + 20;
if ((x >= flx && x <= frx) && (y >= fty && y <= fby)) {
mCurrentMode = PageView.Mode.SelectMoveForward;
return true;
}
if ((x >= llx && x <= lrx) && (y >= lty && y < lby)) {
mCurrentMode = PageView.Mode.SelectMoveBack;
return true;
}
return false;
}
更新完当前的Mode后,在进行下一轮滑动方向区域判断,是否向前滑动,是否想后滑动:
向前滑动判断:
public boolean isCanMoveForward(float down_x, float down_y) {
Path p = new Path();
p.moveTo(LastSelectShowChar.TopRightPosition.x, LastSelectShowChar.TopRightPosition.y);
p.lineTo(mPageView.getWidth(), LastSelectShowChar.TopRightPosition.y);
p.lineTo(mPageView.getWidth(), 0);
p.lineTo(0, 0);
p.lineTo(0, LastSelectShowChar.BottomRightPosition.y);
p.lineTo(LastSelectShowChar.BottomRightPosition.x, LastSelectShowChar.BottomRightPosition.y);
p.lineTo(LastSelectShowChar.TopRightPosition.x, LastSelectShowChar.TopRightPosition.y);
return computeRegion(p).contains((int) down_x, (int) down_y);
}
向后滑动判断:
public boolean isCanMoveBack(float down_x, float down_y) {
Path p = new Path();
p.moveTo(FirstSelectShowChar.TopLeftPosition.x, FirstSelectShowChar.TopLeftPosition.y);
p.lineTo(mPageView.getWidth(), FirstSelectShowChar.TopLeftPosition.y);
p.lineTo(mPageView.getWidth(), mPageView.getHeight());
p.lineTo(0, mPageView.getHeight());
p.lineTo(0, FirstSelectShowChar.BottomLeftPosition.y);
p.lineTo(FirstSelectShowChar.BottomLeftPosition.x, FirstSelectShowChar.BottomLeftPosition.y);
p.lineTo(FirstSelectShowChar.TopLeftPosition.x, FirstSelectShowChar.TopLeftPosition.y);
return computeRegion(p).contains((int) down_x, (int) down_y);
}
关于Path的解释:
你仔细看,就会发现,这些Path连成的区域其实就是上面的黄色区域和蓝色区域。
接下来是确认滑动方向后,进行的取值,简单来说就是不停的去赋值第一个字符或者是最后一个字符。
public void checkSelectForwardText(float down_x, float down_y) {
ShowChar moveToChar = searchPressShowChar(down_x, down_y);
Log.i("PageView", "moveToChar --" + moveToChar);
if (LastSelectShowChar != null && moveToChar != null) {
if (moveToChar.BottomLeftPosition.x < LastSelectShowChar.BottomLeftPosition.x
|| (moveToChar.BottomLeftPosition.x == LastSelectShowChar.BottomLeftPosition.x
&& moveToChar.TopRightPosition.y <= LastSelectShowChar.TopRightPosition.y)) {
Log.i("PageView", "我是checkSelectForwardText ------------ ");
FirstSelectShowChar = moveToChar;
checkSelectText();
}
}
}
public void checkSelectBackText(float down_x, float down_y) {
ShowChar moveToChar = searchPressShowChar(down_x, down_y);
if (FirstSelectShowChar != null && moveToChar != null) {
if (moveToChar.BottomRightPosition.x > FirstSelectShowChar.BottomRightPosition.x
|| (moveToChar.BottomRightPosition.x == FirstSelectShowChar.BottomRightPosition.x
&& moveToChar.TopRightPosition.y >= FirstSelectShowChar.TopRightPosition.y)) {
Log.i("PageView", "我是checkSelectBackText ------------ ");
LastSelectShowChar = moveToChar;
checkSelectText();
}
}
}
在这里其实有一个问题,如果是单行的话,这个代码逻辑没毛病,如果是多行的话,就会有小瑕疵。
private synchronized void checkSelectText() {
Boolean Started = false;
Boolean Ended = false;
//清空之前滑动的数据
mSelectLines.clear();
TxtPage curPage = getCurPage(getPagePos());
//当前页面没有数据或者没有选择或者已经释放了长按选择事件,不执行
if (curPage == null || FirstSelectShowChar == null || LastSelectShowChar == null) {
return;
}
//获取当前页面行数据
List<ShowLine> lines = curPage.showLines;
// 找到选择的字符数据,转化为选择的行,然后将行选择背景画出来。
for (ShowLine line : lines) {
ShowLine selectLine = new ShowLine();
selectLine.CharsData = new ArrayList<>();
for (ShowChar c : line.CharsData) {
if (!Started) {// 定位到行中的字,然后转换成行。 主要是分行
if (c.TopLeftPosition.x == FirstSelectShowChar.TopLeftPosition.x && c.TopLeftPosition.y == FirstSelectShowChar.TopLeftPosition.y) {
Started = true;
selectLine.CharsData.add(c);
if (c.TopLeftPosition.x == LastSelectShowChar.TopLeftPosition.x && c.TopLeftPosition.y == LastSelectShowChar.TopLeftPosition.y) {
Ended = true;
break;
}
}
} else {
if (c.TopLeftPosition.x == LastSelectShowChar.TopLeftPosition.x && c.TopLeftPosition.y == LastSelectShowChar.TopLeftPosition.y) {
Ended = true;
if (selectLine.CharsData != null || !selectLine.CharsData.contains(c)) {
selectLine.CharsData.add(c);
}
break;
} else {
selectLine.CharsData.add(c);
}
}
}
if (selectLine != null) {
mSelectLines.add(selectLine);
}
Log.i("PageLoaderSelect", "选择字体是 --- " + mSelectLines);
if (Started && Ended) {
return;
}
}
}
选择完数据之后,主要是把分行数据进行区分。
private void drawMoveSelectText() {
if (mSelectLines != null && mSelectLines.size() > 0) {
for (ShowLine line : mSelectLines) {
Path path = new Path();
if (line.CharsData.size() > 0) {
Log.i("PageLoaderSelect", "draw-------------move------------select------------text");
ShowChar firstChar = line.CharsData.get(0);
ShowChar lastChar = line.CharsData.get(line.CharsData.size() - 1);
path.moveTo(firstChar.TopLeftPosition.x, firstChar.TopLeftPosition.y);
path.lineTo(lastChar.TopRightPosition.x, lastChar.TopRightPosition.y);
path.lineTo(lastChar.BottomRightPosition.x, lastChar.BottomRightPosition.y + 10);
path.lineTo(firstChar.BottomLeftPosition.x, firstChar.BottomLeftPosition.y + 10);
path.lineTo(firstChar.TopLeftPosition.x, firstChar.TopLeftPosition.y);
canvas.drawPath(path, mSelectBgPaint);
drawBorderPoint();
}
}
}
}
OK。
有几个小问题
1.事件处理的逻辑没有写太多
2.Mode的变化刷新onDraw
3.电子书的数据在线数据的格式,以及对应的业务
其实第三个问题,真的是把我给搞崩溃了,其实现在看来,如果要做批注或者笔记的功能,单纯的文本TXT根本不适合,非要做的话,只能写很多很多逻辑代码。 以后可以考虑一下用HTML,就像Epub一样的格式,对批注这些功能是比较友好的。
PS:如果有什么问题,欢迎文章下面补充 。
微信公众号:SuperMaxs
如果感觉文章对您有帮助 ,可以关注我的公众号 SuperMaxs (如果有技术问题可以通过公众号加私人微信)。
image.png星球了解:https://t.zsxq.com/yJ2fq3z
参考:https://blog.csdn.net/u014614038/article/details/74451484