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難度應該不大,但是開發的過程中發現要思考的東西可不少,而且需要一定的知識儲備,從無到有,從零到一,開發的過程也是不斷學習的過程,荒廢了好久的自定義控件相關知識又拾了起來,完成的那一刻還是蠻開心的,希望自己可以不斷進步不斷成長吧,不管是哪方面的~

最後希望大家可以對本次內容多提意見和建議~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章