自定义轮播的Banner网上有很多资源,经过自己思考后决定想尝试下自己写一个更为通用的Banner。
Banner支持的功能
- 支持只有图片轮播
- 支持图片+圆点提示轮播(三点位置支持左,中,右)
- 支持图片+圆点提示+文字提示+提示背景色轮播
- 支持自定义扩展,继承BaseBannerIndicator
提醒:详细的使用文档请查看github上的wiki
Demo github地址
Banner类
Banner内部简介
- Banner是继承一个FrameLayout,继承的本质是为了能提供改变Banner的四个角的属性。
- Banner内部实例化一个ViewPager,将其添加在第一个层级上。
- Banner根据Attribute属性选择生成对应的Indicator(本质是View)实例,如果没选择,可以为空。如果不为空,添加到第二个层级上。所以Indicator是在ViewPager上方的,背景色会遮挡ViewPager部分内容,背景色属性与View的background属性相同。
- 轮播机制的实现是用Handler与sendMessageDelay来实现的。
Banner内部实现介绍
ViewPager的Adapter
- 继承getCount抽象方法,告知Adapter里含有的Pager数量
- 重写instantiateItem(container: ViewGroup, position: Int)方法,以便可以复用itemView
- 重写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初始化
- BaseBannerIndicator类主要是保存ViewPager引用,在ViewPager状态发生变化的时候,能通知到对应子类的Indicator。
- 每次ViewPager切换的时候,代表着数据源会发生变化,所以对应要触发requestLayout(),重新走一次完整View的measure,Layout,onDraw方法。
- 子类的Indicator继承父类的getMeasureWidth(widthMeasureSpec: Int)和getMeasureWidth(widthMeasureSpec: Int)方法,在里面根据specMode判断Params的类型,以此来计算出子类在对应的Params类型下该返回多大的宽与高。
- 子类继承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布局初始化
- 创建一个Banner基类,将抽象方法createItemView与getLayoutId结合,这样暴露外面的方法只需要getLayoutId和onViewCreate就可以了。
- 创建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)
}
}
定时轮播
- 主要通过handler.sendMessageDelay()方法来实现,但是sendMesageDelay只能保证不被提前执行,无法保证准时执行,如果不明白的可以网上搜下资料看下。
- 当ViewPager处于滑动状态时候,本次message无效,然后重新发送一次新的delay message事件。
- 收到有效的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
}
总结
- Banner其实就是利用Pager的滑动机制实现的。主要是如何处理好多次创建布局所带来的资源损耗,所以我这里想到可以复用已经被container remove的itemView,这样子就无需多次创建布局。
- 图片轮播上可以采用Android timer或者是Handler,我这里实现是用了Handler与message的特性去实现的,如果有兴趣也可以将起该成timer来实现。