需求
- 當TextView限制最大行數的時候,文本內容超過最大行數可自動實現文本內容向上滾動
- 隨著TextView的文本內容的改變,可自動計算換行并實時的向上滾動
- 文字向上滾動后可向下滾動回到正確的水平位置
自定義方法
- 自定義一個View,繼承自View,定重寫里面的onDraw方法
- 文字的滾動是用Canvas對象的drawText方法去實現的
public void drawText(@NonNull String text, float x, float y, @NonNull Paint paint) {
native_drawText(mNativeCanvasWrapper, text, 0, text.length(), x, y, paint.mBidiFlags,
paint.getNativeInstance(), paint.mNativeTypeface);
}
通過控制y參數可實現文字不同的垂直距離,這里的x,y并不代表默認橫向坐標為0,縱向坐標為0的坐標,具體詳解我覺得這篇博客解釋的比較清楚,我們主要關注的是參數y的控制,y其實就是text的baseline,這里還需要解釋text的杰哥基準線:
image.png
ascent:該距離是從所繪字符的baseline之上至該字符所繪制的最高點。這個距離是系統推薦。
descent:該距離是從所繪字符的baseline之下至該字符所繪制的最低點。這個距離是系統推薦的。
top:該距離是從所繪字符的baseline之上至可繪制區域的最高點。
bottom:該距離是從所繪字符的baseline之下至可繪制區域的最低點。
leading:為文本的線之間添加額外的空間,這是官方文檔直譯,debug時發現一般都為0.0,該值也是系統推薦的。
特別注意: ascent和top都是負值,而descent和bottom:都是正值。
由于text的baseline比較難計算,所以我們大約取y = bottom - top的值,這么坐位baseline的值不是很精確,但是用在此自定義控件上文字的大小間距恰好合適,在其他場景可能還是需要精確的去計算baseline的值
動畫效果實現
- 通過循環觸發執行onDraw方法來實現文字的上下滑動,當然在每次觸發onDraw之前首先要計算文字的baseline的值
- 通過設置Paint的alpha的值來控制透明度,alpha的值的變化要和文字baseline的變化保持同步,因為文字上下滑動和文字的透明度要做成一個統一的動畫效果
- 文字的換行,首先用measureText來測量每一個字的寬度,然后持續累加,直到累加寬度超過一行的最大限制長度之后就追加一個換行符號,當然我們是用一個List作為容器來容納文本內容,一行文本就是list的一個item所以不用追加換行符號,直接添加list的item
- 在實現文字上下滑動以及透明度變化的時候遇到一個問題,就是上一次的滑動剛剛滑到一半,文字的baseline和透明度已經改變到一半了,這時候又有新的文本追加進來,那么新的文本會導致一次新的滑動動畫和文字透明度改變動畫會和之前的重疊,造成上一次的滑動效果被中斷,文字重新從初始值開始滑動,所以會看到文字滑動到一半又回到初始位置重新開始滑動,那么如果一直不斷的有文字追加進來會導致文字滑動反復的中斷開始,這種效果當然不是我們想要的,我們想要的就是文字滑動到一半了,那么已經滑動的文字保持當前的狀態,新追加進來的問題從初始值開始滑動,滑動到一半的文字從之前的狀態繼續滑動,所以就需要記錄文字的滑動間距,透明度等信息并保存下來
代碼實現
public class AutoScrollTextView extends View {
public interface OnTextChangedListener {
void onTextChanged(String text);
}
private class TextStyle {
int alpha;
float y;
String text;
TextStyle(String text, int alpha, float y) {
this.text = text;
this.alpha = alpha;
this.y = y;
}
}
public static final int SCROLL_UP = 0, SCROLL_DOWN = 1;
private List<TextStyle> textRows = new ArrayList<>();
private OnTextChangedListener onTextChangedListener;
private Paint textPaint;
/**
* 標題內容
*/
private String title;
/**
* 是否是標題模式
*/
private boolean setTitle;
/**
* 當前的文本內容是否正在滾動
*/
private boolean scrolling;
/**
* 文字滾動方向,支持上下滾動
*/
private int scrollDirect;
/**
* 每行的最大寬度
*/
private float lineMaxWidth;
/**
* 最大行數
*/
private int maxLineCount;
/**
* 每行的高度,此值是根據文字的大小自動去測量出來的
*/
private float lineHeight;
public AutoScrollTextView(Context context) {
super(context);
init();
}
public AutoScrollTextView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public AutoScrollTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public AutoScrollTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init();
}
private void init() {
textPaint = createTextPaint(255);
lineMaxWidth = textPaint.measureText("一二三四五六七八九十"); // 默認一行最大長度為10個漢字的長度
maxLineCount = 4;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
float x;
float y = fontMetrics.bottom - fontMetrics.top;
lineHeight = y;
if (setTitle) {
x = getWidth() / 2 - textPaint.measureText(title) / 2;
canvas.drawText(title, x, y, textPaint);
} else {
synchronized (this) {
if (textRows.isEmpty()) {
return;
}
scrolling = true;
x = getWidth() / 2 - textPaint.measureText(textRows.get(0).text) / 2;
if (textRows.size() <= 2) {
for (int index = 0;index < 2 && index < textRows.size();index++) {
TextStyle textStyle = textRows.get(index);
textPaint.setAlpha(textStyle.alpha);
canvas.drawText(textStyle.text, x, textStyle.y, textPaint);
}
} else {
boolean draw = false;
for (int row = 0;row < textRows.size();row++) {
TextStyle textStyle = textRows.get(row);
textPaint.setAlpha(textStyle.alpha);
canvas.drawText(textStyle.text, x, textStyle.y, textPaint);
if (textStyle.alpha < 255) {
textStyle.alpha += 51;
draw = true;
}
if (textRows.size() > 2) {
if (scrollDirect == SCROLL_UP) {
// 此處的9.0f的值是由255/51得來的,要保證文字透明度的變化速度和文字滾動的速度要保持一致
// 否則可能造成透明度已經變化完了,文字還在滾動或者透明度還沒變化完成,但是文字已經不滾動了
textStyle.y = textStyle.y - (lineHeight / 9.0f);
} else {
if (textStyle.y < lineHeight + lineHeight * row) {
textStyle.y = textStyle.y + (lineHeight / 9.0f);
draw = true;
}
}
}
}
if (draw) {
postInvalidateDelayed(50);
} else {
scrolling = false;
}
}
}
}
}
private Paint createTextPaint(int a) {
Paint textPaint = new Paint();
textPaint.setTextSize(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 15, getContext().getResources().getDisplayMetrics()));
textPaint.setColor(getContext().getColor(R.color.color_999999));
textPaint.setAlpha(a);
return textPaint;
}
public void resetText() {
synchronized (this) {
textRows.clear();
}
}
public void formatText() {
scrollDirect = SCROLL_DOWN;
StringBuffer stringBuffer = new StringBuffer("\n");
synchronized (this) {
for (int i = 0;i < textRows.size();i++) {
TextStyle textStyle = textRows.get(i);
if (textStyle != null) {
textStyle.alpha = 255;
// textStyle.y = 45 + 45 * i;
stringBuffer.append(textStyle.text + "\n");
}
}
}
postInvalidateDelayed(100);
LogUtil.i("formatText:" + stringBuffer.toString());
}
public void appendText(String text) {
setTitle = false;
scrollDirect = SCROLL_UP;
synchronized (this) {
if (textRows.size() > maxLineCount) {
return;
}
if (text.length() <= 10) {
if (textRows.isEmpty()) {
textRows.add(new TextStyle(text, 255, lineHeight + lineHeight * textRows.size()));
} else {
TextStyle pre = textRows.get(textRows.size() - 1);
textRows.set(textRows.size() - 1, new TextStyle(text, pre.alpha, pre.y));
}
} else {
List<String> list = new ArrayList<>();
StringBuffer stringBuffer = new StringBuffer();
float curWidth = 0;
for (int index = 0;index < text.length();index++) {
char c = text.charAt(index);
curWidth += textPaint.measureText(String.valueOf(c));
if (curWidth <= lineMaxWidth) {
stringBuffer.append(c);
} else {
if (list.size() < maxLineCount) {
list.add(stringBuffer.toString());
curWidth = 0;
index--;
stringBuffer.delete(0, stringBuffer.length());
} else {
break;
}
}
}
if (!TextUtils.isEmpty(stringBuffer.toString()) && list.size() < maxLineCount) {
list.add(stringBuffer.toString());
}
if (textRows.isEmpty()) {
for (int i = 0;i < list.size();i++) {
if (i < 2) {
textRows.add(new TextStyle(list.get(i), 255, lineHeight + lineHeight * i));
} else {
textRows.add(new TextStyle(list.get(i), 0, lineHeight + lineHeight * i));
}
}
} else {
for (int i = 0;i < list.size();i++) {
if (textRows.size() > i) {
TextStyle pre = textRows.get(i);
textRows.set(i, new TextStyle(list.get(i), pre.alpha, pre.y));
} else {
TextStyle pre = textRows.get(textRows.size() - 1);
if (i < 2) {
textRows.add(new TextStyle(list.get(i), 255, pre.y + lineHeight));
} else {
textRows.add(new TextStyle(list.get(i), 0, pre.y + lineHeight));
}
}
}
}
}
if (!scrolling) {
invalidate();
}
}
textChanged();
}
public void setTextColor(int corlor) {
textPaint.setColor(corlor);
invalidate();
}
public void setTitle(int resId) {
this.title = getContext().getString(resId);
setTitle = true;
invalidate();
}
public void setOnTextChangedListener(OnTextChangedListener onTextChangedListener) {
this.onTextChangedListener = onTextChangedListener;
}
private void textChanged() {
if (onTextChangedListener != null) {
onTextChangedListener.onTextChanged(getText());
}
}
public String getText() {
StringBuffer allText = new StringBuffer();
for (TextStyle textStyle : textRows) {
allText.append(textStyle.text);
}
return allText.toString();
}
public int getScrollDirect() {
return scrollDirect;
}
public void setScrollDirect(int scrollDirect) {
this.scrollDirect = scrollDirect;
}
public float getLineMaxWidth() {
return lineMaxWidth;
}
public void setLineMaxWidth(float lineMaxWidth) {
this.lineMaxWidth = lineMaxWidth;
}
public int getMaxLineCount() {
return maxLineCount;
}
public void setMaxLineCount(int maxLineCount) {
this.maxLineCount = maxLineCount;
}
public boolean isScrolling() {
return scrolling;
}
}
代碼還可以重構的更加簡潔,但是這邊主要是為了做demo演示,所以就滿看下實現的原理就好了