由来:我们都知道我们做软件的时候,有些应用是有广告的轮番图的,而我们实现这个功能的时候大多数是采用:ViewPager + Fragment来实现的,而且指示器可能是采用RadioButton实现的,本人以前也是如此,但是有好几次碰到要写这个功能,嫌弃以前的麻烦,所以今天分享一下我自己自定义的广告轮番图的控件!
下一篇进行性能的优化和细节的优化
本篇博客效果图:
1.分析控件
控件有多个图片水平排列
可以想到自定义控件应该是继承ViewGroup,这样子只需要安排孩子的位置即可
控件可以用手指左右滑动
可以想到我们需要处理用户的触摸事件,从而来实现我们图片滚动,原理就是滚动画布
控件可以通过方法选择哪一张图片被选中
其实就是滚动画布到指定的位置
控件自带指示器,作用是告知用户当前是第几张图片
直接绘制n个小圆点到界面上,达到指示器的作用
控件可以自动的图片滚动起来,如果是最后一张,下一张就是第一张
控件的使用尽可能简单
2.代码的初步实现
2.1首先毋庸置疑的就是创建一个类,继承ViewGroup
2.2重写几个构造方法和实现onLayout()方法,和进行一些初始化的操作
/**
* 控件自身的宽度
*/
private int mWidth;
/**
* 控件自身的高度
*/
private int mHeight;
/**
* 安排孩子的位置,每一个孩子都是父容器的大小,手指滑动可以显示出下一个孩子
*
* @param changed
* @param l
* @param t
* @param r
* @param b
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
mWidth = getWidth();
mHeight = getHeight();
//获取到孩子的个数
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View v = getChildAt(i);
v.layout(mWidth * i, 0, mWidth * (i + 1), mHeight);
}
}
onLayout里面的实现这里做一个解释:其实就是拿到ViewGroup里面的所有孩子,然后从左到右的排列好,也就是安排孩子们的位置,而孩子是谁呢?这里采用了ImageView作为孩子,因为显示图片我们平常用的就是ImageView控件
2.3我们想先看到效果,所以这里先写好关于用户使用的方法
/**
* 填充父容器的布局对象
*/
private ViewGroup.LayoutParams =new ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
/**
* 图片的个数
*/
private int imageCount = 0;
/**
* 添加图片地址,让轮番图多一张图滚动
*
* @param imageUrl
* @param defalutImageId 图片加载完成前显示的图片的资源id
*/
public void addImageUrl(String imageUrl, int defalutImageId) {
ImageView iv = new ImageView(context);
iv.setLayoutParams(lp);
iv.setImageResource(defalutImageId);
this.addView(iv);
requestLayout();
imageCount++;
ImageLoad.getInstance().asyncLoadImage(iv, imageUrl);
}
参数defalutImageId是图片默认显示的图片的资源地址,如果图片加载失败也显示这个图片
这里的代码也很简单,就是用户通过方法addImageUrl添加图片的网址,然后动态创建
ImageView控件添加到自身的ViewGroup中,ImageView控件是填充父容器的,通过
setLayoutParams方法设置,然后重新让容器布局,图片是异步加载的
这里我使用了自己封装的ImageLoad,亲们大可以更换成你们常用的图片加载器
比如官方的glide
2.4在布局文件中使用
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFFF00"
tools:context="com.example.cxj.adv.MainActivity">
<com.example.cxj.adv.view.AdvView
android:background="#FFFFFF"
android:id="@+id/adv"
android:layout_width="match_parent"
android:layout_height="200dp">
</com.example.cxj.adv.view.AdvView>
</RelativeLayout>
2.5在activity中对控件添加几个图片的地址
private AdvView adv = null;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
adv = (AdvView) findViewById(R.id.adv);
adv.addImageUrl("http://pic4.zhimg.com//4a6c792c5074742bd62269858b187757.jpg",R.mipmap.ic_launcher);
adv.addImageUrl("http://pic2.zhimg.com//6c40c40c443dd2fe672880f1c42936d5.jpg",R.mipmap.ic_launcher);
adv.addImageUrl("http://pic4.zhimg.com//e90b17d4c6b77c5c102965dc5711c1b7.jpg",R.mipmap.ic_launcher);
adv.addImageUrl("http://pic2.zhimg.com//c59d95e6e3a4946f17e7f11d311a22d9.jpg",R.mipmap.ic_launcher);
adv.addImageUrl("http://pic2.zhimg.com//7bcc9b1178f7959183e70f79bddf3b31.jpg",R.mipmap.ic_launcher);
}
2.6初步的效果图
可以看到第一张图片的地址上的图片已经显示在界面上了,但是我们在activity的oncreate方法中添加了很多的图片,这些图片在哪里呢?
这里简单的画了一下原理图,也就是说除了第一张图片,后面的图片其实就是在屏幕的右边,
看不见而已,所以这里要看到他们,我们可以让画布往右滚动,也就是图片往左移动到屏幕上来了
2.7重写onTouchEvent方法
在手指按下的时候记录当前的坐标,手指滑动的时候让画布跟着手指移动
private int currentX;
private int currentY;
private int finalX;
private int finalY;
/**
* 手指是否移动
*/
private boolean isMove = false;
/**
* 是否是我的事件
*/
private boolean isMyEvent = false;
/**
* 是否已经判断是谁的事件
*/
private boolean isJudge = false;
/**
* 手指是否触摸了屏幕
*/
private boolean isTouchScreen = false;
@Override
public boolean onTouchEvent(MotionEvent e) {
vt.addMovement(e);
//获取是什么状态,按下还是移动还是抬起
int action = e.getAction();
if (action == MotionEvent.ACTION_DOWN) { //按下
count = 0;
isJudge = false;
isMove = false;
isTouchScreen = true;
velocityX = 0;
currentX = (int) e.getX();
currentY = (int) e.getY();
return true;
} else if (action == MotionEvent.ACTION_MOVE) { //移动
count = 0;
finalX = (int) e.getX();
finalY = (int) e.getY();
int dx = finalX - currentX;
int dy = finalY - currentY;
if (!isJudge) {
if (dx == 0 && dy == 0) {
return super.onTouchEvent(e);
} else {
if (Math.abs(dy) > Math.abs(dx)) {
isMyEvent = false;
return false;
} else {
isMyEvent = true;
requestDisallowInterceptTouchEvent(true);
}
}
}
//已经判断过了,这次的事件该是谁的
isJudge = true;
if (isMyEvent) {
scrollBy(-dx, 0);
currentX = (int) e.getX();
currentY = (int) e.getY();
if (onMoveStateListener != null) {
onMoveStateListener.onMoveState(MOVING);
}
isMove = true;
return true;
} else {
return super.onTouchEvent(e);
}
} else if (action == MotionEvent.ACTION_UP) { //抬起
if (!isMove) { //如果没有移动
View v = getChildAt(currIndex);
this.onClick(v);
return true;
}
//计算速度
vt.computeCurrentVelocity(1000, Integer.MAX_VALUE);
//水平方向的速度
float xVelocity = vt.getXVelocity();
count = 0;
isTouchScreen = false;
if (onMoveStateListener != null) {
onMoveStateListener.onMoveState(STOPED);
}
//说明飞溅了
if (Math.abs(xVelocity) > 500) {
if (xVelocity < 0) {
currIndex++;
setCurrentImage(currIndex);
} else {
currIndex--;
setCurrentImage(currIndex);
}
return true;
}
//拿到现在的画布偏移量
int scrollX = getScrollX();
//拿到现在显示的图片的下标
int index = scrollX / mWidth;
//判断是左边还是右边多一点,从而应该判断应该往左还是往右
if (scrollX % mWidth > mWidth / 2) {
index++;
}
setCurrentImage(index);
return true;
}
return super.onTouchEvent(e);
}
上述代码中,在按下的时候记录下了手指在屏幕中的坐标,在手指移动的时候,记录下移动后的坐标,然后计算出和按下的时候的水平的间距dx,然后调用
scrollBy(-dx,0)来滚动画布!这里做一下重点解释dx是两点的水平距离,如果手指是向右滑动的,那么dx是大于零,而要让视图也跟着移动,就要让画布往右移动,所以这里调用方法scrollBy(-dx,0)的时候添加了一个负号
2.8目前的效果图
上面的动态图可以看到我们的图片可以左右的移动了,但是我们发现我们松手的时候并不能像
ViewPager一样平滑的滑动到指定的位置,那是因为我们没有在手指抬起的时候让画笔平滑的
滚动到指定位置
2.9编写平滑的滚动到指定位置的代码
/**
* 平滑的移动到指定位置
*/
private void smoothTo(int finalX) {
scroller.startScroll(getScrollX(), 0, finalX - getScrollX(), 0, defalutDuring);
scrollTo(scroller.getCurrX(), 0);
}
/**
* 平滑的移动dx的距离
*
* @param dx
*/
private void smoothBy(int dx) {
scroller.startScroll(getScrollX(), 0, dx, 0, defalutDuring);
}
/**
* 现在选中的下标
*/
private int currIndex = -1;
/**
* 设置被选中的下标
*
* @param index
*/
public void setCurrentImage(int index) {
if (index < 0) {
index = 0;
}
if (index > imageCount - 1) {
index = imageCount - 1;
}
currIndex = index;
smoothTo(index * mWidth);
}
@Override
public void computeScroll() {
if (scroller.computeScrollOffset()) {
removeRepeatData();
scrollTo(scroller.getCurrX(), 0);
}
requestLayout();
}
这里用到一个类Scroller,请参看我另一篇博客:http://blog.csdn.net/u011692041/article/details/50835670
setCurrentImage(index index)方法就是让画布滚动到指定的图片处
平滑滚动的原理:调用smoothTo(int finalX)方法,根据参数finalX得出目标横坐标和目前横坐标之间的间隔距离dx,然后调用scroller.startScroll方法计算出轨迹,然后使用类View中的
scrollTo方法,滚动到轨迹的第一个点,滚动完毕后,computerScroll方法会被调用,所以这里继续得到第二个点,让view滚动到第二个点,一直滚动到最后一个点,这样子在眼睛看来就是平滑的滚动,其实就是很多次的不间断的微小的移动!
2.10效果图
目前的效果可以达到了图片基本的显示,接下来让我们来实现指示器功能
2.11为轮播图添加指示器
要实现上述的小圆点的指示器,其实非常简单
我们可以自己绘制n个小圆点,让整体左右居中,距离顶部的距离top也可以固定下来
或者使用百分比,这里笔者建议使用百分比,这样子就省去了适配的过程
明确一下我们这个自定义控件继承的是ViewGroup,但是ViewGroup也是继承了View,所以必然有draw(Canvas canvas)方法用来绘制.
那么我们就可以利用这个方法来绘制我们需要绘制的n个小圆点,先给出一个图
/**
* 指示器的画笔
*/
private Paint p = new Paint();
/**
* 指示器的水平间距
*/
private int indicatorHorizontalSpacing = 10;
/**
* 指示器的半径
*/
private int indicatorRadius = 10;
/**
* 集合中存放的是指示器的几个小圆点的坐标
*/
private List<Point> indicatorCirclePoints = new ArrayList<Point>();
/**
* 计算指示器有关的数据
* 1.n个小圆点的圆心坐标
*/
private void computerIndicatorData() {
if (indicatorCirclePoints.size() == 0) { //判断是为了防止在多次调用的情况下去重复计算数据
int mTop = mHeight * 4 / 5;
int tmpLeft = (mWidth - imageCount * 2 * indicatorRadius - (imageCount - 1) * indicatorHorizontalSpacing) / 2;
for (int i = 0; i < imageCount; i++) {
indicatorCirclePoints.add(new Point(tmpLeft + indicatorRadius,mTop));
tmpLeft = tmpLeft + indicatorRadius * 2 + indicatorHorizontalSpacing;
}
}
}
/**
* 绘制的方法
* @param canvas
*/
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
//计算指示器有关的数据
computerIndicatorData();
for (int i = 0; i < imageCount; i++) {
if (currIndex == -1 || i == currIndex) {
currIndex = i;
p.setColor(Color.RED);
} else {
p.setColor(Color.WHITE);
}
int x = indicatorCirclePoints.get(i).x;
int y = indicatorCirclePoints.get(i).y;
c.drawCircle(getScrollX() + x, y, indicatorRadius, p);
}
}
这里笔者采用指示器距离顶部的距离为整个控件高度的4/5
这里预先定义好了圆点之间的间距和圆点的半径
然后算出第一个小圆点的距离控件左边的距离,然后利用循环,
计算出剩下的圆点距离控件左边的距离,从而画出n个圆点
重点解释:
1.判断中有判断currIndex == -1的情况,这是因为currentIndex默认是-1
draw方法在那些共有方法之前被调用的,所以这里不得不考虑这个情况,如果当前要绘制的小圆点的下标刚好是图片显示的下标,就让画笔的颜色变成其他不一样的颜色,这里就使用了红色,效果就是下面的效果图所示
2.画圆的时候,横坐标的值还加上了一个getScrollX()的值,这个值是整个控件的画布偏移量,也就是控件被手指左右滑动过去的偏移量,不加上这个值,你会发现你的指示器随着手指的滑动,指示器也会跟着动,所以这里为了让指示器永远的固定,就要加上这个值,保证滑动以后还是在原来的位置!
到这里我们就完成了指示器的绘制,我们在实践的过程中却发现了一个问题,指示器根本没有显示出来,这是为什么?
解答:draw方法在绘制孩子之前被调用,所以先绘制了指示器,后绘制了几个图片,这就导致绘制的指示器被图片給遮盖了!
那么我们就必须在绘制孩子之后绘制我们的指示器,那么我们需要重写dispatchDraw(Canvas c)方法,这个方法是分发绘制的意思,也就是绘制每一个孩子,所以这里我们就可以在这个方法调用之后绘制我们的指示器
把之前写在draw的代码移植到这个方法里面来:
/**
* 绘制孩子
*
* @param c
*/
@Override
protected void dispatchDraw(Canvas c) {
super.dispatchDraw(c);
//计算指示器有关的数据
computerIndicatorData();
for (int i = 0; i < imageCount; i++) {
if (currIndex == -1 || i == currIndex) {
currIndex = i;
p.setColor(Color.RED);
} else {
p.setColor(Color.WHITE);
}
int x = indicatorCirclePoints.get(i).x;
int y = indicatorCirclePoints.get(i).y;
c.drawCircle(getScrollX() + x, y, indicatorRadius, p);
}
}
2.12目前的效果图
接下来我们还要实现点击这几个小圆点的点击事件,由于这几个小圆点是我们绘制上去的,所以这里只能自己做判断,根据用户触摸的坐标是不是在某一个小圆点的范围内
2.13为指示器添加点击事件
开发一个方法,判断触摸的时候的坐标是不是在某一个小圆点里面
/**
* 判断一个点是不是在一个圆里面,包括边界
*
* @param circleCenter
* 圆心
* @param radius
* 半径
* @param anotherPoint
* 另一个点
* @return
*/
public static boolean isCircleContainsPoint(Point circleCenter, double radius, Point anotherPoint) {
if (radius < 0) {
throw new IllegalAccessError("半径不能小于0");
}
return (Math.pow(circleCenter.x - anotherPoint.x, 2) + Math.pow(circleCenter.y - anotherPoint.y, 2))
- radius * radius > 0 ? false : true;
}
/**
* 根据坐标获取选择的指示器的下标,
*
* @param p 触摸的坐标
* @return 如果点击的不是指示器就返回-1
*/
private int getSelectIndicatorIndex(Point p) {
for (int i = 0; i < indicatorCirclePoints.size(); i++) {
//获取每一个小圆点的圆心坐标
Point point = indicatorCirclePoints.get(i);
if (isCircleContainsPoint(point, indicatorRadius, p)) {
return i;
}
}
return -1;
}
这样子就写好了获取触摸的坐标是不是在某一个小圆点的方法,-1表示触摸的坐标没有在小圆点上
让控件实现View.OnclickListener接口
然后在点击回掉方法中获取点击指示器的下标,并且让图片平滑的滑动到指定的位置
@Override
public void onClick(View v) {
//获取用户触摸的指示器的下标,-1表示触摸的不是指示器
int selectIndicatorIndex = getSelectIndicatorIndex(new Point(currentX, currentY));
if (selectIndicatorIndex != -1) { //如果触摸的是指示器
setCurrentImage(selectIndicatorIndex);
setCurrentImage(selectIndicatorIndex);
}
}
可以看到这里我调用了两次setCurrentImage方法,但是笔者也调试了几个小时没弄明白为什么一次的时候就是不行,两次调用就没有问题,这个问题你们拿到源码之后可以研究研究,弄懂了之后请留言告诉笔者,万分感谢!
为了让控件的点击事件能生效,这里需要更改一下我们的触摸的方法里面的代码,因为点击事件是View类中已经处理好的一个事件,而我们重写之后就失去了View中写好的代码,所以这里对触摸事件做一下更改:
2.14为使用者提供一些更改ui显示效果的方法
/**
* 设置指示器的小圆点的半径
* @param indicatorRadius
*/
public void setIndicatorRadius(int indicatorRadius) {
this.indicatorRadius = indicatorRadius;
invalidate();
}
/**
* 设置小圆点之间的间距
* @param indicatorHorizontalSpacing
*/
public void setIndicatorHorizontalSpacing(int indicatorHorizontalSpacing) {
this.indicatorHorizontalSpacing = indicatorHorizontalSpacing;
invalidate();
}
/**
* 指示器被选中的小圆点的颜色
*/
private int selectIndicatorColor = Color.RED;
/**
* 指示器没有被选中的小圆点的颜色
*/
private int unSelectIndicatorColor = Color.WHITE;
/**
* 设置指示器选中的那个小圆点的颜色,默认是红色的
*
* @param color
*/
public void setSelectIndicatorColor(int color) {
selectIndicatorColor = color;
invalidate();
}
/**
* 设置指示器没有选中的小圆点的颜色
*
* @param color
*/
public void setUnSelectIndicatorColor(int color) {
unSelectIndicatorColor = color;
invalidate();
}
2.15优化加载图片功能
为了和加载图片这个功能降低耦合性,加载图片功能留给用户,所以采用设置接口的方式: /**
* 加载图片的监听
*/
public interface OnLoadImageListener {
/**
* 加载图片
*
* @param imageView
*/
public void loadImage(ImageView imageView,String imageUrl);
}
/**
* 图片加载接口对象
*/
private OnLoadImageListener onLoadImageListener = null;
/**
* 设置图片加载的接口
*
* @param onLoadImageListener
*/
public void setOnLoadImageListener(OnLoadImageListener onLoadImageListener) {
this.onLoadImageListener = onLoadImageListener;
}
然后在加载图片的时候,回掉这个接口中的方法
到此,轮播图控件制作完毕,笔者暂时没有解决的问题,请浏览者也思考思考,如果知道怎么回事,请留言!