Android多效果轮播器/Banner实现,支持无限轮播、自动切换、指示器动画

2019.9.12

已封装成控件扔到GitHub上https://github.com/kjt666/Banner

开篇

接上篇文章动手实现你的ViewPager切换动画

本次内容是利用ViewPager实现画廊效果图片轮播器,画廊效果已经在ViewPager上实现了,那么一个标准的轮播器无外乎下面几点要求:

轮播的无限循环

轮播器中最重要的一点就是能够实现无限循环,让图片首尾相连、流动切换。可是ViewPager并不支持循环这个操作,那么要实现这一点通常有两种办法:

1、ViewPager设置Integer.MAX_VALUE,这种办法比较取巧,实现比较容易。

2、在要循环的图片首尾各插入一张图片,将原本的第一张图片插入到最后一张的位置,将原本的最后一张图片插入到第一张的位置,然后在滑动到第一张和最后一张的位置时,利用ViewPager的setCurrentItem方法,将item设置为原本的第一张和第二张的位置,以此实现视觉上的无限循环。

轮播的定时切换

定时切换可以利用Timer或者Handler实现。

轮播指示器

指示器这个比较简单,原本有多少图片就画多少个点,然后标深当前所展示图片对应的点就是了,不过像我这么有追求的人怎么可能就这么简单完事了呢,所以我加了一个指示器的滑动动画,和ViewPager的滑动同步。

先给大家看看最终实现的效果图

动手实现

道理我都懂,原理也明白,那么要实现这样一个banner,又是ViewPager又是指示器,我需要从何下手?这样我得实现一个ViewGroup了吧?

没错,我们需要实现一个ViewGroup,然后将ViewPager啊、指示器啊全都扔进去,组合成一个Banner。不过在写代码前,我先把这个Banner的层次结构图画了出来,有了图在编码,思路更加清晰嘛。

那么本次实现从以下几个步骤进行

1、整体结构布局

2、ViewPager无限循环

3、ViewPager定时切换

4、指示器添加及动画效果实现

整体结构布局

Banner继承自FrameLayout,在构造函数中把先vp和指示器的根布局添加上,指示器的根部局也是frameLayout。别忘了加上ClipChildren属性。

constructor(mContext: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(mContext, attrs, defStyleAttr) {
        clipChildren = false
        mViewPager.clipChildren = false
        //add vp
        addView(mViewPager)
        //add indicators frame
        val params = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL)
        params.bottomMargin = dp2px(5)
        mFrameIndicators.layoutParams = params
        addView(mFrameIndicators)
    }

设置vp的宽度。

@SuppressLint("DrawAllocation")
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
       
        mViewPager.layoutParams = LayoutParams(measuredWidth * 4 / 5, LayoutParams.WRAP_CONTENT, Gravity.CENTER_HORIZONTAL)
       
    }

ViewPager无限循环

实现vp的无限循环第一步,首先对设置给ViewPager的数据源加工一番。

private fun initData(resIds: ArrayList<Int>) {
        resIds.apply {
            //图片列表初识大小
            mInitialImgSize = size
            add(0, last())
            add(0, get(lastIndex - 1))
            add(resIds[2])
            add(resIds[3])
            var img: ImageView
            forEach {
                img = ImageView(context)
                img.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
                img.scaleType = ImageView.ScaleType.FIT_XY
                img.setImageResource(it)
                mImgViews.add(img)
            }
        }
    }

这里为什么我要在首尾各加两张图片而不是一张呢,大家可以先想想,后面会讲到原因。

将我们加工后的数据设置给vp并添加监听,当滑动到加工后的第二页或倒数第二页时,将当前item设置为原第一页或最后一页的位置。

private fun setData2Vp() {
        mAdapter = VpImagesAdapter(mImgViews)
        mViewPager.apply { 
        adapter = mAdapter
        //设置画廊效果动画
        setPageTransformer(true, GalleryTransform())
        offscreenPageLimit = mImgViews.size
        //进场展示原第一页图片
        currentItem = 2
        addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
                //图片列表最后一项索引值
                val lastIndex = mImgViews.lastIndex
                override fun onPageScrollStateChanged(p0: Int) {
                    if (p0 == ViewPager.SCROLL_STATE_IDLE) {
                        when (mViewPager.currentItem) {
                            1 -> mViewPager.setCurrentItem(lastIndex - 2, false)
                            lastIndex - 1 -> mViewPager.setCurrentItem(2, false)
                        }
                    }
                }

                override fun onPageScrolled(p0: Int, p1: Float, p2: Int) {
                    mCurrentPosition = p0
                }

                override fun onPageSelected(p0: Int) {
                }
            })
}

