小說閱讀器開發筆記(二)文本的排版與分頁
??一個最簡單的小說閱讀器,也離不開文本的顯示。起初,我以為這是件十分容易完成的事,慢慢的,我才意識到其中的復雜性。很多時候,對於文本的顯示,一個文本框便能解決。但是,兼顧著排版與分頁等復雜的功能,常用的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。
??參考目錄:
??Android View的繪制流程
??自定義View,有這一篇就夠了
??手把手教你寫一個完整的自定義View
小說閱讀器開發筆記(二)文本的排版與分頁