水煮安卓 - 自定义广告图片轮播

简介

随便打开一款app,都能看到横幅位置有个广告图片无限循环轮播的控件。
它具有如下特点:

  1. 每隔几秒自动切换到下一张图片
  2. 当播放完最后一张图片后,自动切换到第一张图片实现无限循环
  3. 有几个小圆圈来指示共有几张图片以及当前显示的是第几张图
  4. 手指左右滑动可以实现左右切换
  5. 底部可能存在标题
  6. 点击实现页面跳转

它的实现方法有很多,简单的比如使用ViewPager,复杂点的比如自定义控件继承ViewGroup,然后将各个元素作为子视图并控制它们的滑动来实现轮播。
今天我们介绍另一种实现方案:继承View来实现广告图片轮播控件。
其主要原理:

  1. 使用Bitmap数组或列表来存放广告图片;
  2. 使用Handler延迟post的方法来实现自动切换;
  3. 重写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();
    }
}

效果图
效果图

阶段二 - 添加标题栏

想法是这样的:

  1. 在View的底部添加一半透明的矩形横条用来书写标题和放置指示圈;
  2. 标题和指示圈水平居中于横条里,标题位于左侧,指示圈位于右侧;
  3. 如果标题过长应该自动截断,并用省略号来表示未全部显示。

关键代码:

  1. 如果有标题的,把指示圈定为到底部横条的右侧:
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);
}
  1. 因为底部横条上有标题栏和指示圈,所以得计算一下可以用来书写标题的标题栏的矩形框的大小。
/**
 * 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);
}
  1. 标题的文字长度大于标题栏的矩形宽度,那边就需要截断文字,并用省略号来表示文本没有全部显示。

我是用循环来截断并判断直到符合条件,这边存在优化空间,当文本长度非常长的情况下,需要优化。

/**
 * 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;
}
  1. 最后是在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);
    }
}

效果图:
在这里插入图片描述

手指左右拖动切换图片

在这里我们要先定义几个规则:

  1. 当手指拖动图片的距离大于一半时放开,自动切换到前一张/后一张图片,否则回弹;
  2. 当手指左右快速滑动或者拖动图片后存在惯性速度,则直接切换到前一张/后一张图片;
  3. 在自动切换的过程中,我们视为一种过渡状态,所以在这状态下不允许处理触摸事件。

第三点的实现有个知识点:DOWN、MOVE、UP/CANCEL是一套整体动作,如果不处理DOWN事件,那么后续的一整套事件都不会交由该view处理。因此我们可以在DOWN事件判断是否正在切换中返回false来不处理这一套事件。

由于添加了滑动事件,也相应地添加了复杂性,因而之前有的代码已经不适用了,需要重构。代码重构是必要的,不然如果只是添加判断来修复逻辑,势必会造成代码阅读性差以及增加代码的维护成本。
我这里重新定义了一个逻辑:
左右滑动时必然会同时显示两张图片的情形,因而为简化我们的逻辑,我们在绘图时不区分当前图片与非当前图片,只区分左右图片。因而需要添加两个变量来实现该逻辑:

  1. mLeftIndex:当前绘制的两张图片中,左图所对应index(可能取值为当前图片或者上一张图片),需要在所有执行绘制的地方前设置该值
  2. 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中查看,谢谢

GitHub地址:https://github.com/aabingoo/AdPlayer

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值