小说阅读器开发笔记(二)文本的排版与分页

  一个最简单的小说阅读器,也离不开文本的显示。起初,我以为这是件十分容易完成的事,慢慢的,我才意识到其中的复杂性。很多时候,对于文本的显示,一个文本框便能解决。但是,兼顾着排版与分页等复杂的功能,常用的UI控件就显得力不从心了。为了实现这些较为特殊的功能,就需要通过自定义View来解决。本文将从认识View的概念讲起。

View的概念

  我们对一个应用最直观的印象,就是其使用界面,而界面又由一个或多个控件构成。事实上,我们在手机屏幕上所看到的一切元素,都是View的实例,更本质上讲,都是View所描绘出的一个个像素点。View是以矩形的方式显示在屏幕上,View是用户界面控件的基础。一行文字、一个按钮、一张图片,这些看似整体却又相互独立的元素,可以当作View在屏幕上的展示。
view01
  从安卓开发文档上可以看到,View的父类是Object类,而子类则包含了比 悉的ImageView、Button、TextView等等。因此,屏幕上呈现在我们眼前的种种元素,都可以抽象成对象。万物皆对象,而对象就有属性。要想更准确的理解View,就不可避免的直面官方的介绍:

  这个类表示用户界面组件的基本构造模块,一个View 在屏幕上占据了一块矩形区域,并负责绘图和事件处理。View是窗口小部件的基类,用于创建交互式UI组件(按钮、文本字段等)。ViewGroup子类是布局的基类,其是不可见的容器,包含着其他View(或其他ViewGroup),并定义它们的布局属性。

View
  View的绘制流程是从ViewRoot的performTraversals方法开始的,包含了测量、布局和绘图三个过程,分别是measure、layout和draw。其基本的设计思想是先测量视图的大小,接着设置视图的位置,即视图在屏幕上坐标,最后在所设定的区域描绘出所需的图形。具体的作用如下:

  • measure:判断是否需要重新计算View的大小,需要的话则计算;
  • layout:判断是否需要重新计算View的位置,需要的话则计算;
  • draw:判断是否需要重新绘制View,需要的话则重绘制。

自定义View

  安卓的开发内容各式各样,内置的UI控件往往不能满足我们的需求,正如我们的小说阅读器项目一样,普通的文本框已经无法实现排版和分页的功能,因此,自己定制一个UI控件就成了当务之急。安卓开发也提供可这种方法,允许我们根据自己的需求定义一个UI控件,这便是自定义View。自定义View并不复杂,一个最简单的自定义View需要重写onMeasure()、onDraw()两个函数,onMeasure负责对当前View的尺寸进行测量,onDraw负责把当前这个View绘制出来。完整的自定义Viewch程序还需要写至少写2个构造函数:

    public MyView(Context context) {
        super(context);
    }

    public MyView(Context context, AttributeSet attrs) {
        super(context, attrs); 
    }
    
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }
    
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
    }

重写onMeasure方法

  为什么要重写onMeasure方法?重写onMeasure方法又有什么用处呢?我刚开始接触的时候也并不是很懂。回想一下,在xml布局文件中,我们在设置控件的layout_width和layout_height属性时,常常使用wrap_content或match_parent作为参数值,而非具体的数值。这是由于要满足不同手机屏幕尺寸的需求,控件的大小不能写死,应具有一定的弹性。wrap_content的作用是强制性地使视图扩展以显示全部内容,布局元素将根据内容更改UI控件的大小。match_parent则强制性地使控件扩展,以填充布局单元内尽可能多的空间。
  当我们设置布局为wrap_content时,自定义控件并不能为我们处理大小,这时就需要重写onMeasure方法,并在该方法中测量控件大小的具体数值。onMeasure方法提供了widthMeasureSpec和 heightMeasureSpec两个参数,除了带有具体的大小数值外,还携带了布局的模式信息,即UNSPECIFIED,AT_MOST,EXACTLY三种模式,分别对应布局中的wrap_content、match_parent和指定数值。在这里,我们主要是处理UNSPECIFIED模式下的大小,即对具体内容的测量。
  小说阅读器重写onMeasure方法的具体代码如下:

    @SuppressLint("DrawAllocation")
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        paddingLeft = getPaddingLeft();
        paddingTop = getPaddingTop();
        paddingRight = getPaddingRight();
        paddingBottom = getPaddingBottom();

        viewWidth = widthSize;
        viewHeight = heightSize;
        readWidth = viewWidth - paddingLeft - paddingRight;
        readHeight = viewHeight - paddingTop - paddingBottom;
        setMatrix();
        getStrData(eBook);

        int width;
        int height ;
        if (widthMode == MeasureSpec.UNSPECIFIED) {
            width = textWidth;
        } else {
            width = widthSize;
        }

        if (heightMode == MeasureSpec.UNSPECIFIED) {
            height = textHeight;
        } else {
            height = heightSize;
        }

        setMeasuredDimension(width, height);
    }

