现在的App中,广告栏Banner的使用还是挺广泛的,用于展示各种广告、活动推荐等。使用HorizontalScrollView可以很简单的实现一个可自动播放、可滑动、可点击的广告栏Banner,这个也可以做为一个例子,来学习自定义控件的制作。相关原理主要包括两个方面:
这两部分搞懂后,制作自定义控件就得心应手了。
最终效果如图:
HorizontalScrollView本来就是一个横向滑动组件,使用它可以很方便的实现滑动及相应的动画效果,所以选择用它来写这个控件,我看网上也有使用ViewPager实现,原理都是大同小异;下面是按上面的需求做的初始设计,在实现的过程中还会碰到其他问题,需要按情况解决。
布局
HorizontalScrollView——LinearLayout——ImageView List
同时需要在HorizontalScrollView上画小白点指示当前页
定时滚动
添加一个定时器,每隔一段时间滑动到下一页,注意最后一页的循环处理。
用户事件
添加事件监控,触摸停止定时器及滑动事件处理
xml中的布局只有最外层控件,其他的LinearLayout和ImageView都是动态添加进去的,实现如下:
public class ADPager extends HorizontalScrollView{
private LinearLayout container = null;
private LinearLayout.LayoutParams imgLayoutParams = null;
public ADPager(Context context) {
super(context);
init();
}
private void init(){
Context ctx = getContext();
container = new LinearLayout(ctx);
ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
container.setLayoutParams(layoutParams);
//横向布局
container.setOrientation(LinearLayout.HORIZONTAL);
imgLayoutParams = new LinearLayout.LayoutParams(getWidth(),getHeight());
this.addView(container);
this.setSmoothScrollingEnabled(true);
//不显示滑动条
this.setHorizontalScrollBarEnabled(false);
}
public void setImageList(int imgArray[]){
int size = imgArray.length;
if(size > 1){
//如果大于一张图片,第一张前放最后一张图片
this.container.addView(makeImageView(imgArray[size - 1]));
}
for(int imgId:imgArray){
this.container.addView(makeImageView(imgId));
}
if(size > 1){
//如果大于一张图片,最后一张后放第一张图片
this.container.addView(makeImageView(imgArray[0]));
}
}
public ImageView makeImageView(int resourceId){
ImageView imageView;
Context ctx = getContext();
imageView = new ImageView(ctx);
imageView.setImageResource(resourceId);
imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
imageView.setLayoutParams(imgLayoutParams);
return imageView;
}
在onCreate中调用初始化图片:
int imgIdArray[] = {R.drawable.img1,R.drawable.img2,R.drawable.img3};
ADPager adPager = (ADPager)findViewById(R.id.adpager);
adPager.setImageList(imgIdArray);
然后运行就碰到了第一个坑,根本没有图片被展示出来,原因是:在初始化时,我们尝试使用getWidth和getHeight函数来获取宽度和高度,然后设置图片大小,但在View还没有展示出来时,其实通过这两个函数是不能获取宽高的,比如在onCreate/onStart/onResume中,详见:
Activity中获取view的高度和宽度为0的原因以及解决方案
在上面的文章中,也提到了几种获取的方式,但我们是自定义控件,还有他方式来获取:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
imgLayoutParams.width = getMeasuredWidth();
imgLayoutParams.height = getMeasuredHeight();
}
这种方法使用了onMeasure函数,现在只要知道这个函数是用来测量自己及子View的大小就可以了,后面还会系统总结。
现在已经可以展示出图片,且可以自由滑动,当然,现在还简陋的很:
只不过有多张图片时,我们显示的是最后一张图片,是因为我们为了第一张图片还可以往前滑动,在前面添加的,所以我们需要在初始时,滑动到第一张图片展示:
public void scrollToPage(int page,boolean isSmooth){
if(page < 0){
page = mTotalSize - 1;
}else if(page >= mTotalSize){
page = 0;
}
//设置当前页
mCurrPage = page;
int width = getWidth();
//因为第一张前面加了一张,所以页数需要+1。而只有一张图片时,scrollTo其实没有产生效果
if(isSmooth){
this.smoothScrollTo((page + 1) * width, 0);
}else{
this.scrollTo((page + 1) * width, 0);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
scrollToPage(0,false);
}
小白点用来指示当前是哪张图片,位于中间下方,且不随图片滑动而移动。所以只能画在最上层的HorizontalScrollView上。这里我们在onDraw画控件函数中,直接在画版上画:
private void initPaint(){
mStrokePaint = new Paint();
//抗锯齿
mStrokePaint.setAntiAlias(true);
//空心线宽
mStrokePaint.setStrokeWidth(1.0f);
//中空
mStrokePaint.setStyle(Paint.Style.STROKE);
//颜色
mStrokePaint.setColor(Color.WHITE);
mFillPaint = new Paint();
mFillPaint.setAntiAlias(true);
mFillPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mFillPaint.setColor(Color.WHITE);
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
int width = getWidth();
float density = getContext().getResources().getDisplayMetrics().density;
//半径转换为像素
int radiusInPixel = (int)(CIRCLE_RADIUS * density);
//白点间隔
int margin = radiusInPixel;
//白点区域总宽度
int totalWidth = radiusInPixel * 2 * mTotalSize + margin * (mTotalSize - 1);
//初始第一个点位置
int offsetX = getScrollX() + width / 2 - totalWidth / 2 + radiusInPixel;
int offsetY = (int)(getHeight() - density * 10 - radiusInPixel);
//开始画点
for(int i = 0;i < mTotalSize; i++){
if(i == mCurrPage){
canvas.drawCircle(offsetX,offsetY,radiusInPixel,mFillPaint);
}else{
canvas.drawCircle(offsetX,offsetY,radiusInPixel,mStrokePaint);
}
offsetX += radiusInPixel * 2 + margin;
}
}
可以看到我代码中其实是使用dispatchDraw来画的,而不是上面说的onDraw函数。是因为我在实现时又踩了一个坑,因为View会先调用onDraw来画自己的东西,然后调用dispatchDraw去画孩子(当然,View没有孩子,这个只有在ViewGroup中才有用)
// Step 3, draw the content
if (!dirtyOpaque) onDraw(canvas);
// Step 4, draw the children
dispatchDraw(canvas);
如果在onDraw中去画点,则会被后来画的孩子遮挡住,这个可以将container不添加到父节点中去来测试,可以看到我们画的圆。所以应该在dispatchDraw中,画完孩子然后去画点。
当然,在网上也看到如果没有背景会跳过onDraw直接调用dispatchDraw的说法,实验结果并不是这样。
这个比较简单,不过其中也碰到了一个坑,内存泄漏问题。这个可以看下前面的一个杂记:Android内存泄露杂记2016-02-26
具体就是匿名Runnable引用外部数据,后来使用WeakReference解决,代码如下:
public static class AutoPlayRunable implements Runnable{
private WeakReference<ADPager> reference = null;
public AutoPlayRunable(ADPager adPager){
reference = new WeakReference<ADPager>(adPager);
}
@Override
public void run() {
ADPager adPager = reference.get();
if(adPager != null){
int page = adPager.getCurrPage();
adPager.scrollToPage(page + 1,true);
adPager.postDelayed(adPager.getAutoPlayRunnable(),AUTO_PLAY_DUATION);
}
}
}
但是现在还无法通过触摸来顺畅控制广告移动,就和上图一样。因为HorizontalScrollView自己处理了触摸事件,通过手指来自由滑动。但这不是我们想要的结果,我们需要的是通过触摸,可以左右滑动,但超过一半,就应该显示下一张,或者没超过一半退回,而不是停在中间。然后就是手指滑动的够快,就算不超过一半也需要到下一张,就和有惯性一样。想要实现这样的结果,我们需要重写触摸事件处理,幸运的是,Android是支持这样做的。
这需要使用到几个触摸事件接口,并对其流程足够了解。一共涉及3个接口,如下:
View里,有两个回调函数 :
1. public boolean dispatchTouchEvent(MotionEvent ev);
2. public boolean onTouchEvent(MotionEvent ev);
ViewGroup里,有三个回调函数 :
1. public boolean dispatchTouchEvent(MotionEvent ev);
2. public boolean onInterceptTouchEvent(MotionEvent ev);
3. public boolean onTouchEvent(MotionEvent ev);
在Activity里,有两个回调函数 :
1. public boolean dispatchTouchEvent(MotionEvent ev);
2. public boolean onTouchEvent(MotionEvent ev);
事件传递默认是从父节点开始,直到传递到View。也就是说传递过程是Activity-ViewGroup-View。
触摸事件是由一系列的ACTION_DOWN、ACTION_MOVE…MOVE…MOVE、ACTION_UP的过程
对上面的接口来说,事件包含三个处理方式,一是分发(dispatchTouchEvent),二是拦截(onInterceptTouchEvent),一个是消费(onTouchEvent),并都有其返回值。
配上伪代码:
View mTarget=null;//保存捕获Touch事件处理的View
public boolean dispatchTouchEvent(MotionEvent ev) {
//....其他处理,在此不管
if(ev.getAction()==KeyEvent.ACTION_DOWN){
//每次Down事件,都置为Null
if(!onInterceptTouchEvent()){
mTarget=null;
View[] views=getChildView();
for(int i=0;i<views.length;i++){
if(views[i].dispatchTouchEvent(ev))
mTarget=views[i];
return true;
}
}
}
//当子View没有捕获down事件时,ViewGroup自身处理。这里处理的Touch事件包含Down、Up和Move
if(mTarget==null){
return super.dispatchTouchEvent(ev);
}
//...其他处理,在此不管
if(onInterceptTouchEvent()){
//...其他处理,在此不管
}
//这一步在Action_Down中是不会执行到的,只有Move和UP才会执行到。
return mTarget.dispatchTouchEvent(ev);
}
更具体的知识储备:
Android中的dispatchTouchEvent()、onInterceptTouchEvent()和onTouchEvent()
Android:30分钟弄明白Touch事件分发机制
现在我们就可以来想上面的问题了:
最后代码如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
//停止自动播放
removeCallbacks(mAutoPlayRunnable);
break;
}
return super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
if(mVelocityTracker == null){
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(ev);
switch (ev.getAction()){
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
int width = getWidth();
//计算手指每秒移动像素
mVelocityTracker.computeCurrentVelocity(1000,2000);
float speedX = mVelocityTracker.getXVelocity();
int page;
if(Math.abs(speedX) > 1000){
//移动速度够大
page = scrollX / width;
if(speedX > 0){
page -= 1;
}
}else{
//缓慢移动,按当前哪张图展示多就显示哪张
page = (int)Math.round(scrollX * 1.0 / width) - 1;
}
scrollToPage(page, true);
//开启自动播放
postDelayed(mAutoPlayRunnable,AUTO_PLAY_DUATION);
//直接返回,不让ScrollView处理事件
return true;
}
return super.onTouchEvent(ev);
}
这一步就很简单了,我们只需要给最下层的View添加点击事件就可以了。因为前面提到过的,View如果消费了UP事件用于Click事件,就不会传递给上层的ScrollView了。这里其实就也可以回答上面提出的问题,为什么不能在onTouchEvent中来取消自动播放,因为View默认会消费掉DOWN事件,是传递不到ScrollView中的。
至此,广告栏Banner就已经做好了。其中涉及的各种知识,在自定义控件中,都是必须的,只有熟练掌握,才能写出属于自己的个性化控件,少踩几个坑。
PS:推荐一个好的看Android原码网站:
http://grepcode.com/