因为前段时间在做广告机,正好需要用到字幕滚动,期间也踩了一些坑,所以这边就讲一下安卓字幕滚动的几种实现方案。(这边只讲水平滚动,不讲垂直滚动,因为原理是类似的)
1.最简单的实现方案
首先,我们可以直接使用系统的 TextView
控件。通过查询系统源码,我们可以发现,在TextView
控件里面有个Marquee.class
内部类,而这个类又是控制 TextView
文本滚动的。所以我们可以这样添加一个TextView
控件
或
TextView textView = new TextView(context);
textView.setSingleLine();
textView.setMarqueeRepeatLimit(-1);//循环次数,-1无限循环
同时,我们根据源码要求,必须满足 isFocused() || isSelected()
,所以当需要循环的时候,可以调用 textView.setSelected(true);
2.稍微复杂的方案
如果需要对文本滚动速度进行调节的,那么使用TextView
的限制就比较大了,当然你也可以通过使用hook或者修改源码等一系列方案来修改TextView
,不过那太麻烦了,除非你愿意。所以,我们可以考虑通过定制一个View
,然后设定画笔画布,绘制指定的文本,再做个定时器,让文本滚动起来。
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.FontMetrics;
import android.graphics.PixelFormat;
import android.graphics.PorterDuff.Mode;
import android.support.annotation.ColorInt;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.view.ViewGroup;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScrollTextView1 extends SurfaceView implements SurfaceHolder.Callback {
private final String TAG = "ScrollTextView";
// surface Handle onto a raw buffer that is being managed by the screen compositor.
private SurfaceHolder surfaceHolder; //providing access and control over this SurfaceView's underlying surface.
private Paint paint = null;
private boolean stopScroll = false; // stop scroll
private boolean pauseScroll = false; // pause scroll
private int speed = 4; // scroll-speed
private int textColor = Color.BLACK;
private String text = ""; // scroll text
private float textSize = 20f; // default text size
private int viewWidth = 0;
private int viewHeight = 0;
private float textWidth = 0f;
private float textX = 0f;
private float textY = 0f;
private float viewWidth_plus_textLength = 0.0f;
private ScheduledExecutorService scheduledExecutorService;
boolean isSetNewText = false;
/**
* constructs 1
*
* @param context you should know
*/
public ScrollTextView1(Context context) {
super(context);
}
/**
* constructs 2
*
* @param context CONTEXT
* @param attrs ATTRS
*/
public ScrollTextView1(Context context, AttributeSet attrs) {
super(context, attrs);
surfaceHolder = this.getHolder(); //get The surface holder
surfaceHolder.addCallback(this);
paint = new Paint();
paint.setColor(textColor);
paint.setTextSize(textSize);
setZOrderOnTop(true); //Control whether the surface view's surface is placed on top of its window.
getHolder().setFormat(PixelFormat.TRANSLUCENT);
setFocusable(true);
}
/**
* measure text height width
*
* @param widthMeasureSpec widthMeasureSpec
* @param heightMeasureSpec heightMeasureSpec
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int mHeight = getFontHeight(textSize); //实际的视图高
viewWidth = MeasureSpec.getSize(widthMeasureSpec);
viewHeight = MeasureSpec.getSize(heightMeasureSpec);
// when layout width or height is wrap_content ,should init ScrollTextView Width/Height
if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT && getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(viewWidth, mHeight);
viewHeight = mHeight;
} else if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(viewWidth, viewHeight);
} else if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(viewWidth, mHeight);
viewHeight = mHeight;
}
}
/**
* surfaceChanged
*
* @param arg0 arg0
* @param arg1 arg1
* @param arg2 arg1
* @param arg3 arg1
*/
@Override
public void surfaceChanged(SurfaceHolder arg0, int arg1, int arg2, int arg3) {
Log.d(TAG, "arg0:" + arg0.toString() + " arg1:" + arg1 + " arg2:" + arg2 + " arg3:" + arg3);
}
/**
* surfaceCreated,init a new scroll thread.
* lockCanvas
* Draw something
* unlockCanvasAndPost
*
* @param holder holder
*/
@Override
public void surfaceCreated(SurfaceHolder holder) {
stopScroll = false;
scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
scheduledExecutorService.scheduleAtFixedRate(new ScrollTextThread(), 100, 100, TimeUnit.MILLISECONDS);
Log.d(TAG, "ScrollTextTextView is created");
}
/**
* surfaceDestroyed
*
* @param arg0 SurfaceHolder
*/
@Override
public void surfaceDestroyed(SurfaceHolder arg0) {
stopScroll = true;
scheduledExecutorService.shutdownNow();
Log.d(TAG, "ScrollTextTextView is destroyed");
}
/**
* text height
*
* @param fontSize fontSize
* @return fontSize`s height
*/
private int getFontHeight(float fontSize) {
// Paint paint = new Paint();
paint.setTextSize(fontSize);
FontMetrics fm = paint.getFontMetrics();
return (int) Math.ceil(fm.descent - fm.ascent);
}
/**
* set scroll text
*
* @param newText scroll text
*/
public void setText(String newText) {
isSetNewText = true;
stopScroll = false;
this.text = newText;
measureVarious();
}
/**
* Draw text
*
* @param X X
* @param Y Y
*/
private synchronized void draw(float X, float Y) {
Canvas canvas = surfaceHolder.lockCanvas();
canvas.drawColor(Color.TRANSPARENT, Mode.CLEAR);
canvas.drawText(text, X, Y, paint);
surfaceHolder.unlockCanvasAndPost(canvas);
}
/**
* measure text
*/
private void measureVarious() {
textWidth = paint.measureText(text);
viewWidth_plus_textLength = viewWidth + textWidth;
textX = viewWidth - viewWidth / 5;
//baseline measure !
FontMetrics fontMetrics = paint.getFontMetrics();
float distance = (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom;
textY = viewHeight / 2 + distance;
}
/**
* Scroll thread
*/
class ScrollTextThread implements Runnable {
@Override
public void run() {
measureVarious();
while (!stopScroll) {
if (pauseScroll) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Log.e(TAG, e.toString());
}
continue;
}
draw(viewWidth - textX, textY);
textX += speed;
if (textX > viewWidth_plus_textLength) {
textX = 0;
}
}
}
}
}
参考demo 大佬链接
3.有点麻烦的方案
通过以上方案,大概可以解决百分之85的水平字幕滚动需求了。当然,还有各种特殊情况,比如部分主板绘制超过千字的文本,会出现左起点文字重叠的现象,
通过多次试验,发现这些主板对应的系统必须保证 Canves.drawText()
函数每次绘制的文字不能超过指定的字数(大部分是千字)。那么,针对这一原则,为了保证每次绘制的文字不能超过千字,所以需要对文字进行裁切。那么这里就出现了一个问题。我们只要一个字幕滚动,切成多份的文字之后,要保证每段文字可以上下衔接同步滚动,就必须对文字的宽高进行计算。
这里扩展延伸一下安卓字符宽高的计算方案:
首先,我们需要知道每个字符的计算规则如下图所示
于是,我们就有了以下计算文字宽度的方案:
TextPaint paint = new TextPaint();
paint.setTextAlign(Paint.Align.LEFT);
paint.setStyle(Paint.Style.FILL);
paint.setTextSize(textSize);
String str = "test msg ... 1k";
// 第一种 较精确
float len1 = Layout.getDesiredWidth(str, 0, str.length(), paint);
// 第二种 取近似值,较大
float len2 = 0;
float widths[] = new float[str.length()];
mPaint.getTextWidths(str, widths);
for (int i = 0; i < len; i++) {
len2 += Math.ceil(widths[i]);
}
// 第三种 较精确,在某种情况下与第一种相同
Rect rect = new Rect();
paint.getTextBounds(str, 0, str.length(), rect);
float len3 = rect.width();
// 第四种 取近似值
float len4 = paint.measureText(cacheStr);
//第五种 更精确,更小
Path textPath = new Path();
RectF boundsPath = new RectF();
paint.getTextPath(str, 0, str.length(), 0.0f, 0.0f, textPath);
textPath.computeBounds(boundsPath, true);
float len5 = boundsPath.width();
同时,经过多种主板测试,我们发现测量文字宽高必须是屏幕可容纳字数之内才是较为精确的。比如屏幕最多容纳100个字的情况下,测量100个字符的宽高是准确的,但是测量超过这个数量之后,可能出现长度偏大或偏小的情况。
考虑到我们把字符串分成n段,而每一段的长度可能不一样,假设每段都接近一个屏幕宽,然后给每段设定一个x坐标,那么最少需要三段字幕。当滚动第一段完毕的时候,第二段完整展示在界面上面,第三段可能显示出来(即第一段的x为负的第一段宽度,第二段的x为0,第三段的x为第二段的宽)。于是,我们就可以构造这样的控件。
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.FontMetrics;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.support.annotation.NonNull;
import android.text.Html;
import android.text.Layout;
import android.text.TextPaint;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.widget.LinearLayout;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.Locale;
public class MarqueeTextView extends SurfaceView implements SurfaceHolder.Callback {
private static final String TAG = "打印_MarqueeTextView";
public static final int MAX_SPEED = 50;
public static final int MIN_SPEED = 0;
private Paint paint = null;// 画笔
private float textSize = 15f; // 字号
private int textColor = Color.BLACK; // 文字颜色
private int bgColor = Color.GRAY; // 背景颜色
private int orizontal = LinearLayout.HORIZONTAL; // HORIZONTAL 水平滚动|VERTICAL 垂直滚动
private float speed = 4; // 滚动速度
private SurfaceHolder holder;
// 按每屏长的文字,缓存到列表
private final LinkedList txtCacheList = new LinkedList<>();
private String oldStr = "";//缓存文字,作为比对使用
private int mTextDistance = 80;//item间距,dp单位
private Thread mScheduledThread; //滚动线程
private float mLoc_X_1 = 0;//第一屏的x坐标
private float mLoc_Y_1 = 0;//第一屏的y坐标
private float offsetDis = 0;//偏移量,以速度为基准
private int mIndex_1 = 0;//第一屏的文字角标
private int mIndex_2 = 1;//第二屏的文字角标
private int mIndex_3 = 2;//第三屏的文字角标
private boolean isRolling = false;//是否在滚动
private boolean isInterrupted = true;//是否停止线程循环
private float totalLength = 0.0f;// 显示总长度
private int totalTimes = -1; // 滚动次数
private int doneCount = 0;//准备执行滚动的次数
private float simpleHeight; //单文字高度
public MarqueeTextView(Context context) {
this(context, null);
}
public MarqueeTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MarqueeTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(attrs);
}
private void init(AttributeSet attrs) {
paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(textColor);
paint.setTextSize(textSize);
setSpeed(speed);
setText(null);
}
// 获取字体宽
private float getFontWith(String txt) {
return paint.measureText(txt);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (holder == null) {
holder = getHolder();
holder.removeCallback(this);
holder.addCallback(this);
}
}
@Override
protected void onVisibilityChanged(@NonNull View changedView, int visibility) {
super.onVisibilityChanged(changedView, visibility);
if (visibility != View.VISIBLE) {
stopRolling();
} else {
startRolling();
}
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
stopRolling();
}
private Rect rect;
private float getContentWidth(String black) {
if (black == null || black.length() == 0) {
return 0;
}
if (rect == null) {
rect = new Rect();
}
paint.getTextBounds(black, 0, black.length(), rect);
return rect.width();
}
private float getBlackWidth() {
String text1 = "en en";
String text2 = "enen";
return getContentWidth(text1) - getContentWidth(text2);
}
/**
* 重置参数
*/
private void reset() {
if (txtCacheList.size() <= 0) return;
mIndex_1 = 0;//第一屏的文字角标
simpleHeight = FormatTextTask.getFontHeight(textSize);
//fixme 这边先不考虑内边距
// 水平滚动
totalLength = getWidth();
//定高
mLoc_Y_1 = getHeight() / 2 + simpleHeight / 3;
paint.setTextAlign(Paint.Align.LEFT);
mLoc_X_1 = getWidth() / 2;//第一屏的坐标
//不少于两屏
mIndex_2 = txtCacheList.size() > 1 ? 1 : 0;//第二屏的文字角标
mIndex_3 = mIndex_2 + 1 < txtCacheList.size() ? mIndex_2 + 1 : 0;//第三屏的文字角标
}
/// 绘制文字
public void onDrawUI() {
if (txtCacheList.size() > 0 && holder != null) {
Canvas canvas = holder.lockCanvas();
canvas.drawColor(bgColor);
//水平滚动,往左
int size = txtCacheList.size();
if (txtCacheList.size() > 0) {
mLoc_X_1 = mLoc_X_1 - offsetDis;
// 类似传送带方式的移动
MarqueeBean bean1 = txtCacheList.get(mIndex_1 % size);
String str1 = bean1.getMsg();
MarqueeBean bean2 = txtCacheList.get(mIndex_2 % size);
String str2 = bean2.getMsg();
MarqueeBean bean3 = txtCacheList.get(mIndex_3 % size);
String str3 = bean3.getMsg();
float mX_2 = bean1.getLen() + mLoc_X_1;
float mX_3 = bean2.getLen() + mX_2;
canvas.drawText(str1, mLoc_X_1, mLoc_Y_1, paint);
canvas.drawText(str2, mX_2, mLoc_Y_1, paint);
canvas.drawText(str3, mX_3, mLoc_Y_1, paint);
if (mX_2 < 0) {
// 变化游标
mIndex_1 = mIndex_2;
mIndex_2 = mIndex_3;
mIndex_3 = (mIndex_2 + 1) % txtCacheList.size();
// 变化坐标
mLoc_X_1 = mX_2;
}
}
holder.unlockCanvasAndPost(canvas);
}
}
/**
* 滚动任务
*/
private Runnable mScheduledRun = new Runnable() {
@Override
public void run() {
while (!isInterrupted) {
synchronized (txtCacheList) {
if (txtCacheList.size() <= 0 || speed <= 0 || getVisibility() != View.VISIBLE) {
//小于一屏或者滚动速度为0,那么中断滚动
stopRolling();
break;
}
}
if (!isRolling) {
isRolling = true;
}
try {
Thread.sleep(40);
onDrawUI();//每隔40毫秒重绘视图
} catch (Throwable e) {
e.printStackTrace();
}
}
isRolling = false;
}
};
/**
* 意图事件处理
*/
private Handler mEventHandler = new Handler(new Handler.Callback() {
@Override
public boolean handleMessage(Message msg) {
if (msg.what == 0) {
Bundle data = msg.getData();
if (data != null) {
ArrayList list = data.getParcelableArrayList("data");
LogUtils.v(TAG, "收到数据 == " + (list == null ? null : list.size()));
if (list != null) {
txtCacheList.addAll(list);
reset();
startRolling();
}
}
} else if (msg.what == 1) {
if (holder != null) {
//初始化背景色
Canvas canvas = holder.lockCanvas();
if (canvas != null) {
canvas.drawColor(bgColor);
}
holder.unlockCanvasAndPost(canvas);
}
reset();
if (txtCacheList.size() == 0 && !TextUtils.isEmpty(oldStr)) {
//先停止滚动,然后才能设置文字
if (totalLength <= 0) {
totalLength = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
}
LogUtils.v(TAG, "onlayout 总长 = " + totalLength + " 字 = " + oldStr.length());
new FormatTextTask(mEventHandler, totalLength, textSize).execute(oldStr);
}
}
return false;
}
});
/**
* 格式化文字任务
*/
private static class FormatTextTask extends AsyncTask> {
//控件对应一屏的长度,如果是水平滚动,那么就是一屏宽度,如果是垂直滚动,就是一屏高度,必须有确切的宽或高
private float contentLength;
private float textSize;//字体大小
private Handler mEventHandler;
public FormatTextTask(Handler mEventHandler, float contentLength, float textSize) {
this.mEventHandler = mEventHandler;
this.contentLength = contentLength;
this.textSize = textSize;
}
@Override
protected ArrayList doInBackground(String... strings) {
if (strings == null || strings.length <= 0) {
return null;
}
LogUtils.v(TAG, "滚动方向的长度 = " + contentLength);
if (contentLength <= 0) {
//必须有确切的宽或高
return null;
}
String str = strings[0]; // 需要格式的文字
if (str == null || str.length() == 0) {
return null;
}
String formatStr;
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
formatStr = Html.fromHtml(str, Html.FROM_HTML_MODE_LEGACY).toString();
} else {
formatStr = Html.fromHtml(str).toString();
}
} catch (Throwable e) {
LogUtils.log(TAG, "字符无法转编码", LogUtils.LogType.FLAG_LOG_V, e);
formatStr = str;
}
ArrayList list = new ArrayList<>();
Rect rect = new Rect();
TextPaint paint = new TextPaint();
paint.setTextAlign(Paint.Align.LEFT);
paint.setStyle(Paint.Style.FILL);
paint.setTextSize(textSize);
int start = 0, len = 20;
int index = 0;
do {
int end = (start + len);
if (end > formatStr.length()) {
end = formatStr.length();
}
String cacheStr = formatStr.substring(start, end);
float len1 = Layout.getDesiredWidth(cacheStr, 0, cacheStr.length(), paint);
MarqueeBean bean = new MarqueeBean(cacheStr, len1);
list.add(bean);
start = end;
index++;
} while (start < formatStr.length());
LogUtils.w(TAG, "拆分的字符 =======================>> " + list.size());
return list;
}
@Override
protected void onPostExecute(ArrayList list) {
if (mEventHandler != null) {
Message message = mEventHandler.obtainMessage(0);
Bundle bundle = new Bundle();
bundle.putParcelableArrayList("data", list);
message.setData(bundle);
mEventHandler.sendMessage(message);
}
}
// 获取字体高度
private static float getFontHeight(float fontSize) {
Paint paint = new Paint();
paint.setTextAlign(Paint.Align.CENTER);
paint.setTextSize(fontSize);
FontMetrics fm = paint.getFontMetrics();
return (float) Math.ceil(fm.descent - fm.ascent);
}
}
private static int dp2px(Resources res, float dpValue) {
if (res == null) return -1;
final float scale = res.getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
///////////////////////////////////// out public method //////////////////////////////////////
private Runnable textRun;
/**
* 设置文字
*
* @param s 文字
*/
public void setText(final String s) {
//先停止滚动,然后才能设置文字
stopRolling();
txtCacheList.clear();
if (textRun != null) {
removeCallbacks(textRun);
}
if (TextUtils.isEmpty(s)) {
oldStr = null;
return;
}
post(textRun = new Runnable() {
@Override
public void run() {
int count = Math.round(getWidth() / paint.measureText("H")) * 3;
LogUtils.w("当前 = " + s.length() + " 总共不能小于 = " + count);
if (s != null && s.length() > 0 && s.length() < count) {
oldStr = "";
int num = count / s.length();
for (int index = 0; index < num; index++) {
oldStr += s + " ● ";
}
oldStr += s;
} else {
oldStr = s;
}
mEventHandler.removeMessages(1);
mEventHandler.sendEmptyMessageDelayed(1, 500);
}
});
}
/**
* 设置字体大小
*
* @param textSize 文字大小
*/
public void setTextSize(float textSize) {
this.textSize = textSize;
paint.setTextSize(textSize);
}
/**
* 设置文字颜色
*
* @param textColor
*/
public void setTextColor(int textColor) {
this.textColor = textColor;
paint.setColor(textColor);
}
/**
* 设置背景颜色
*
* @param bgColor 背景颜色
*/
@Override
public void setBackgroundColor(int color) {
// super.setBackgroundColor(color);
this.bgColor = color;
}
/**
* 设置滚动速度
*
* @param speed
*/
public void setSpeed(float speed) {
if (speed > MAX_SPEED || speed < MIN_SPEED) {
throw new IllegalArgumentException(
String.format(Locale.getDefault(),
"Speed was invalid integer, it must between %d and %d", MIN_SPEED, MAX_SPEED));
} else {
this.speed = speed;
offsetDis = speed * 2;
}
}
/**
* 开始滚动
*/
private void startRolling() {
try {
if (txtCacheList.size() < 1) {
//如果文字不够一屏,不移动
return;
}
if (getVisibility() != View.VISIBLE) {
//如果不显示,就不滚动
return;
}
LogUtils.v(TAG, "startRolling -------- ");
if (mScheduledThread == null) {
mScheduledThread = new Thread(mScheduledRun, "schedule");
isInterrupted = false;
mScheduledThread.start();
}
} catch (Throwable e) {
LogUtils.log(TAG, "start rolling error", LogUtils.LogType.FLAG_LOG_E, e);
}
}
/**
* 停止滚动
*/
public void stopRolling() {
try {
LogUtils.v(TAG, "stopRolling -------- ");
if (mScheduledThread != null) {
isInterrupted = true;
mScheduledThread.interrupt();
mScheduledThread = null;
}
} catch (Throwable e) {
LogUtils.log(TAG, "stop rolling error", LogUtils.LogType.FLAG_LOG_E, e);
}
}
/**
* 销毁滚动
*/
public void destroyRolling() {
Log.v(TAG, "destroyRolling -------- ");
try {
startRolling();
txtCacheList.clear();
} catch (Throwable e) {
LogUtils.log(TAG, "destroy rolling error", LogUtils.LogType.FLAG_LOG_E, e);
}
}
@Override
protected void onDetachedFromWindow() {
if (holder != null) {
holder.removeCallback(this);
}
super.onDetachedFromWindow();
}
public static class MarqueeBean implements Parcelable {
private String msg;
private float len;
public MarqueeBean(String msg, float len) {
this.msg = msg;
this.len = len;
}
public String getMsg() {
return msg;
}
public float getLen() {
return len;
}
}
}
这里需要注意的是,修改了文字大小,必须重新设置字符串进行文字长度计算。
全部示例都在这边了,因为暂时没时间维护开源库,所以就不上传代码了,有需要的话再联系我。