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

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

效果图如下:

功能主要包含:

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 ShowLine {
    public List<ShowChar> CharsData = null;

    public String getLineData() {
        String linedata = "";
        if (CharsData == null || CharsData.size() == 0) return linedata;
        for (ShowChar c : CharsData) {
            linedata = linedata + c.charData;
        }
        return linedata;
    }

    public float lintHeight;

}

每页的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的解释:

移动起点 moveTo 移动下一次操作的起点位置
连接直线 lineTo 添加上一个点到当前点之间的直线到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一样的格式,对批注这些功能是比较友好的。

微信公众号:SuperMaxs

星球了解:https://t.zsxq.com/yJ2fq3z

参考:https://blog.csdn.net/u014614038/article/details/74451484

发布了57 篇原创文章 · 获赞 40 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_37492806/article/details/84939157