重写onDraw方法

  重写onDraw方法比较好理解,我们要在这里把UI控件的内容绘制出来,可以是文本,可以是图形,当然也可以是图片。可以把Canvas当作画布,Paint当作画笔,而我们程序员就是画家,手敲代码就如同手持画笔,双手灵活的在其中作画,灵感所在而随心所欲。在这里我才深切的感受到自定义的真谛,完全可以由需求来定制,不局限于任何限制。
  Canvas提供了几种绘制方法,可以满足大部分的需求:

  • drawLine(s)画直线:前四个参数为直线的起点和终点的 XY 轴坐标。
  • drawRect画矩形:确定矩形四个顶点的位置配上画笔即可。
  • drawText画文本:在 x,y 位置开始画文本其中 y 表示文字的基线(baseline)所在的坐标,而 x坐标就是文字绘制的起始水平坐标,但是每个文字本身两侧都有一定的间隙,故实际文字的位置会比 x 的位置再偏右侧一些。
  • drawBitmap画图片bitmap:要画在画布上的位图,matrix:构建的矩阵作用于将要画出的位图。
  • drawArc画圆弧userCenter 若为true表示此弧会和 RectF 中心相连形成扇形,否则,弧的两头直接相连形成图形。startAngle,负数或大于360则对360模除。sweepAngle,大于360,则画出一圈。角度:以 RectF 中心为坐标中心,中心所在直线为水平线,负角度弧斜上走,正角度弧斜下走,或者说以时钟三点钟为0度,顺时针为正,逆时针为负。
  • drawCircle画圆cx,cy 为所画圆的中心坐标,radius 为圆的半径。当画笔设置了 StrokeWidth 时,圆的半径=内圆的半径+StrokeWidth/2。
  • drawColor,drawRGB画颜色:画整个画布的背景,但若区域受到剪裁,则只绘制剪裁区域的背景。
  • drawOval画椭圆:绘制椭圆,类似drawRect。

  由于小说阅读器要实现页面的排版,根据需要可设置为左对齐、右对齐和两端对齐,我的解决方案是对所有文字进行单独绘制,根据文字的宽度设置对应得坐标,设置对齐方式时,微调其坐标位置即可,而不需要做太大的改动。因为每个文字是单独绘制的,可以十分容易的调整其字间距、行间距以及段与段之间的距离。
  小说阅读器重写onDraw方法的具体代码如下:

    @SuppressLint("DrawAllocation")
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        PageModel page = chapterModel.getPageModels().get(chapterModel.getIndex());
        canvas.drawBitmap(bitmap,matrix,mPaint);
        for(int i = 0;i<page.getLineModels().size();i++){
            LineModel line= page.getLineModels().get(i);
            int num =line.getStringList().size();
            float spacing;
            if(num == 0){
                spacing = 0;
            }else {
                spacing = line.getStrDiff()/(float)(num-1);
            }
            for (int j=0;j<num;j++){
                mPaint.setColor(line.getStrColors().get(j));
                canvas.drawText(line.getStringList().get(j), line.getStrX().get(j)+ paddingLeft + j*spacing,
                        (i + 1) * fontSize * 1.5f + paddingTop - 4, mPaint);
            }
        }
    }

自定义布局属性

  在UI控件的使用过程中,我们通常可以通过改变属性值来改变控件的状态,自定义View也一样,为我们提供了一种自定义布局属性的方法。自定义View的构造函数中,提供了带有布局属性的参数,不过在获取这些参数之前,需要在res目录中的values文件夹下新建一个attrs.xml文件。本例中我定义的attrs.xml文件内容如下所示,包含了颜色、字体大小、文本内容、背景颜色或图片等属性。
  值得注意的是,format指定的参数,具有特殊的含义,具体内容如下,使用时需要一一对应,以免出错:

  • reference: 表示引用,参考某一资源ID;
  • string: 表示字符串;
  • color: 表示颜色值;
  • dimension: 表示尺寸值;
  • boolean: 表示布尔值;
  • integer: 表示整型值;
  • float: 表示浮点值;
  • fraction: 表示百分数;
  • enum: 表示枚举值;
  • flag: 表示位运算。

  本例新建的attrs.xml文件内容如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ReadView">
        <attr name="color" format="color"/>
        <attr name="fontSize" format="dimension"/>
        <attr name="text" format="string"/>
        <attr name="background" format="reference"/>
        <attr name="type" format="enum">
            <enum name="common" value="1"/>
            <enum name="material" value="2"/>
        </attr>
        <attr name="flag">
            <flag name="flag1" value="0x01"/>
            <flag name="flag2" value="0x02"/>
            <flag name="flag4" value="0x04"/>
        </attr>
    </declare-styleable>
</resources>

  获取属性值代码如下所示:第二个参数是属性的默认值,当在xml文件中不使用该属性时,系统会获取到默认值,做默认处理。

    TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.ReadView);
    //获取字体大小,默认大小是24
    fontSize = (int) ta.getDimension(R.styleable.ReadView_fontSize, 24);
    //获取文字内容
    eBook = ta.getString(R.styleable.ReadView_text);
    //获取文字颜色,默认颜色是BLUE
    textColor = ta.getColor(R.styleable.ReadView_color, Color.BLACK);
    //获取背景
    background = ta.getResourceId(R.styleable.ReadView_background,R.drawable.paper);
    ta.recycle();

