Android-阅读器系列

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

2020-03-02  本文已影响0人  Super含

近期公司有一个电子书需求的开发,功能除了电子书的基本功能之外,还有长按选中,可以滑动高亮显示等等。最初是准备使用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。总体的说有几个难点

      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 -> {
                       });

总体思路:

无论是长按选中高亮显示,还是滑动绘制高亮显示,最关键是位置信息(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

上一篇下一篇

猜你喜欢

热点阅读