需求
- 当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的杰哥基准线:
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 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 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演示,所以就满看下实现的原理就好了