美文网首页Android-阅读器系列
Android 电子书功能实现、长按选中、高亮显示。 TXT

Android 电子书功能实现、长按选中、高亮显示。 TXT

作者: Super含 | 来源:发表于2020-03-02 11:38 被阅读0次

    近期公司有一个电子书需求的开发,功能除了电子书的基本功能之外,还有长按选中,可以滑动高亮显示等等。最初是准备使用FBReader,但是发现不太优化,之前用过FBReader。然后就网上找demo,发现对于选中高亮显示真的是有点尴尬。之后就参考一些博客,然后就自己搞了一下。

    image.png

    功能主要包含:

    1. 长按选中高亮显示

    2. 滑动绘制高亮显示

    3. 滑动绘制之后弹窗

    4. 句或者段落后面出现标识

    5. 绘制虚线

    电子书解析绘制翻页主线功能我用的是
    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字段,比较重要,只要理解他的作用,滑动绘制基本就很随意了。

    重点解释一下这两个含义:

    image.png

    具体的业务需求是:长按选中对应坐标下的字,背景绘制为高亮,左右两边绘制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的解释:

    image.png

    你仔细看,就会发现,这些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

    相关文章

      网友评论

        本文标题:Android 电子书功能实现、长按选中、高亮显示。 TXT

        本文链接:https://www.haomeiwen.com/subject/eospkhtx.html