最近突然對原來做的一個項目有想法,當時是一個顯示文本的界面會循環滾動,因為時間比較倉促,就以實現需求為目的寫了一個滾動的TextView,結果還是效果挺好的。現在想把它分享給大家,這次寫demo是從零開始,沒在原來的項目基礎上改,因為我發現原來的實現方式有些不足,比如:英文單詞的切詞算法。另外自己也想加深一下印象,練練手。當然這個demo不會和我項目的一模一樣。我做了改進。
截圖與展示(截圖比較卡,實際滾動很平滑):
先說說它的優點吧:
1.當view的大小容不下文字的時候,這個view有循環滾動文字的能力。
2.滾動的時候輕輕點擊它,會停止滾動。
3.停止滾動時輕輕點擊它,又會繼續滾動。
4.可以通過手指拖動文字的顯示位置。
5.當view的大小能容下文字的時候,它不會滾動,也不會響應手指拖動。
缺點:
1.應用了自己的換行機制,與TextView官方的換行機制不一樣。
2.目前只支持中文和英文,其它國家的文字沒有測驗過。
3.切詞用到了ArrayList來裝載行數,如果文字太多的話可能會損性能,考慮用數組存索引優化。
4.失去了TextVew的其它比較好的功能如:支持html風格,支持超鏈接等等,因此它不太像是擴展TextView,就把它想成是一個新的View吧.繼承TextView只是偷了點懶,為了讓它在xml里配置textColor,textSize能取到效果。
適用范圍:
1.擴展成小說閱讀器
2.公告欄、小窗口展示消息或通知
3.滾動新聞
4.可以擴展成支持多種字體滾動播放
技術難點提要:
1.換行處理及英文切詞
2.測量view的長度和高度、能否滾動的判斷條件
3.循環滾動的實現
4.動畫的實現
6.手指托動文字
7.手指控制滾動
用到的api:
paint.measureText(string):測量paint畫String所需要的寬度
view.requestLayout():重新布局
vew.invalidate():刷新view
canvas.drawText():畫文字
textview.getLineHeight():獲取行高
技術難點詳解
1.換行處理及英文切詞解析
廣大讀者可能會問以下兩個問題:
為什么要換行處理?
TextView中每行字的所在位置是固定的,所以不需要考慮把每行文字都拿出來,它只要一個一個往下排,排到哪算哪;而現在,是需要滾動,並且頂端的文字滾出屏幕,它還會因為循環滾動出現在底端,這個時候是需要把每一行的文字和行號都拿到才能處理的。
為什么要英文切詞?
這個主要是因為英文單詞是好幾個字母組成,字母之間用空格分開,如果一個單詞剛好排到第一行末尾,但排到一半就換行了,看了肯定不舒服如:第一行末尾顯示"teach" 第二行開始"er", 一個"teacher"單詞被兩行瓜分了。這肯定是不允許的。我的處理辦法是,如果出現這樣的情況就直接換行顯示teacher。除非這個單詞太長了,一行都顯示不了,那就做兩行顯示。TextView就是這樣處理的。
先說說中文的換行算法吧:
主要是用paint.measureText(string)方法去計算要畫string的長度
例如有一個句子:你好,我是小明,很高興認識大家!
首先得知道一行的最大寬度,比如最大寬度為120;
系統會先計算第一個字符“你”的長度,然后與最大寬度對比,如果小於最大寬度就計算前兩個字符“你好”的長度,如果“你好”還是小於最大寬度120,就計算“你好,”,一直循環下去,假如到了“你好,我是小明,很高”時發現剛好超過120,那第一行就是“你好,我是小明,很”;然后對剩下的字符“高興認識大家!”進行上述處理,把切出來的行保存到lineStrings里;
以下是代碼與說明(以下代碼把英文字符排除在外,只考慮中文字符):
/** * 獲取一行的字符 * * @param MaxWidth 該行的最大長度 * @param str 需要分行的字符串 * @return */ private String getLineText(int MaxWidth, String str) { // 真實行 StringBuffer trueStringBuffer = new StringBuffer(); // 臨時行 StringBuffer tempStringBuffer = new StringBuffer(); for (int i = 0; i < str.length(); i++) { char c = str.charAt(i); String add = ""; add = "" + c; tempStringBuffer.append(add); String temp = tempStringBuffer.toString(); float width = getPaint().measureText(temp.toString()); if (width <= MaxWidth) { trueStringBuffer.append(add); } else { break; } } return trueStringBuffer.toString(); }
英文換行:
英文換行與中文換行其實一樣,只是英文不允許一個單詞被分成兩行,我們只要把一個單詞綁定在一起做判斷就ok了,英文有個特點,單詞間有空格,我們可以通過空格來切單詞。在原來的算法中,我是把所有的字符通過空格切開,然后再按中文換行處理。 這樣做有一些缺點;這次我將它改進:如果碰到一個字符不是中文而是英文,再判斷它的前一個字符是不是空格或者是不是一個句子的第一個字母,如果是的話代表是一個單詞的第一個字母,那么需要找到下一個空格的位置,然后直接把從上一空格到下一空格的字母認為是一個中文字,然后走中文的那套換行方法。
這里寫一個例子:"Hello, I am XiaoMing, nice to meet you!"
會把這個句子切成如下詞進行中文換行處理
Hello,
I
am
XiaoMing,
nice
to
meet
you!
以上紅字可以寫成方法:
a.如果碰到一個字符不是中文而是英文,再判斷它的前一個字符是不是空格或者是不是一個句子的第一個字母
/** * 是否為英文單詞的首字母 * * @param str * @param i * @return */ boolean isENWordStart(String str, int i) { if (i == 0) { return true; } else if (str.charAt(i - 1) == ' ') { return true; } return false; }
/** * 判斷是否為中文 * * @param c * @return */ private static final boolean isChinese(char c) { Character.UnicodeBlock ub = Character.UnicodeBlock.of(c); if (ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS || ub == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS || ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A || ub == Character.UnicodeBlock.GENERAL_PUNCTUATION || ub == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION || ub == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS) { return true; } return false; }
b.需要找到下一個空格的位置
這里注意,如果返回是-1,那就視為中文不管它了。
/** * 找到下一個空格的地方 * * @param i * @param str * @return */ int getNextSpecePlace(int i, String str) { for (int j = i; j < str.length(); j++) { char c = str.charAt(j); if (c == ' ') { return j; } } return -1; }
這兩個方法都有了,那么就可以把它視為中文了,就可以混在中文換行里。
/** * 獲取一行的字符 * * @param MaxWidth * @param str * @return */ private String getLineText(int MaxWidth, String str) { // 真實行 StringBuffer trueStringBuffer = new StringBuffer(); // 臨時行 StringBuffer tempStringBuffer = new StringBuffer(); for (int i = 0; i < str.length(); i++) { char c = str.charAt(i); String add = ""; // 如果c是字母需要考慮英文單詞換行功能 if (!isChinese(c) && isENWordStart(str, i)) { int place = getNextSpecePlace(i, str); // 找到下一個空格 if (place > -1) { add = str.substring(i, place) + " "; if (getPaint().measureText(add) > MaxWidth) { add = "" + c; } else { i = place; } } else { add = "" + c; } } else { add = "" + c; } tempStringBuffer.append(add); String temp = tempStringBuffer.toString(); float width = getPaint().measureText(temp.toString()); if (width <= MaxWidth) { trueStringBuffer.append(add); } else { break; } } return trueStringBuffer.toString(); }
最后把每行字放到ArrayList里保存以便ondraw()里使用:
/** * 生成多行字符串列表 * * @param MaxWidth */ public void generateTextList(int MaxWidth) { lineStrings = new ArrayList<String>(); String remain = scrollText; while (!remain.equals("")) { String line = getLineText(MaxWidth, remain); lineStrings.add(line); remain = remain.substring(line.length(), remain.length()); } };
2.測量view的長度和高度、能否滾動的判斷條件
說到高度在這里我要提兩個概念,
首先是view的高度,即顯示這個view所需要的高度
其它是顯示所有文字所需要的高度,這里我用absloutHeight變量
view的高度可以通過xml配置得來,也就是onMeasure的時候,而absloutHeight是需要看文字有多少行。前面已經講過換行算法,行數不難求出:lineString.size()
那么計算文字的真實高度就不難了:
可以把lineStrings.size()*getLineheight()就能算出真實高度。
代碼就是這樣實現的(exactlyHeight可以先無視):
/** * 測量高度 * * @param width:寬度 * @param heightMeasureSpec * @return */ private int MeasureHeight(int width, int heightMeasureSpec) { int mode = MeasureSpec.getMode(heightMeasureSpec); int height = MeasureSpec.getSize(heightMeasureSpec); generateTextList(width); int lines = lineStrings.size(); absloutHeight = lines * getLineHeight() + getPaddingBottom() + getPaddingTop(); // 如果是wrap_content if (mode == MeasureSpec.AT_MOST) { height = (int)Math.min(absloutHeight, height); exactlyHeight = -1; } else if (mode == MeasureSpec.EXACTLY) { exactlyHeight = height; } return height; }
3.循環滾動的實現
首先需要知道什么時候才會滾動:
當view的高度低於文字的高度的時候會出現滾動,也就是:
exactlyHeight < absloutHeight
這里給一張示意圖來表示exactlyHeight與absloutHeight的區別:黃色區域是文字區域,灰色區域是這個view的可見區域
注意:當xml里配置view的高度為wrap_content是不會滾動的,因為它剛好能容納文字,只有當配置為fill_parent和具體值時,才會滾動.回顧一下exactlyHeight是如何賦值的:
/** * 測量高度 * * @param width:寬度 * @param heightMeasureSpec * @return */ private int MeasureHeight(int width, int heightMeasureSpec) { int mode = MeasureSpec.getMode(heightMeasureSpec); int height = MeasureSpec.getSize(heightMeasureSpec); generateTextList(width); int lines = lineStrings.size(); absloutHeight = lines * getLineHeight() + getPaddingBottom() + getPaddingTop(); // 如果是wrap_content if (mode == MeasureSpec.AT_MOST) { height = (int)Math.min(absloutHeight, height); exactlyHeight = -1; } else if (mode == MeasureSpec.EXACTLY) { exactlyHeight = height; } return height; }
以上的所有准備工作做好了,就可以開始畫了:
如果不考慮滾動,那么就直接一個for循環把lineStrings畫完就結束了,但現在要考慮滾動,必需在它們for循環的基礎上做一個y方向上的位移,而且這個位移會變化,我們可以用一個變量來定義它currentY.
這里onDraw()方法是精髓。先看一張滾動示意圖,此圖描述了幾個滾動的關鍵狀態:
不難看出,當y值小於exactlyHeight - absloutHeight時就得讓它循環畫在view的可見范圍內我信就讓y=y+absloutHeight,但是當y
y >=exactlyHeight - absloutHeight&& y < textSize + exactlyHeight - absloutHeight時,這個時候需要在view的最底端畫出上半 部分文字
詳情如圖示:
另外當向下滾動時如果y >= absloutHeight時也是需要在頂端畫出一部分文字
代碼如下:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float x = getPaddingLeft(); float y = getPaddingTop(); float lineHeight = getLineHeight(); float textSize = getPaint().getTextSize(); for (int i = 0; i < lineStrings.size(); i++) { y = lineHeight * i + textSize + currentY; float min = 0; if (exactlyHeight > -1) { min = Math.min(min, exactlyHeight - absloutHeight); } if (y < min) { y = y + absloutHeight; } else if (y >= min && y < textSize + min) { //如果最頂端的文字已經到達需要循環從下面滾出的時候 canvas.drawText(lineStrings.get(i), x, y + absloutHeight, getPaint()); } if (y >= absloutHeight) { //如果最底端的文字已經到達需要循環從上面滾出的時候 canvas.drawText(lineStrings.get(i), x, y, getPaint()); y = y - absloutHeight; } canvas.drawText(lineStrings.get(i), x, y, getPaint()); } }
4.動畫的實現
這一塊簡單,只需要不停的用handler發消息控制currentY自增操作就ok了,為了不讓currentY越界,讓它在absloutHeight與-absloutHeight之間
代碼如下 :
handler = new Handler() { @Override public void handleMessage(Message msg) { if (absloutHeight <= getHeight()) { currentY = 0; stop(); return; } switch (msg.what) { case 0: { currentY = currentY - speed; resetCurrentY(); invalidate(); handler.sendEmptyMessageDelayed(0, delayTime); break; } case 1: { currentY += msg.arg1; resetCurrentY(); invalidate(); } } } /** * 重置currentY(當currentY超過absloutHeight時,讓它重置為0) */ private void resetCurrentY() { if (currentY >= absloutHeight || currentY <= -absloutHeight || getHeight() <= 0) { currentY = 0; } } };
5.手指托動文字
手指托動主要是在ontouch里寫代碼,在move的時候記錄前一次y坐標,然后根據當前這次move事件與上次move事件的差值,得到滾動的距離。
move事件先上代碼:
switch (event.getAction()) { case MotionEvent.ACTION_MOVE: float dy = event.getY() - lastY; lastY = event.getY(); // currentY = currentY + dy; Message msg = Message.obtain(); msg.what = 1; msg.arg1 = (int)dy; handler.sendMessage(msg); return true;
6.手指控制滾動
手指控制滾動主要在ontouch里的down和up/cancel事件里處理,當手指位移不超過performUpScrollStateDistance值時,表示手指是點擊而不是拖動,那么就讓它updateScrollStatus,這里updateScrollStatus就是讓它更改滾動狀態
/** * 更改滾動狀態 */ public void updateScrollStatus() { if (scrolling) { stop(); } else { play(); } } /** * 開始滾動 */ public void play() { if (!scrolling) { handler.sendEmptyMessage(0); scrolling = true; } } /** * 停止滾動 */ public void stop() { if (scrolling) { handler.removeMessages(0); scrolling = false; } }
case MotionEvent.ACTION_DOWN: distanceY = lastY = event.getY(); distanceX = event.getX(); pause(); case MotionEvent.ACTION_CANCEL: goOn(); float y = event.getY() - distanceY; float x = event.getX() - distanceX; if (Math.sqrt(y * y + x * x) < performUpScrollStateDistance) { updateScrollStatus(); } return true; }
源碼下載:MyScrolTextView.rar
首發地址:http://www.eoeandroid.com/thread-208337-1-1.html