写到这可以运行一下看看效果,然后把加工数据时的添加两张改为一张再运行看看就知道为什么加两张了,因为我们的画廊效果可以看到当前两端的图片,当只添加一张图时,滑动到两端图片做以上代码隐式切换时,会有一个明显的切换效果。

添加两张图片可以去除掉这种明显的切换效果,可是这又带来了另一个问题:本应滑动到第二页和倒数第二页时会进行的切换操作,会由于滑动过快滑到第一页或最后一页而没有执行。

针对这个问题,可以在vp滑动时拦截它的触摸事件,停止滑动时恢复。这样我们的隐藏切换就一定会执行了。

override fun onPageScrollStateChanged(p0: Int) {
                    if (p0 == ViewPager.SCROLL_STATE_SETTLING)
                        mIntercept = true
                    else if (p0 == ViewPager.SCROLL_STATE_IDLE) {
                        mIntercept = false
                    }
                }
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        return mIntercept
    }

ViewPager定时切换

vp的定时切换,可以用Timer或者Handler,考虑到Banner在看不见的情况下,不用再一直切换,所以实现两个开关方法。

延时三秒启动,间隔三秒切换。

fun startLoop() {
        mTimer = Timer()
        mTimer.schedule(object : TimerTask() {
            override fun run() {
                mViewPager.currentItem = mCurrentPosition + 1
                if (mCurrentPosition + 1 == 7)
                    mCurrentPosition = 2
            }
        }, 3000, 3000)
    }

    fun stopLoop() {
        mTimer.cancel()
    }

ok,运行一次看看效果

好像切换动画执行的太快了,能不能让动画的切换时间延长一点?因为滑动都是通过Scroller这个类来控制,然后我在vp的源码smoothScrollTo这个方法里,发现了他设置的执行时间

 duration = Math.min(duration, 600);
this.mScroller.startScroll(sx, sy, dx, dy, duration);

它的执行时间是一个动态的,最高为600毫秒,然后通过startScroll方法开始执行。因为这个duration是个局部变量,不能拿到,所以我们自己实现一个Scroller,重写startScroll这个方法。再通过反射vp修改mScroller。

class MyScroller:Scroller {

    var scrollDuration : Long = 0

    constructor(context: Context):super(context)

    constructor(context: Context,interpolator: Interpolator):super(context,interpolator)

    override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int) {
        super.startScroll(startX, startY, dx, dy, scrollDuration.toInt())
    }

    override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, duration: Int) {
        super.startScroll(startX, startY, dx, dy, scrollDuration.toInt())
    }

}

Banner中添加方法,并设置动画执行时间默认为1秒。

private fun setVPScrollSpeed(duration: Long = 1000) {
        val field: Field = ViewPager::class.java.getDeclaredField("mScroller")
        field.isAccessible = true
        val scroller = MyScroller(context)
        scroller.scrollDuration = duration
        field.set(mViewPager, scroller)
    }

现在再来看看,执行时间有没有变化

指示器添加及动画效果实现

终于要完成了,写这么多字也挺累的。。指示器添加挺容易的,动态生成几个view再add进去就好,反倒是这个指示器动画让我想了挺久。。本以为最容易的却花了最多的时间调试

看客们可以先看看效果图,想一下这个效果怎么实现再往下看。指示器跟随vp同步滑动,并在最后一张切换到第一张时滑动到开始位置,第一张切换到最后一张时滑动到结尾位置。

在动画之前先把指示器加上,线性布局内水平排放灰色小点,在之上叠加红色小点构成指示器。

private fun addIndicators() {
        //add indicators ll in frame
        mLlIndicators.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
        mLlIndicators.orientation = LinearLayout.HORIZONTAL
        var indicator: ImageView
        for (i in 0 until mInitialImgSize) {
            indicator = ImageView(context)
            indicator.setImageResource(R.mipmap.dot_gray)
            indicator.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
            mLlIndicators.addView(indicator)
        }
        mFrameIndicators.addView(mLlIndicators)
        //add red indicator in frame
        mRedIndicator.setImageResource(R.mipmap.dot_red)
        mRedIndicator.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
        mFrameIndicators.addView(mRedIndicator)
    }

