1. 程式人生 > >小說閱讀器開發筆記(二)文本的排版與分頁

小說閱讀器開發筆記(二)文本的排版與分頁

繪制 最簡 bottom 換行符 cati RR 自定義 在屏幕上 模塊

??一個最簡單的小說閱讀器,也離不開文本的顯示。起初,我以為這是件十分容易完成的事,慢慢的,我才意識到其中的復雜性。很多時候,對於文本的顯示,一個文本框便能解決。但是,兼顧著排版與分頁等復雜的功能,常用的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

小說閱讀器開發筆記(二)文本的排版與分頁