自定义Banner控件

自定义轮播的Banner网上有很多资源,经过自己思考后决定想尝试下自己写一个更为通用的Banner。

Banner支持的功能

  1. 支持只有图片轮播
  2. 支持图片+圆点提示轮播(三点位置支持左,中,右)
  3. 支持图片+圆点提示+文字提示+提示背景色轮播
  4. 支持自定义扩展,继承BaseBannerIndicator
    提醒:详细的使用文档请查看github上的wiki
    Demo github地址

Banner类

Banner内部简介

  1. Banner是继承一个FrameLayout,继承的本质是为了能提供改变Banner的四个角的属性。
  2. Banner内部实例化一个ViewPager,将其添加在第一个层级上。
  3. Banner根据Attribute属性选择生成对应的Indicator(本质是View)实例,如果没选择,可以为空。如果不为空,添加到第二个层级上。所以Indicator是在ViewPager上方的,背景色会遮挡ViewPager部分内容,背景色属性与View的background属性相同。
  4. 轮播机制的实现是用Handler与sendMessageDelay来实现的。

Banner内部实现介绍

ViewPager的Adapter

  1. 继承getCount抽象方法,告知Adapter里含有的Pager数量
  2. 重写instantiateItem(container: ViewGroup, position: Int)方法,以便可以复用itemView
  3. 重写destroyItem(container: ViewGroup, position: Int, object: Any?)方法,itemView在onDestroy的时候从container里面移除。
BannerPagerAdapter关键代码
//原理上实现了无限右滑动
 override fun getCount(): Int {
        return Int.MAX_VALUE
 }
//获取外部接口IBannerItem返回的ItemView
override fun instantiateItem(container: ViewGroup, position: Int): Any {
        val itemBanner = getBannerItem(position)
        val itemView = itemBanner.getChildView(container)
        itemView.tag = itemBanner
        itemView.setOnClickListener(this)
        container.addView(itemView)
        return itemView
    }
//移除不需要itemView
 override fun destroyItem(container: ViewGroup, position: Int, `object`: Any?) {
        (`object` as? View)?.let {
            container.removeView(it)
        }
    }

BannerIndicator初始化

  1. BaseBannerIndicator类主要是保存ViewPager引用,在ViewPager状态发生变化的时候,能通知到对应子类的Indicator。
  2. 每次ViewPager切换的时候,代表着数据源会发生变化,所以对应要触发requestLayout(),重新走一次完整View的measure,Layout,onDraw方法。
  3. 子类的Indicator继承父类的getMeasureWidth(widthMeasureSpec: Int)和getMeasureWidth(widthMeasureSpec: Int)方法,在里面根据specMode判断Params的类型,以此来计算出子类在对应的Params类型下该返回多大的宽与高。
  4. 子类继承onDrawView方法后,在里面计算起始x,y坐标,绘制自己需要的字体,图像等。
以PointBannerIndicator代码为例
//方法1:BaseBannerIndicator保存了ViewPager的引用,实现了ViewPager.OnPageChangeListener,每当ViewPager切换时候都会接受到回调。
//方法2:每次回调都会触发requestLayout(),从而达到上诉方法2的效果。
 override fun onPageSelected(position: Int) {
        mCurrentPage = position
        requestLayout()
        if (mListener != null) {
            mListener!!.onPageSelected(position)
        }
    }

//方法3:子View对应要告知父布局,在对应Params模式下,自身的宽与高,这里就用宽度做例子讲明就可以了。
override fun getMeasureWidth(widthMeasureSpec: Int): Int {
        var result: Int
        val specMode = MeasureSpec.getMode(widthMeasureSpec)
        val specSize = MeasureSpec.getSize(widthMeasureSpec)

        if (specMode == MeasureSpec.EXACTLY || mViewPager == null) {
            //该specMode对应的是match_parent,所以自身的宽度应该与父布局的宽度一样。
           //android艺术探索:EXACTLY -> match_parent,;AT_MOST -> wrap_content;UNSPECIFIED(子View要多大,父就给多大,我也希望有这种金主爸爸) 
            result = specSize
        } else {
            //获取绘制的圆点个数 => 得出所需的宽度 => AT_MOST模式下取ParentWidth和CircleWidth的最小值。
            val count = if (mRealSize == 0) mViewPager!!.adapter!!.count else mRealSize
            result = (paddingLeft + paddingRight + count * 2 * mBaseIndicatorParams.circleRadius + (count - 1) * mBaseIndicatorParams.offset)
            //Respect AT_MOST value if that was what is called for by measureSpec
            if (specMode == MeasureSpec.AT_MOST) {
                result = Math.min(result, specSize)
            }
        }
        return result
    }