再来就是动画的代码了,在vp的OnPageChangeListener内的onPageScrolled方法内实现。p1是偏移量百分比,在[0,1]在各区间,左滑是从0到1递增,右滑时从1到0递减。有position和百分比,指示器动画正好可以拿来用。代码具体我就不解释了,本人苍白的语言在此时相形见绌。。。你们看着想一下就能理解了。

//p0是positon,p1是偏移量百分比,p2是偏移量像素
override fun onPageScrolled(p0: Int, p1: Float, p2: Int) {
        mCurrentPosition = p0
        if (p1 != 0.toFloat()) {
           if (p0 > 1 && p0 < lastIndex - 2)
              mRedIndicator.translationX = (p1 * mRedIndicator.measuredWidth) + ((mCurrentPosition - 2) * mRedIndicator.measuredWidth)
           else if (p0 == lastIndex - 2 || p0 == 1)
              mRedIndicator.translationX = ((1 - p1) * (mFrameIndicators.measuredWidth - mLlIndicators.getChildAt(0).measuredWidth))
  }
}

2019.9.9

在对Banner进行封装的时候发现了两个可以优化的地方,一是上面提到的滑动过快导致隐藏切换没有执行的问题,二是滑动后图片轮播顺序错乱的问题。

第一个问题很好解决,添加对页数的特殊处理即可。

when (mViewPager.currentItem) {
      0 -> mViewPager.setCurrentItem(lastIndex - 3, false)
      1 -> mViewPager.setCurrentItem(lastIndex - 2, false)
      lastIndex - 1 -> mViewPager.setCurrentItem(2, false)
      lastIndex -> mViewPager.setCurrentItem(3, false)
}

第二个问题产生的原因在于Timer发出的延时任务是在你手动切换之前或同时发出的,所以当你从第2张切换到第5张时,之前发出的延时任务又会使Banner自动切换到第3张,这就需要我们在手动切换时清除掉之前发出的延时任务,这就需要有一个队列来提供管理这些任务,Handler可以很好地解决这点。

var handlerr = Handler(Handler.Callback { msg ->
    mViewPager.currentItem = msg.what
    true
})
fun startLoop() {
    handlerr.sendEmptyMessageDelayed(mViewPager.currentItem + 1, mDuration)
}

fun stopLoop() {
    handlerr.removeCallbacksAndMessages(null)
}

ok,现在我只需要考虑在哪个地方清除掉多余的任务。一开始我想到了使用事件拦截来做处理,但随即想到了另一种更好地方式,监听vp的滑动状态,为拖动时remove掉handler所有的任务,静止时发出延时切换任务,这样涉及Banner切换的核心代码也都能集中在一个地方。

所以本次优化的代码都集中在了vp的滑动监听中

val lastIndex = mImgViews.lastIndex
                override fun onPageScrollStateChanged(p0: Int) {
                    if (p0 == ViewPager.SCROLL_STATE_DRAGGING) {
                        handlerr.removeCallbacksAndMessages(null)
                    } else if (p0 == ViewPager.SCROLL_STATE_IDLE) {
                        //当手滑动到我们后期添加的页面时,切换到原顺序对应图片
                        when (mViewPager.currentItem) {
                            0 -> mViewPager.setCurrentItem(lastIndex - 3, false)
                            1 -> mViewPager.setCurrentItem(lastIndex - 2, false)
                            lastIndex - 1 -> mViewPager.setCurrentItem(2, false)
                            lastIndex -> mViewPager.setCurrentItem(3, false)
                        }
                        handlerr.sendEmptyMessageDelayed(mViewPager.currentItem + 1, mDuration - 1000L)
                    }
                }

结尾

刚开始想着完成一个Banner难度应该不大,但是开发的过程中发现要思考的东西可不少,而且需要一定的知识储备,从无到有,从零到一,开发的过程也是不断学习的过程,荒废了好久的自定义控件相关知识又拾了起来,完成的那一刻还是蛮开心的,希望自己可以不断进步不断成长吧,不管是哪方面的~

最后希望大家可以对本次内容多提意见和建议~

发布了45 篇原创文章 · 获赞 18 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/Ever69/article/details/93471943