简介
随便打开一款app,都能看到横幅位置有个广告图片无限循环轮播的控件。
它具有如下特点:
- 每隔几秒自动切换到下一张图片
- 当播放完最后一张图片后,自动切换到第一张图片实现无限循环
- 有几个小圆圈来指示共有几张图片以及当前显示的是第几张图
- 手指左右滑动可以实现左右切换
- 底部可能存在标题
- 点击实现页面跳转
它的实现方法有很多,简单的比如使用ViewPager,复杂点的比如自定义控件继承ViewGroup,然后将各个元素作为子视图并控制它们的滑动来实现轮播。
今天我们介绍另一种实现方案:继承View来实现广告图片轮播控件。
其主要原理:
- 使用Bitmap数组或列表来存放广告图片;
- 使用Handler延迟post的方法来实现自动切换;
- 重写onDraw方法绘制我们想要的效果
我们将分为几个阶段,直到所有功能实现为止。
第一阶段实现
- 每隔几秒自动切换到下一张图片
- 当播放完最后一张图片后,自动切换到第一张图片实现无限循环
- 有几个小圆圈来指示共有几张图片以及当前显示的是第几张图
- 手指左右滑动可以实现左右切换
- 底部可能存在标题
- 点击实现页面跳转
话不多说,直接上代码(习惯英文注释,避免代码迁移中文乱码的尴尬,英文不好,请轻喷),后面贴有GitHub源码地址,写的好请不吝射星。
package com.boilbingo.adplayer;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.View;
import java.util.List;
/**
* TODO: document your custom view class.
*/
public class AdPlayer extends View implements View.OnClickListener {
private final int DEFAULT_SWITCH_TIME = 3 * 1000;
// Store the current index of shown picture
private int mIndex;
// Array to store AD pictures
private List<Bitmap> mAdPictures;
private int mIndexIconRadius = 10;
// The space between two center of index icon
private int mIndexIconSpace = 35;
private int mIndexIconMargin = 50;
private class Point {
int x;
int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
}
private Paint mIndexIconPaint;
// The time switch to next picture (ms).
private int mSwitchTime = DEFAULT_SWITCH_TIME;
private Handler mPlayHandler = new Handler();
private Runnable mPlayRunnable = new Runnable() {
@Override
public void run() {
// Refresh view while updating index success
if (updateIndex(true)) {
// Refresh view.
invalidate();
// Post next switch task
postNextSwitchTask();
}
}
};
public interface ItemClickListener {
void onItemClick(int index);
}
private ItemClickListener mItemClickListener;
/**
* Constructor
* @param context
*/
public AdPlayer(Context context) {
super(context);
}
public AdPlayer(Context context, AttributeSet attrs) {
super(context, attrs);
mIndexIconPaint = new Paint();
setOnClickListener(this);
}
public void setAdPictures(List<Bitmap> adPictures) {
mAdPictures = adPictures;
}
public void setItemClickListener(ItemClickListener listener) {
mItemClickListener = listener;
}
@Override
public void onClick(View v) {
if (mItemClickListener != null) {
mItemClickListener.onItemClick(mIndex);
}
}
/**
* Update the current index of shown picture
* @param forward
* @return true for success and false for failure.
*/
private boolean updateIndex(boolean forward) {
boolean result = false;
if (mAdPictures != null && mAdPictures.size() > 1) {
if (forward) {
mIndex = ++mIndex % mAdPictures.size();
} else {
mIndex = (--mIndex < 0) ? mAdPictures.size() - 1 : mIndex;
}
result = true;
}
return result;
}
/**
* Post next switch task
*/
private void postNextSwitchTask() {
mPlayHandler.postDelayed(mPlayRunnable, mSwitchTime);
}
/**
* Start to play AD.
* Should stop() while exit.
*/
public void start() {
if (mAdPictures != null && mAdPictures.size() > 0) {
// Initial index to 0
mIndex = 0;
// Refresh player to show first picture
invalidate();
// Post task to switch picture at next time point
postNextSwitchTask();
}
}
/**
* Stop play AD.
*/
public void stop() {
// Remove switch task
mPlayHandler.removeCallbacks(mPlayRunnable);
}
/**
* Calculate the position of first index icon
* @return pos
*/
private Point calculateFirstIndexIconPos() {
int viewWidth = getMeasuredWidth();
int viewHeight = getMeasuredHeight();
int halfCenterLen = mIndexIconSpace * (mAdPictures.size() - 1) / 2;
return new Point(viewWidth / 2 - halfCenterLen, viewHeight - mIndexIconMargin);
}
/**
* Return scaled picture to show
* @return bitmap
*/
private Bitmap getCurrentScaledPicture() {
Bitmap oldBm = mAdPictures.get(mIndex);
int bmWidth = oldBm.getWidth();
int bmHeight = oldBm.getHeight();
float scaleWidth = ((float)getMeasuredWidth()) / bmWidth;
float scaleHeight = ((float)getMeasuredHeight()) / bmHeight;
boolean needScale = (scaleWidth != 1.0f || scaleHeight != 1.0f);
if (needScale) {
Matrix matrix = new Matrix();
matrix.postScale(scaleWidth, scaleHeight);
Bitmap newBm = Bitmap.createBitmap(oldBm,
0, 0, bmWidth, bmHeight, matrix, true);
mAdPictures.set(mIndex, newBm);
oldBm.recycle();
}
return mAdPictures.get(mIndex);
}
@Override
protected void onDraw(Canvas canvas) {
// super.onDraw(canvas);
if (mAdPictures != null && mAdPictures.size() > 0) {
// Draw picture
canvas.drawBitmap(getCurrentScaledPicture(), 0, 0, null);
// Draw index icon
Point firstIndexPos = calculateFirstIndexIconPos();
for (int i = 0; i < mAdPictures.size(); i++) {
mIndexIconPaint.setColor(Color.DKGRAY);
if (i == mIndex) {
mIndexIconPaint.setColor(Color.RED);
}
canvas.drawCircle(firstIndexPos.x + mIndexIconSpace * i,
firstIndexPos.y, mIndexIconRadius, mIndexIconPaint);
}
}
}
}
MainActivity中的简单调用:
package com.boilbingo.adplayer;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.widget.Toast;
import java.util.ArrayList;
import java.util.List;
public class MainActivity extends AppCompatActivity {
private AdPlayer adPlayer;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
adPlayer = (AdPlayer) findViewById(R.id.adPlayer);
List<Bitmap> list = new ArrayList<>();
Bitmap bitmap1 = BitmapFactory.decodeResource(getResources(), R.drawable.p1);
Bitmap bitmap2 = BitmapFactory.decodeResource(getResources(), R.drawable.p2);
Bitmap bitmap3 = BitmapFactory.decodeResource(getResources(), R.drawable.p3);
Bitmap bitmap4 = BitmapFactory.decodeResource(getResources(), R.drawable.p4);
list.add(bitmap1);
list.add(bitmap2);
list.add(bitmap3);
list.add(bitmap4);
adPlayer.setAdPictures(list);
adPlayer.setItemClickListener(new AdPlayer.ItemClickListener() {
@Override
public void onItemClick(int index) {
Toast.makeText(MainActivity.this,
"Index:" + index + " is clicked!!!", Toast.LENGTH_SHORT).show();
}
});
adPlayer.start();
}
@Override
protected void onDestroy() {
super.onDestroy();
adPlayer.stop();
}
}
效果图
阶段二 - 添加标题栏
想法是这样的:
- 在View的底部添加一半透明的矩形横条用来书写标题和放置指示圈;
- 标题和指示圈水平居中于横条里,标题位于左侧,指示圈位于右侧;
- 如果标题过长应该自动截断,并用省略号来表示未全部显示。
关键代码:
- 如果有标题的,把指示圈定为到底部横条的右侧:
private Point calculateFirstIndexIconPos() {
int viewWidth = getMeasuredWidth();
int viewHeight = getMeasuredHeight();
int halfCenterLen = mIndexIconSpace * (mAdPictures.size() - 1) / 2;
// Locate to right if have title
if (mTitles != null && mTitles.size() > 0) {
return new Point(viewWidth - halfCenterLen - mIndexIconMargin * 2,
viewHeight - mIndexIconMargin);
}
// Locate to center
return new Point(viewWidth / 2 - halfCenterLen, viewHeight - mIndexIconMargin);
}
- 因为底部横条上有标题栏和指示圈,所以得计算一下可以用来书写标题的标题栏的矩形框的大小。
/**
* Calculate the rect that can used to write title
* @param textBounds: the real rect of title
* @return Rect object
*/
private Rect getDrawableTextRect(Rect textBounds) {
int left = mIndexIconMargin;
// The space of rect's bottom to title's bottom.
int bottomMargin = (mTitleBgHeight - textBounds.height()) / 2;
int top = getMeasuredHeight() - mTitleBgHeight + bottomMargin;
int right = left + getMeasuredWidth() - mIndexIconMargin * 4 -
mIndexIconSpace * (mAdPictures.size() - 1) + 2 * mIndexIconRadius;
int bottom = getMeasuredHeight() - bottomMargin;
return new Rect(left, top, right, bottom);
}
- 标题的文字长度大于标题栏的矩形宽度,那边就需要截断文字,并用省略号来表示文本没有全部显示。
我是用循环来截断并判断直到符合条件,这边存在优化空间,当文本长度非常长的情况下,需要优化。
/**
* Cut title if title's length is larger than drawable rect's width
* @param text: title
* @param textBounds: the real rect of title
* @param drawableRect: the rect that can used to write title
* @return A string that can be shown
*/
private String getWritableText(String text, Rect textBounds, Rect drawableRect) {
String result = null;
boolean needCut = false;
// Cut title until can be written
// TODO: Optimize if the title length is very long
while (textBounds.width() > drawableRect.width()) {
needCut = true;
text = text.subSequence(0, text.length() - 1 - 3) + "...";
mTitlePaint.getTextBounds(text, 0, text.length(), textBounds);
}
if (needCut) result = text;
return result;
}
- 最后是在draw方法中画上新添加的这些元素
@Override
protected void onDraw(Canvas canvas) {
// super.onDraw(canvas);
if (mAdPictures != null && mAdPictures.size() > 0) {
// Draw picture
canvas.drawBitmap(getCurrentScaledPicture(), 0, 0, null);
// Draw title
if (mTitles != null) {
// Draw tile background
canvas.drawRect(0,
getMeasuredHeight() - mTitleBgHeight,
getMeasuredWidth(),
getMeasuredHeight(), mTitleBgPaint);
String title = mTitles.get(mIndex);
if(!TextUtils.isEmpty(title)) {
// Get text bounds
Rect textBounds = new Rect();
mTitlePaint.getTextBounds(title, 0, title.length(), textBounds);
// Get the drawable rect to write title
Rect drawableRect = getDrawableTextRect(textBounds);
String writableText = getWritableText(title, textBounds, drawableRect);
if (!TextUtils.isEmpty(writableText)) {
title = writableText;
mTitles.set(mIndex, title);
}
canvas.drawText(title,
drawableRect.left, drawableRect.bottom, mTitlePaint);
}
}
// Draw index icon
Point firstIndexPos = calculateFirstIndexIconPos();
for (int i = 0; i < mAdPictures.size(); i++) {
mIndexIconPaint.setColor(Color.DKGRAY);
if (i == mIndex) {
mIndexIconPaint.setColor(Color.RED);
}
canvas.drawCircle(firstIndexPos.x + mIndexIconSpace * i,
firstIndexPos.y, mIndexIconRadius, mIndexIconPaint);
}
}
}
效果图:
平滑地切换
现在,我们的广告图片在切换的时候是直接绘制下一张图片,这样会显得有点突兀。
为了在切换的过程中能平滑地过渡到下一张图片,我们需要做一些额外的工作,比如在切换到下一张时,让前后两张左右相连,一起向左滚动,直到新图片完全显示为止。
它的完整流程是:
=> 三秒时间到,准备切换下一张图片
=> 平滑切换过渡:
-> 1. 设置总过渡时间以及刷新频率
-> 2. 计算每次偏移量;
-> 3. 根据偏移量绘制前一张图与下一张图
-> 4. 循环2、3步骤知道下一张图片完全显示(一样是用handler来延迟触发下一次循环)
=> 开始计时,循环切换下一张图片
关键代码:
计算偏移量以及post任务
private Runnable smootoSwitchingRunnable = new Runnable() {
@Override
public void run() {
// Calculate alpha to ensure finish switch in given time
int width = getMeasuredWidth();
int alpha = width * mDelayTimeWhileSwitching / mTotalSwitchingTime;
if (mForward) {
mSwitchedOffset -= alpha;
} else {
mSwitchedOffset += alpha;
}
if (Math.abs(mSwitchedOffset) > width) {
// Finish switching task
// reset offset
mSwitchedOffset = 0;
// Post the next switch task
postNextSwitchTask();
} else {
// post next recycle
mPlayHandler.postDelayed(this, mDelayTimeWhileSwitching);
}
// Refresh view
invalidate();
}
};
绘制广告图
/**
* Draw the pictures to bitmap
* @param canvas: from onDraw
*/
private void drawDisplayBitmap(Canvas canvas) {
if (mSwitchedOffset != 0) {
// In progress of smooth switching
if (mForward) {
// switch to next picture
// draw left
canvas.drawBitmap(mAdPictures.get(mPreIndex), mSwitchedOffset, 0, null);
//draw right
canvas.drawBitmap(getCurrentScaledPicture(),
getMeasuredWidth() + mSwitchedOffset, 0, null);
} else {
// switch to previous picture
// draw left
canvas.drawBitmap(mAdPictures.get(mPreIndex),
-getMeasuredWidth() + mSwitchedOffset, 0, null);
//draw right
canvas.drawBitmap(getCurrentScaledPicture(), mSwitchedOffset, 0, null);
}
} else {
// Draw current picture
canvas.drawBitmap(getCurrentScaledPicture(), 0, 0, null);
}
}
效果图:
手指左右拖动切换图片
在这里我们要先定义几个规则:
- 当手指拖动图片的距离大于一半时放开,自动切换到前一张/后一张图片,否则回弹;
- 当手指左右快速滑动或者拖动图片后存在惯性速度,则直接切换到前一张/后一张图片;
- 在自动切换的过程中,我们视为一种过渡状态,所以在这状态下不允许处理触摸事件。
第三点的实现有个知识点:DOWN、MOVE、UP/CANCEL是一套整体动作,如果不处理DOWN事件,那么后续的一整套事件都不会交由该view处理。因此我们可以在DOWN事件判断是否正在切换中返回false来不处理这一套事件。
由于添加了滑动事件,也相应地添加了复杂性,因而之前有的代码已经不适用了,需要重构。代码重构是必要的,不然如果只是添加判断来修复逻辑,势必会造成代码阅读性差以及增加代码的维护成本。
我这里重新定义了一个逻辑:
左右滑动时必然会同时显示两张图片的情形,因而为简化我们的逻辑,我们在绘图时不区分当前图片与非当前图片,只区分左右图片。因而需要添加两个变量来实现该逻辑:
- mLeftIndex:当前绘制的两张图片中,左图所对应index(可能取值为当前图片或者上一张图片),需要在所有执行绘制的地方前设置该值
- mLeftOfLeftPicture:左图的左上角横坐标的值,需要在所有执行绘制的地方前设置该值
代码:
/**
* Draw the pictures to bitmap
* @param canvas: from onDraw
*/
private void drawDisplayBitmap(Canvas canvas) {
if (mLeftOfLeftPicture != 0) {
// In progress of smooth switching
// draw left
canvas.drawBitmap(getScaledPicture(mLeftIndex), mLeftOfLeftPicture, 0, null);
//draw right
mRightIndex = (mLeftIndex + 1) % mAdPictures.size();
canvas.drawBitmap(getScaledPicture(mRightIndex),
getMeasuredWidth() + mLeftOfLeftPicture, 0, null);
} else {
// Draw current picture
canvas.drawBitmap(getScaledPicture(mLeftIndex), 0, 0, null);
}
}
onTouchEvent的代码:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (mLeftOfLeftPicture != 0) {
// it is switching, don't handle the set of events
return false;
}
// Cancel task of auto switch to next picture
stop();
// Initial velocity tracker and displacement
mVelocityTracker = VelocityTracker.obtain();
mDisplacement = 0;
// record start x coordinate
mStartX = event.getX();
break;
case MotionEvent.ACTION_MOVE:
// Add event to tracker for further computation
mVelocityTracker.addMovement(event);
// use displacement to check toward left or right to drag direction
mDisplacement = (int)(event.getX() - mStartX);
if (mDisplacement > 0) {
// Toward right, need to change mLeftIndex to mPreIndex
mLeftIndex = mPreIndex;
// Update the left of left picture
mLeftOfLeftPicture = mDisplacement - getMeasuredWidth();
} else {
// Toward left, mLeftIndex is mCurIndex
mLeftIndex = mCurIndex;
// Left of left picture equals displacement
mLeftOfLeftPicture = mDisplacement;
}
invalidate();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mVelocityTracker.addMovement(event);
mVelocityTracker.computeCurrentVelocity(1000);
// check if needs to switch
if (Math.abs(mDisplacement) > getMeasuredWidth() / 2
|| Math.abs(mVelocityTracker.getXVelocity()) > 200f) {
// Switch to left or right, it is the direction of displacement
mTowardLeft = mDisplacement < 0;
// Update index to previous if toward right, or update to next
updateIndex(mTowardLeft);
startSmoothSwitching();
} else {
if (Math.abs(mDisplacement) > 0) {
// rollback so its direction is opposite to displacement
mTowardLeft = !(mDisplacement < 0);
startSmoothSwitching();
}
}
// recycle
mVelocityTracker.recycle();
mVelocityTracker = null;
break;
}
return true;
}
效果图
完整代码请到github中查看,谢谢