//方法4:计算绘制圆点的(x,y)和确定圆点显示位置
override fun onDrawView(canvas: Canvas) {
        if (mViewPager == null) {
            return
        }
        val count = mViewPager!!.adapter!!.count
        if (count == 0 || mRealSize == 0) {
            return
        }
        if (mCurrentPage >= count) {
            setCurrentItem(count - 1)
            return
        }
        //计算圆点的y坐标,(x,y)为起始点,radius为半径绘制一个圆
        val yOffset = (paddingTop + mBaseIndicatorParams.circleRadius).toFloat()

        var dX: Float
        var dY: Float

        for (iLoop in 0 until mRealSize) {
            //确定圆点的显示位置:left,center,right
            dX = switchGravity(iLoop).toFloat()
            dY = yOffset
            // Only paint fill if not completely transparent
            if (mSelectPaint.alpha > 0) {
                canvas.drawCircle(dX, dY, mBaseIndicatorParams.circleRadius.toFloat(), mUnSelectPaint)
            }
        }

        val current = if (mRealSize == 0) mCurrentPage else mCurrentPage % mRealSize
        dX = switchGravity(current).toFloat()
        dY = yOffset
        canvas.drawCircle(dX, dY, mBaseIndicatorParams.circleRadius.toFloat(), mSelectPaint)
        return
    }

ItemView布局初始化

  1. 创建一个Banner基类,将抽象方法createItemView与getLayoutId结合,这样暴露外面的方法只需要getLayoutId和onViewCreate就可以了。
  2. 创建ImageTextBanner继承基类,实现IBannerDataCallback接口,返回数据包含的图片和文字。
基类代码
//方法1:创建布局上,对外只需继承getLayoutId抽象方法即可。
abstract class BaseImageBanner : Banner.BaseBannerItem() {
    override fun createItemView(viewGroup: ViewGroup): View {
        val view = LayoutInflater.from(viewGroup.context).inflate(getLayoutId(), viewGroup, false)
        onViewCreate(view)
        return view
    }
    abstract fun onViewCreate(itemView: View)
}
//方法2:继承基类,加载数据
class ImageTextBanner(val b: BannerBean.DataBean) : BaseImageBanner(), IBannerDataCallback {
    override fun getImageUrl(): String {
        return b.image
    }
    override fun getShowText(): String {
        return b.title
    }

    lateinit var mImageView: SimpleDraweeView
    //复用View的时候需要重新加载正确的图片,文字是onDrawView时候绘制的,所以无需外部重新设置。
    override fun useOldView(itemView: View) {
        mImageView = itemView.findViewById(R.id.draweeView) as SimpleDraweeView
        mImageView.setImageURI(b.image)
    }

    override fun getLayoutId(): Int {
        return R.layout.item_banner
    }

    override fun onViewCreate(itemView: View) {
        mImageView = itemView.findViewById(R.id.draweeView) as SimpleDraweeView
        mImageView.setImageURI(b.image)
    }
}

定时轮播

  1. 主要通过handler.sendMessageDelay()方法来实现,但是sendMesageDelay只能保证不被提前执行,无法保证准时执行,如果不明白的可以网上搜下资料看下。
  2. 当ViewPager处于滑动状态时候,本次message无效,然后重新发送一次新的delay message事件。
  3. 收到有效的message时间后,调用viewPager.setCurrentItem(position : Int)实现自动滑动效果。
Handler关键代码
override fun handleMessage(msg: Message?): Boolean {
        when (msg?.what) {
            MSG_FLIP -> {
                mHandler?.let {
                    it.removeMessages(MSG_FLIP)
                    if (mIndicator != null) {
                        if (mIndicator!!.isScrollIdle()) {
                            it.sendEmptyMessageDelayed(MSG_FLIP, mBannerParams.flipTime.toLong())
                            showNextItem()
                        } else {
                            it.sendEmptyMessageDelayed(MSG_FLIP, BANNER_START_DELAY.toLong())
                        }
                    } else {
                        it.sendEmptyMessageDelayed(MSG_FLIP, mBannerParams.flipTime.toLong())
                        showNextItem()
                    }
                }
            }
        }
        return true
    }

总结

  1. Banner其实就是利用Pager的滑动机制实现的。主要是如何处理好多次创建布局所带来的资源损耗,所以我这里想到可以复用已经被container remove的itemView,这样子就无需多次创建布局。
  2. 图片轮播上可以采用Android timer或者是Handler,我这里实现是用了Handler与message的特性去实现的,如果有兴趣也可以将起该成timer来实现。

你可能感兴趣的:(自定义Banner控件)