android自定义控件:坚直滚动的TextView

最近突然对原来做的一个项目有想法,当时是一个显示文本的界面会循环滚动,因为时间比较仓促,就以实现需求为目的写了一个滚动的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; 
} 
} 
};

**6.手指托动文字
**手指托动主要是在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;

**7.手指控制滚动
**手指控制滚动主要在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; 
}