文本的排版与分页

  文本的排版与分页,是小说阅读器重点解决的问题。排版的引证解释是指按照稿本把铅字、图版等排在一起拼成书报的版子,以供印刷。分页更好理解,是将一本书或者一个章节,按照一定的版面,一张一张的剥离开来。排版与分页看似不同,却基本原理却相差无几。由于这是一个小说阅读器的开发,暂时不考虑图片的显示问题,所有排版均针对文本而言。因此主要体现在三个方面,首行缩进两个字符,自动换行以及文本的两端对齐。至于字间距、行间距、甚至段间距,也可以做相应的调整。
  文本的排版方案,我的思路是首先解决的是文本的自动换行问题,这需要测量字符的宽度,通过累加字符的宽度,然后对比控件宽度,大于时或遇到换行符时就切换下一行。由于每个字符需要单独绘制,这就需要设置每个字符的坐标,这里也比较容易解决,在累加字符宽度时,加入些字间距调整,就是文本在画布上的坐标。至于首行缩进问题,就更简单了,只要判断是段落开始时,加入两个空格符即可。还有一个问题,是解决因为半角全角符号、中英文混排所造成的,文本不对齐现象。我的解决办法是通过计算每行文字的宽度与控件宽度的差值,然后平均加到每个字符的横坐标上作为补充,使得每一行的首尾宽度一致,实现了文本的两端对齐。
text
  分页也一样,通过累加字符的高度值和行间距,然后对比控件的高度值,就可以准确的分出每一个页面来。为了提高效率,控件应该减少对大文件的处理,因此该小说阅读器只针对章节进行排版分页。在绘制文本时,出现了比较明显的锯齿而不清晰,刚开始我并不知道什么问题,最后通过加入mPaint.setAntiAlias(true)解决,该函数是用来防止边缘的锯齿。

    private void getStrData(String str){
        readTool.init();
        readTool.setStrCaptal(fontSize,textColor);
        int lineWidth = 2*fontSize;
        for(int i=0;i<str.length();i++){
            String subStr;
            if(i < str.length()-1){
                subStr  = str.substring(i, i + 1);
            }else {
                subStr = str.substring(i);
            }
            int fontWidth = (int)mPaint.measureText(subStr);
            lineWidth = lineWidth + fontWidth;
            if (subStr.equals("\n")){
                readTool.addPage(readHeight,fontSize);
                readTool.addLine(0);
                readTool.setStrCaptal(fontSize,textColor);
                lineWidth = 2*fontSize;
            }else if(lineWidth < readWidth){
                readTool.addStrArr(subStr,fontWidth,lineWidth-fontWidth,textColor);
            }else{
                readTool.addPage(readHeight,fontSize);
                readTool.addLine(readWidth-lineWidth+fontWidth);
                lineWidth = fontWidth;
                readTool.addStrArr(subStr,lineWidth,0,textColor);
            }
        }
        readTool.addEnd(readHeight,fontSize);
        lineWidth = 0;
        chapterModel.setPageModels(readTool.getPageModels());
        lineNum = readTool.getLineModels().size();
        if (lineNum > 1){
            textWidth = getWidth();
        }else {
            textWidth = lineWidth;
        }
        textHeight = lineNum * (fontSize+lineHeight);
    }

  设置背景图片时,由于缩放的缘故,图片十分不清晰。查阅相关资料后,我是通过矩阵Matrix的坐标映射和数值转换来解决。实际上不论2D还是3D,我们要将图形显示在屏幕上,都离不开Matrix,所以说Matrix是一个在背后辛勤工作的劳模。Matrix是一个矩阵,最根本的作用就是坐标转换,其基本原理是:

我们所用到的变换均属于仿射变换,仿射变换是线性变换(缩放,旋转,错切)和平移变换(平移) 的复合。
仿射变换概念:仿射变换其实就是二维坐标到二维坐标的线性变换,保持二维图形的“平直性”(即变换后直线还是直线不会打弯,圆弧还是圆弧)和“平行性”(指保持二维图形间的相对位置关系不变,平行线还是平行线,而直线上点的位置顺序不变),可以通过一系列的原子变换的复合来实现,原子变换就包括:平移、缩放、翻转、旋转和错切。这里除了透视可以改变z轴以外,其他的变换基本都是上述的原子变换,所以,只要最后一行是0,0,1则是仿射矩阵。

    private void setMatrix(){
        float bitmapWidth = bitmap.getWidth();
        float bitmapHeight = bitmap.getHeight();
        float scaleX = viewWidth / bitmapWidth;
        float scaleY = viewHeight / bitmapHeight;
        matrix = new Matrix();
        matrix.postTranslate(0, 0);
        matrix.preScale(scaleX, scaleY);
    }

  文本的排版与分页效果图:
效果图

  完整项目代码见:小说阅读器分步代码-part2

  参考目录:
  Android View的绘制流程
  自定义View,有这一篇就够了
  手把手教你写一个完整的自定义View

猜你喜欢

转载自www.cnblogs.com/wathinst/p/9172471.html