自定义View实战-仿京东首页轮播文字(又名垂直跑马灯)
京东客户端的轮播文字效果:
本次要实现的只是后面滚动的文字(前面的用ImageView或者TextView实现即可),看一下实现的效果:
经过更改后本组件已可以已开源库的形式添加到项目中使用(不用下载然后导入),使用方法及介绍详见Github.本文的实例Demo也在这里.ADTextView,欢迎star.
我还写了另外一个开源库,多达288种动画效果定制的侧滑菜单库有兴趣的点了看一下,欢迎star(不应该说欢迎,应该说求star,因为快毕业找工作了,多几个star简历上也好看一下,谢谢了)
关于开源库的详细介绍可以看这篇博客:多达288种动态效果的侧滑菜单开源库,满足您项目的各种需求
关于如何发布一个开源库的内容可以查看这篇博客:发布新手的第一个开源库-快速发布开源库到JitPack
好了,接着说垂直跑马灯的内容
实现思路:
上图只是一个大概的思路,要实现还需要完善更多的细节,下面会一步步的来实现这个效果:
1.封装数据源:从图上可以看到,轮播的文字是分为两个部分的,暂且把它们分别叫做前缀和内容,而且实际的使用过程中点击轮播图肯定是需要跳转页面的,而且大部分应该是WebView,不妨我们就设置点击时候需要获取的内容就是一个链接,那么数据源的结构就很明了了
创建ADEnity
类并设计参数完善一些基本的方法,代码如下
public class ADEnity {
private String mFront ; //前面的文字
private String mBack ; //后面的文字
private String mUrl ;//包含的链接
public ADEnity(String mFront, String mBack,String mUrl) {
this.mFront = mFront;
this.mBack = mBack;
this.mUrl = mUrl;
}
public String getmUrl() {
return mUrl;
}
public void setmUrl(String mUrl) {
this.mUrl = mUrl;
}
public String getmFront() {
return mFront;
}
public void setmFront(String mFront) {
this.mFront = mFront;
}
public String getmBack() {
return mBack;
}
public void setmBack(String mBack) {
this.mBack = mBack;
}
}
2.接下来应该是定制这个自定义View了,首先理一下思路,看一个构造图
实现这个自定义View的所有参数都在上表列出了,大部分参数很容易理解,个别参数加进去是很有必要的,比如说是否初始化进入文字的纵坐标,文字是否在移动中,是否处于停顿状态,这三个参数,之后的内容会详细的叙述一下.
在动手绘制之前还得需要知道一点基础的知识,就是关于绘制文字的方法,里面有很多细节需要处理
首先是画布绘制文字的方法:
返回值 |
方法 |
描述 |
void |
drawText(String text, float x, float y, Paint paint) |
Draw the text, with origin at (x,y), using the specified paint. |
void |
drawText(CharSequence text, int start, int end, float x, float y, Paint paint) |
Draw the specified range of text, specified by start/end, with its origin at (x,y), in the specified Paint. |
void |
drawText(char[] text, int index, int count, float x, float y, Paint paint) |
Draw the text, with origin at (x,y), using the specified paint. |
void |
drawText(String text, int start, int end, float x, float y, Paint paint) |
Draw the text, with origin at (x,y), using the specified paint. |
方法都比较好理解,绘制指定字符串(可以指定范围)在坐标( x , y )
处,但是其中的x,y
并不是我们所理解的应该是文字左上角的坐标点.其中的x坐标是根据Paint
的属性可变换的,默认的x是文字的左边坐标,如果Paint
设置了paint.setTextAlign(Paint.Align.CENTER)
;那就是字符的中心位置.Y
坐标是文字的baseline
的y
坐标.
关于绘制文字的baseline
:
用图来说话吧
图中蓝色的线即为baseline
,可以看出他既不是顶部坐标也不是底部坐标,那么当我们绘制文字的时候肯定是希望能把文字绘制在正中间.这时候就要引入paint.getTextBound()
方法了
getTextBounds(String text, int start, int end, Rect bounds)
,传入一个Rect
对象,调用此方法之后则会填充这个rect对象,而填充的内容就是所绘制的文字相对于baseline
的偏移坐标,将这个Rect加上baseline
的坐标,绘制后是这样的:
但其实他的值只是(2,-25,76,3)
,是相对于baseline的位置,画个图会比较好理解
那么要将文字绘制在中间,那么实际绘制baseline的坐标应该是组件的中心,加上文字中心(即图中框的中间坐标)相对于baseline的偏移值
这张图中应该会好理解实际绘制文字的坐标与组件中心坐标的关系.关于偏移值的计算,按常规的几何计算方法,应该是组件的中心坐标+偏移值的绝对值==baseline坐标(即实际绘制的坐标)
,但是由于框的坐标值都是相对于baseline
来计算的,top
为负值,botton
为正值,那么这个偏移值就可以直接用(top+bottom)/2的绝对值
来表示,没看懂的同学可以画个草图,用top=-25
,bottom=3
来算一下,看是否结果是一致的.
经过上面的理解,那我们来绘制正确绘制文字的方法也就确定了
已获得组件的高度int mHeight
, 文字外框Rect bound
的情况下
绘制文字在正中间
mHeight / 2 - (bound.top + bound.bottom) / 2
(因为bound.top + bound.bottom为负值,所以用减法)
//在纵坐标为mY的地方绘制文字
//计算方式
//mheight /2 = mY + (bound.top + bound.bottom) / 2 ;
文字滚动到最高点
mY == 0 - bound.bottom
//在纵坐标为mY的地方绘制,此时文字刚好移动到最高点
//计算方式
//mY + bound.bottom = 0 ;
文字滚动到最低点,刚好滚出组件
mY = mHeight - indexBound.top;
//在纵坐标为mY的地方绘制,此时文字刚好移动到最高点
//计算方式
//mY + bound.top = mHeight ;
知道了如何正确的绘制文字和边界情况的坐标判断,下面就到了绘制文字的步骤了
书写自定义View,定义需要用到的属性,完成构造方法
public class ADTextView extends View {
private int mSpeed; //文字出现或消失的速度 建议1~5
private int mInterval; //文字停留在中间的时长
private int mFrontColor; //前缀颜色
private int mContentColor; //内容的颜色
private int mFrontTextSize; //前缀文字大小
private int mContentTextSize; //内容文字大小
private List mTexts; //显示文字的数据源
private int mY = 0; //文字的Y坐标
private int mIndex = 0; //当前的数据下标
private Paint mPaintContent; //绘制内容的画笔
private Paint mPaintFront; //绘制前缀的画笔
private boolean isMove = true; //文字是否移动
private String TAG = "ADTextView";
private boolean hasInit = false;
private boolean isPaused = false;
public ADTextView(Context context) {
this(context, null);
}
public ADTextView(Context context, AttributeSet attrs) {
super(context, attrs);
//获取资源属性值
obtainStyledAttrs(attrs);
//初始化数据
init();
}
定义资源文件属性,values下建立attrs.xml文件
代码中获取资源文件内的属性值,并赋予默认值
//获取资源文件
private void obtainStyledAttrs(AttributeSet attrs) {
TypedArray array = getContext().obtainStyledAttributes(attrs, R.styleable.ADTextView);
mSpeed = array.getInt(R.styleable.ADTextView_ad_text_view_speed, 1);
mInterval = array.getInt(R.styleable.ADTextView_ad_text_view_interval, 2000);
mFrontColor = array.getColor(R.styleable.ADTextView_ad_text_front_color, Color.RED);
mContentColor = array.getColor(R.styleable.ADTextView_ad_text_content_color, Color.BLACK);
mFrontTextSize = (int) array.getDimension(R.styleable.ADTextView_ad_text_front_size, SizeUtil.Sp2Px(getContext(), 15));
mContentTextSize = (int) array.getDimension(R.styleable.ADTextView_ad_text_content_size, SizeUtil.Sp2Px(getContext(), 15));
array.recycle();
}
注:设置默认值时用到了尺寸的转换,详情见这篇博客:自定义View之尺寸的转化
初始化数据
//初始化默认值
private void init() {
mIndex = 0;
mPaintFront = new Paint();
mPaintFront.setAntiAlias(true);
mPaintFront.setDither(true);
mPaintFront.setTextSize(mFrontTextSize);
mPaintFront.setColor(mFrontColor);
mPaintContent = new Paint();
mPaintContent.setAntiAlias(true);
mPaintContent.setDither(true);
mPaintContent.setTextSize(mContentTextSize);
mPaintContent.setColor(mContentColor);
}
重写onMeasure进行宽高的测量(细节见注释)
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = measureWidth(widthMeasureSpec);
int height = measureHeight(heightMeasureSpec);
//设置宽高
setMeasuredDimension(width, height);
}
//测量宽度
private int measureHeight(int heightMeasureSpec) {
int result = 0;
int mode = MeasureSpec.getMode(heightMeasureSpec);
int size = MeasureSpec.getSize(heightMeasureSpec);
if (mode == MeasureSpec.EXACTLY) {
result = size; //具体的值
} else { //高度至少为两倍字高
int mfronTextHeight = (int) (mPaintFront.descent() - mPaintFront.ascent()); //前缀文字字高
int mContentTextHeight = (int) (mPaintContent.descent() - mPaintContent.ascent()); //内容文字字高
result = Math.max(mfronTextHeight, mContentTextHeight) * 2;
if (mode == MeasureSpec.AT_MOST) {
result = Math.min(result, size);
}
}
return result;
}
//测量高度
private int measureWidth(int widthMeasureSpec) {
int result = 0;
int mode = MeasureSpec.getMode(widthMeasureSpec);
int size = MeasureSpec.getSize(widthMeasureSpec);
if (mode == MeasureSpec.EXACTLY) {
result = size;
} else { //宽度最小十个字的宽度
String text = "十个字十个字十个字字";
Rect rect = new Rect();
mPaintContent.getTextBounds(text, 0, text.length(), rect);
result = rect.right - rect.left;
if (mode == MeasureSpec.AT_MOST) {
result = Math.min(result, size);
}
}
return result;
}
前面的叙述中我们知道,刚开始进入的时候文字应该是位于组件的底部的,但是这个值是需要获取组件的高度和当前显示文字的情况下来判断的,所以应该放在onDraw
内来初始化这个值,所以需要前面的是否初始化的属性,判断当mY==0
并且未初始化的时候给mY
赋值.
接下来就是onDraw
内的处理
获取当前的数据
ADEnity model = mTexts.get(mIndex);
String font = model.getmFront();
String back = model.getmBack();
为测量前缀与内容的宽度,获取文字的Rect对象
//前缀的Bound
Rect indexBound = new Rect();
mPaintFront.getTextBounds(font, 0, font.length(), indexBound);
//内容文字的Bound
Rect contentBound = new Rect();
mPaintContent.getTextBounds(back, 0, back.length(), contentBound);
if (mY == 0 && hasInit == false) {
mY = getMeasuredHeight() - indexBound.top;
hasInit = true;
}
对mY
进行初始化
if (mY == 0 && hasInit == false) {
mY = getMeasuredHeight() - indexBound.top;
hasInit = true;
}
绘制文字
canvas.drawText(back, 0, back.length(), (indexBound.right - indexBound.left) + 20, mY, mPaintContent);
canvas.drawText(font, 0, font.length(), 10, mY, mPaintFront);
对边界情况的处理
//移动到最上面
if (mY <= 0 - indexBound.bottom) {
mY = getMeasuredHeight() - indexBound.top; //返回底部
mIndex++; //换下一组数据
isPaused = false; //重置暂停状态
}
//移动到中间
if (!isPaused && mY <= getMeasuredHeight() / 2 - (indexBound.top + indexBound.bottom) / 2) {
isMove = false;
isPaused = true;
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
postInvalidate();
isMove = true;
}
}, mInterval);
}
mY -= mSpeed;
移动的处理与数据源的处理
mY -= mSpeed; //速度即为每次移动的像素值,推荐1~5,这也是前面判断中间与最上方的时候使用<=的原因,如果每次只移动1像素,使用==完全可以,其他值则有可能跳过==的这个条件,导致不会停顿或者不会循环
//循环使用数据
if (mIndex == mTexts.size()) {
mIndex = 0;
}
//如果是处于移动状态时的,则延迟绘制
//计算公式为一个比例,一个时间间隔移动组件高度,则多少毫秒来移动1像素
if (isMove) {
postInvalidateDelayed(mDuration / getMeasuredHeight());
}
至此对逻辑的处理就完成了,接下来要设置点击事件
//设置一个回调并设置setXXX方法
public interface onClickLitener {
public void onClick(String mUrl);
}
private onClickLitener onClickLitener;
public void setOnClickLitener(TextViewAd.onClickLitener onClickLitener) {
this.onClickLitener = onClickLitener;
}
//重写onTouchEvent事件,并且要返回true,表明当前的点击事件由这个组件自身来处理
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (onClickLitener != null) {
//调用回调,将当前数据源的链接传出去 onClickLitener.onClick(mTexts.get(mIndex).getmUrl());
}
break;
}
return true;
}
暴露一些其他属性的设置方式
//设置数据源
public void setmTexts(List mTexts) {
this.mTexts = mTexts;
}
//设置广告文字的停顿时间
public void setInterval(int mInterval) {
this.mInterval = mInterval;
}
//设置速度
public void setSpeed(int spedd) {
this.mSpeed = spedd;
}
//设置前缀的文字颜色
public void setFrontColor(int mFrontColor) {
mPaintFront.setColor(mFrontColor);
}
//设置正文内容的颜色
public void setBackColor(int mBackColor) {
mPaintContent.setColor(mBackColor);
}
有兴趣的同学可以将这些属性设置到attrs.xml
文件中然后就可以在布局文件中设置属性了,这里就不演示了,因为觉得每次copy
这个View
还得把xml
文件也copy
比较麻烦,毕竟as有自动补全,可以很方便的看到暴露在外面的方法.(个人感受而已).
贴一下完整的ADTextView的代码,方便查看
package com.brioal.brioallib.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.Rect;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import com.brioal.brioallib.R;
import com.brioal.brioallib.entity.AdEntity;
import com.brioal.baselib.util.klog.KLog;
import com.brioal.baselib.util.SizeUtil;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
/**
* 仿京东垂直滚动广告栏
* Created by Brioal on 2016/7/22.
*/
public class ADTextView extends View {
private int mSpeed; //文字出现或消失的速度 建议1~5
private int mInterval; //文字停留在中间的时长
private int mFrontColor; //前缀颜色
private int mContentColor; //内容的颜色
private int mFrontTextSize; //前缀文字大小
private int mContentTextSize; //内容文字大小
private List mTexts; //显示文字的数据源
private int mY = 0; //文字的Y坐标
private int mIndex = 0; //当前的数据下标
private Paint mPaintContent; //绘制内容的画笔
private Paint mPaintFront; //绘制前缀的画笔
private boolean isMove = true; //文字是否移动
private String TAG = "ADTextView";
private boolean hasInit = false;
private boolean isPaused = false;
public interface onClickListener {
public void onClick(String mUrl);
}
private onClickListener onClickListener;
public void setOnClickListener(onClickListener onClickListener) {
this.onClickListener = onClickListener;
}
public ADTextView(Context context) {
this(context, null);
}
public ADTextView(Context context, AttributeSet attrs) {
super(context, attrs);
obtainStyledAttrs(attrs);
init();
}
//获取资源文件
private void obtainStyledAttrs(AttributeSet attrs) {
TypedArray array = getContext().obtainStyledAttributes(attrs, R.styleable.ADTextView);
mSpeed = array.getInt(R.styleable.ADTextView_ad_text_view_speed, 1);
mInterval = array.getInt(R.styleable.ADTextView_ad_text_view_interval, 2000);
mFrontColor = array.getColor(R.styleable.ADTextView_ad_text_front_color, Color.RED);
mContentColor = array.getColor(R.styleable.ADTextView_ad_text_content_color, Color.BLACK);
mFrontTextSize = (int) array.getDimension(R.styleable.ADTextView_ad_text_front_size, SizeUtil.Sp2Px(getContext(), 15));
mContentTextSize = (int) array.getDimension(R.styleable.ADTextView_ad_text_content_size, SizeUtil.Sp2Px(getContext(), 15));
array.recycle();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (onClickListener != null) {
onClickListener.onClick(mTexts.get(mIndex).getmUrl());
}
break;
}
return true;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = measureWidth(widthMeasureSpec);
int height = measureHeight(heightMeasureSpec);
setMeasuredDimension(width, height);
}
//测量宽度
private int measureHeight(int heightMeasureSpec) {
int result = 0;
int mode = MeasureSpec.getMode(heightMeasureSpec);
int size = MeasureSpec.getSize(heightMeasureSpec);
if (mode == MeasureSpec.EXACTLY) {
result = size;
} else { //高度至少为两倍字高
int mfronTextHeight = (int) (mPaintFront.descent() - mPaintFront.ascent()); //前缀文字字高
int mContentTextHeight = (int) (mPaintContent.descent() - mPaintContent.ascent()); //内容文字字高
result = Math.max(mfronTextHeight, mContentTextHeight) * 2;
if (mode == MeasureSpec.AT_MOST) {
result = Math.min(result, size);
}
}
return result;
}
//测量高度
private int measureWidth(int widthMeasureSpec) {
int result = 0;
int mode = MeasureSpec.getMode(widthMeasureSpec);
int size = MeasureSpec.getSize(widthMeasureSpec);
if (mode == MeasureSpec.EXACTLY) {
result = size;
} else { //宽度最小十个字的宽度
String text = "十个字十个字十个字字";
Rect rect = new Rect();
mPaintContent.getTextBounds(text, 0, text.length(), rect);
result = rect.right - rect.left;
if (mode == MeasureSpec.AT_MOST) {
result = Math.min(result, size);
}
}
return result;
}
//设置数据源
public void setmTexts(List mTexts) {
this.mTexts = mTexts;
}
//设置广告文字的停顿时间
public void setInterval(int mInterval) {
this.mInterval = mInterval;
}
//设置速度
public void setSpeed(int spedd) {
this.mSpeed = spedd;
}
//设置前缀的文字颜色
public void setFrontColor(int mFrontColor) {
mPaintFront.setColor(mFrontColor);
}
//设置正文内容的颜色
public void setBackColor(int mBackColor) {
mPaintContent.setColor(mBackColor);
}
//初始化默认值
private void init() {
mIndex = 0;
mPaintFront = new Paint();
mPaintFront.setAntiAlias(true);
mPaintFront.setDither(true);
mPaintFront.setTextSize(mFrontTextSize);
mPaintFront.setColor(mFrontColor);
mPaintContent = new Paint();
mPaintContent.setAntiAlias(true);
mPaintContent.setDither(true);
mPaintContent.setTextSize(mContentTextSize);
mPaintContent.setColor(mContentColor);
}
@Override
protected void onDraw(Canvas canvas) {
if (mTexts != null) {
AdEntity model = mTexts.get(mIndex);
String font = model.getmFront();
String back = model.getmBack();
//绘制前缀
Rect indexBound = new Rect();
mPaintFront.getTextBounds(font, 0, font.length(), indexBound);
//绘制内容文字
Rect contentBound = new Rect();
mPaintContent.getTextBounds(back, 0, back.length(), contentBound);
if (mY == 0 && hasInit == false) {
mY = getMeasuredHeight() - indexBound.top;
hasInit = true;
}
//移动到最上面
if (mY <= 0 - indexBound.bottom) {
KLog.i(TAG, "onDraw: " + getMeasuredHeight());
mY = getMeasuredHeight() - indexBound.top;
mIndex++;
isPaused = false;
}
canvas.drawText(back, 0, back.length(), (indexBound.right - indexBound.left) + 20, mY, mPaintContent);
canvas.drawText(font, 0, font.length(), 10, mY, mPaintFront);
//移动到中间
if (!isPaused && mY <= getMeasuredHeight() / 2 - (indexBound.top + indexBound.bottom) / 2) {
isMove = false;
isPaused = true;
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
postInvalidate();
isMove = true;
}
}, mInterval);
}
mY -= mSpeed;
//循环使用数据
if (mIndex == mTexts.size()) {
mIndex = 0;
}
//如果是处于移动状态时的,则延迟绘制
//计算公式为一个比例,一个时间间隔移动组件高度,则多少毫秒来移动1像素
if (isMove) {
postInvalidateDelayed(2);
}
}
}
}
至此这个自定义View
就完成了,有不足的地方欢迎指出,另外建了个新手交流Android
开发的QQ
群,欢迎加入.
群号:375276053