小说阅读器开发笔记(二)文本的排版与分页
一个最简单的小说阅读器,也离不开文本的显示。起初,我以为这是件十分容易完成的事,慢慢的,我才意识到其中的复杂性。很多时候,对于文本的显示,一个文本框便能解决。但是,兼顾着排版与分页等复杂的功能,常用的UI控件就显得力不从心了。为了实现这些较为特殊的功能,就需要通过自定义View来解决。本文将从认识View的概念讲起。
View的概念
我们对一个应用最直观的印象,就是其使用界面,而界面又由一个或多个控件构成。事实上,我们在手机屏幕上所看到的一切元素,都是View的实例,更本质上讲,都是View所描绘出的一个个像素点。View是以矩形的方式显示在屏幕上,View是用户界面控件的基础。一行文字、一个按钮、一张图片,这些看似整体却又相互独立的元素,可以当作View在屏幕上的展示。
从安卓开发文档上可以看到,View的父类是Object类,而子类则包含了比 悉的ImageView、Button、TextView等等。因此,屏幕上呈现在我们眼前的种种元素,都可以抽象成对象。万物皆对象,而对象就有属性。要想更准确的理解View,就不可避免的直面官方的介绍:
这个类表示用户界面组件的基本构造模块,一个View 在屏幕上占据了一块矩形区域,并负责绘图和事件处理。View是窗口小部件的基类,用于创建交互式UI组件(按钮、文本字段等)。ViewGroup子类是布局的基类,其是不可见的容器,包含着其他View(或其他ViewGroup),并定义它们的布局属性。
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();
文本的排版与分页
文本的排版与分页,是小说阅读器重点解决的问题。排版的引证解释是指按照稿本把铅字、图版等排在一起拼成书报的版子,以供印刷。分页更好理解,是将一本书或者一个章节,按照一定的版面,一张一张的剥离开来。排版与分页看似不同,却基本原理却相差无几。由于这是一个小说阅读器的开发,暂时不考虑图片的显示问题,所有排版均针对文本而言。因此主要体现在三个方面,首行缩进两个字符,自动换行以及文本的两端对齐。至于字间距、行间距、甚至段间距,也可以做相应的调整。
文本的排版方案,我的思路是首先解决的是文本的自动换行问题,这需要测量字符的宽度,通过累加字符的宽度,然后对比控件宽度,大于时或遇到换行符时就切换下一行。由于每个字符需要单独绘制,这就需要设置每个字符的坐标,这里也比较容易解决,在累加字符宽度时,加入些字间距调整,就是文本在画布上的坐标。至于首行缩进问题,就更简单了,只要判断是段落开始时,加入两个空格符即可。还有一个问题,是解决因为半角全角符号、中英文混排所造成的,文本不对齐现象。我的解决办法是通过计算每行文字的宽度与控件宽度的差值,然后平均加到每个字符的横坐标上作为补充,使得每一行的首尾宽度一致,实现了文本的两端对齐。
分页也一样,通过累加字符的高度值和行间距,然后对比控件的高度值,就可以准确的分出每一个页面来。为了提高效率,控件应该减少对大文件的处理,因此该小说阅读器只针对章节进行排版分页。在绘制文本时,出现了比较明显的锯齿而不清晰,刚开始我并不知道什么问题,最后通过加入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。