【自定義View】洋蔥數學同款Banner的進化-BannerView

開箱即用的源碼地址

洋蔥數學同款BannerViewjava

支持XML自定義屬性:git

  • bv_viewHeight:Banner視圖區域的高度,小於等於0時爲該佈局的高度
  • bv_viewCornerRadius:視圖區域圓角的半徑
  • bv_itemViewWidthRatio:根據該佈局寬度的百分比設置ItemView的寬度
  • bv_itemViewMargin:設置ItemView之間的間距
  • bv_intervalInMillis:Banner輪換時間(在SMOOTH模式下爲Banner從右勻速到左的時間)
  • bv_pageHoldInMillis:手指滑動後,頁面停留的時長(只在SMOOTH模式下生效)
  • bv_scrollMode:設置Banner滾動模式
    • INTERVAL:間隔切換模式
    • SMOOTH:勻速滾動模式
  • bv_itemViewAlign:ItemView與父WrapperView的對齊方式(決定了itemViewMargin的留白位置)
    • CENTER_HORIZONTAL:水平居中
    • ALIGN_PARENT_LEFT:居左對齊
    • ALIGN_PARENT_RIGHT:居右對齊

暴露的API有:github

  • setBannerViewImpl(impl: IBannerView):設置Banner必須的實現類
  • startAutoScroll():開始自動滾動(頁面數量小於1時不會滾動)
  • stopAutoScroll():中止自動滾動
/** * 定義頁面切換回調 */
interface OnPageChangeListener {
    fun onPageSelected(position: Int)
}

interface IBannerViewBase {
    fun getCount(): Int

    fun getItemView(context: Context): View

    fun onBindView(itemView: View, position: Int)
}

/** * BannerView依賴的外部實現 */
interface IBannerView : OnPageChangeListener, IBannerViewBase {

    /** * 當count爲0時的默認view */
    fun getDefaultView(context: Context): View? {
        return null
    }

    /** * 默認關閉自動滾動 */
    fun isDefaultAutoScroll(): Boolean {
        return false
    }

    override fun onPageSelected(position: Int) {}

}
複製代碼

起源

關於我接手我司Banner控件後的故事大概是這樣:app

  1. 數月前UI大佬要從新定義Banner樣式,其中指示器由原點變爲了小橫杆,而且位於Banner的正下方必定距離。當時業務緊迫,用了一天多的時間匆忙基於ViewPager封裝了出來,此時指示器耦合在Banner中。
  2. 以後UI大佬爲了擠出一點頁面空間,要求在某條件下,指示器小橫杆要放進Banner內部,距底部必定距離。又是業務緊迫,用臨時代碼解決了需求。
  3. 終於業務側騰出了時間,我趕忙把BannerView重構了,其中解耦了指示器、處理了臨時代碼、整理歸置代碼等。
  4. 然後沒多久,UI大佬發話說,要從新定義Banner樣式...其中頁面切換方式由常見ViewPager樣式改到了要『從右向左勻速移動的效果』,而且要『手指滑動時要跟ViewPager同樣的切換手感』,而且要『一屏多顯示的畫廊效果』,而且要『頁面之間要有間距』。
  5. 因而我就開啓了一次踩坑與成長之旅,其中滋味,『真香』!

看下最終手感的效果圖: ide

踩坑過程:佈局

  1. 畢竟以前基於ViewPager的Banner已經封裝好了,並且考慮到『手指滑動時要跟ViewPager同樣的切換手感』,我第一反應就是基於以前的代碼去作擴展,這樣就巧妙的避開了處理切換手感的問題。
  2. 專一解決『從右向左勻速移動的效果』,因而我經過反射的方式用線性Scroller實例替換了ViewPager的mScroller實例,由於以每五秒定時切換一頁,且mScroller執行一次動畫設置爲五秒,這樣看起來就實現了『從右向左勻速移動的效果』...
  3. 固然我還經過手勢事件DOWN UP等不斷切換mScroller的實例,以保持手指切換時的手感...
  4. 以上雖然看起來解決了動效問題。對!它只是看起來解決了問題,但當把這樣子的Banner嵌入到列表中時(RecyclerView),性能問題、卡頓Bug就來了!
  5. 而且ViewPager很差處理畫廊效果(我不喜歡clipChildren的方式)

因而:post

  1. 因而我痛定思痛決心重寫底層實現。
  2. 因而我帶着最不想處理的『切換手感問題』去查解決方案。
  3. 因而我打開『小飛機』、打開『Chrome』、輸入『RecyclerView實現ViewPager』。
  4. 因而我領會到玉皇大帝給你關上一扇門就會給你打開一扇窗。這扇窗即是PagerSnapHelper

成長總結:性能

  1. 以上是一種極其失敗的解決方案!
  2. 不要採用『看起來能解決』的解決方案!
  3. 當你以爲實現方案就不常規、不暢、不合理的時候,大機率這方案不可用!
  4. 當你須要一個解決方案的時候,不妨先跟同事聊聊、google一下試試,說不定有意外收穫!

思考分析

NOTE動畫

  1. 這篇文章咱們專一於BannerView的封裝與實現,關於更底層的PagerSnapHelper的原理部分不在範圍內,但在我拜讀的文章中貼出了一份連接,你們可自行食用。

前路漫漫,咱們先梳理下需求:ui

  1. 要支持兩種滾動模式,間隔切換、平滑滾動
  2. 要支持設置視圖區域圓角
  3. 要支持設置條目視圖圓角(ItemView)(該需求本次未作實現,下文會自動忽略該需求)
  4. 要支持無限循環滾動
  5. 要支持根據BannerView的寬的比值設置ItemView的寬
  6. 要支持設置ItemView之間的間距
  7. 要支持設置滾動間隔,勻速模式要支持設置滾動一頁的時間
  8. 要支持設置勻速模式下,手指滑動後,頁面停留的時長
  9. 要支持設置ItemView與父WrapperView的對齊方式(決定了itemViewMargin的留白位置)
  10. 要支持設置默認是否開啓滾動
  11. 要支持設置數據源爲空時的默認View
  12. 要支持數據源只有1張banner時,禁止滾動
  13. 要暴露API控制Banner的自動滾動與暫停
  14. 要支持設置指示器(Indicator),且能靈活控制指示器位置,且與BannerView解耦

🤩這麼多需求,不要怕,咱們根據需求來理一遍核心技術點:

  1. 平滑滾動模式可使用RecyclerView+PagerSnapHelper實現,間隔滾動模式能夠繼續使用ViewPager實現,也可使用前者方式實現。(本文統一使用RecyclerView+PagerSnapHelper方式,不過代碼中也留出了接口,可用ViewPager作實現)
  2. 設置圓角仍是採用Xfermode作裁剪合成便可。(該方式在以前的文章ShadowLayout中使用過,故本文再也不贅述)
  3. 需求[4]將adpter中getItemCount()返回Int.MAX_VALUE,再在綁定View時候,用當前的position與真實count求餘數,做爲真實的position去綁定數據,便可實現。
  4. 需求[4]到[13],都沒有技術複雜度,但有業務複雜度,作常規實現便可。
  5. 需求[14]可定義Indicator涉及的接口作代碼解耦,並將BannerView繼承RelativeLayout,這樣Indicator做爲子View在xml中可靈活控制位置。

這樣一來,實現咱們想要的BannerView只是耐心+時間的問題了。如下,我會挑本次實現中重要的幾點來作說明,以下:

  1. RecyclerView+PagerSnapHelper實現的PagerRecyclerView
  2. 生成PagerView實例的工廠PagerViewFactory
  3. Indicator的解耦實現

PagerRecyclerView

看名字便知這是一個用RecyclerView實現ViewPager功能的類,因此繼承自RecyclerView。

它做爲BannerView的核心功能實現類,爲了與上層解耦(也就是方便切換爲其它實現,好比用ViewPager作實現)因此定義接口IPagerViewInstance

/** * PagerView功能實例需實現的接口 */
interface IPagerViewInstance {

    /** * 設置自動滾動 * @param intervalInMillis: Int 在INTERVAL模式下爲頁面切換間隔 在SMOOTH模式下爲滾動一頁所需時間 */
    fun startAutoScroll(intervalInMillis: Int)

    /** * 中止自動滾動 */
    fun stopAutoScroll()

    /** * 獲取當前Item的位置(List的索引) */
    fun getCurrentPosition(): Int

    /** * 獲取當前真實的Item的位置(List的索引) */
    fun getRealCurrentPosition(realCount: Int): Int

    /** * 設置平滑模式是否開啓,不然爲間隔切換模式 */
    fun setSmoothMode(enabled: Boolean)

    /** * 設置頁面停留時長 */
    fun setPageHoldInMillis(pageHoldInMillis: Int)

    /** * 設置頁面切換回調 */
    fun setOnPageChangeListener(listener: OnPageChangeListener)

    /** * 通知數據刷新 */
    fun notifyDataSetChanged()
}
複製代碼

關於PagerSnapHelper的使用極其簡單,只需建立出實例,attachToRecyclerView一下,便可讓RecyclerView搖身一變成爲ViewPager同樣。(這裏實在讓人驚歎!!咱們都應該追求這種API的極致設計)

/** * 滑動到具體位置幫助器 */
private var mSnapHelper: PagerSnapHelper = PagerSnapHelper()
... 省略代碼
init {
    mSnapHelper.attachToRecyclerView(this)
    ... 省略代碼
}
複製代碼

關於間隔切換模式 勻速滾動模式的實現主要是在startTimer()方法中,二者的區別在於Timer的間隔時間不一樣、回調中執行的方法不一樣。其中勻速模式的Timer間隔時間須要使用外部設置的滾動一屏的時間一屏的寬度每次scrollBy的距離計算而來。

/** * 開始定時器 */
private fun startTimer() {
    mTimer?.cancel()
    if (mWidth > 0 && mFlagStartTimer && context != null && context is Activity) {
        mTimer = timer(initialDelay = mDelayedTime, period = mPeriodTime) {
            if (mScrollState == SCROLL_STATE_IDLE) {
                (context as Activity).runOnUiThread {
                    if (mSmoothMode) {
                        scrollBy(DEFAULT_PERIOD_SCROLL_PIXEL, 0)
                        triggerOnPageSelected()
                    } else {
                        smoothScrollToPosition(++mOldPosition)
                        mPageChangeListener?.onPageSelected(mOldPosition)
                    }
                }
            }
        }
    }
}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    mWidth = (w - paddingLeft - paddingRight).toFloat()
    mHeight = (h - paddingTop - paddingBottom).toFloat()

    //計算勻速滾動的時間間隔
    if (mSmoothMode) {
        mPeriodTime = (mSmoothSpeed / (mWidth / DEFAULT_PERIOD_SCROLL_PIXEL)).toLong()
    }

    if (mTimer == null) {
        startTimer()
    }
}
複製代碼

頁面選中是根據PagerSnapHelper中提供的findSnapView方法,先找到Snap(就是當前的目標View),再找它的位置,固然還需用一個變量記錄一下,防止屢次觸發回調。

/** * 觸發OnPageSelected回調 */
private fun triggerOnPageSelected() {
    val layoutManager = getLinearLayoutManager()
    val view = mSnapHelper.findSnapView(layoutManager)
    if (view != null) {
        val position = layoutManager.getPosition(view)
        //防止同一位置屢次觸發
        if (position != mOldPosition) {
            mOldPosition = position
            mPageChangeListener?.onPageSelected(position)
        }
    }
}
複製代碼

還有一個值得說道的點是初始化時須要矯正Snap的位置,由於PagerSnapHelper手指滑動的時候才工做讓RecyclerView滑動出ViewPager的感受,因此初始化時不矯正會發現選中的頁面不居中顯示,仍是一個RecyclerView的樣子。那如何矯正呢?這裏去看了PagerSnapHelper實現,搬過來,稍加修改便可。

/** * 矯正首次初始化時SnapView的位置 */
private fun correctSnapViewPosition() {
    val layoutManager = getLinearLayoutManager()
    val snapView = mSnapHelper.findSnapView(layoutManager)
    if (snapView != null) {
        val snapDistance = mSnapHelper.calculateDistanceToFinalSnap(layoutManager, snapView)
        if (snapDistance != null) {
            if (snapDistance[0] != 0 || snapDistance[1] != 0) {
                //咱們把源碼的smoothScrollBy改成scrollBy,這樣視覺上覺察不出矯正過程
                scrollBy(snapDistance[0], snapDistance[1])
            }
            //首次觸發回調
            triggerOnPageSelected()
        }
    }
}

/** * 這是源碼 */
void snapToTargetExistingView() {
    if (this.mRecyclerView != null) {
        LayoutManager layoutManager = this.mRecyclerView.getLayoutManager();
        if (layoutManager != null) {
            View snapView = this.findSnapView(layoutManager);
            if (snapView != null) {
                int[] snapDistance = this.calculateDistanceToFinalSnap(layoutManager, snapView);
                if (snapDistance[0] != 0 || snapDistance[1] != 0) {
                    this.mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
                }
            }
        }
    }
}
複製代碼

以上是我認爲PagerRecyclerView較爲關鍵的點,其它部分均爲業務邏輯的處理與實現,你們可打開源碼自行食用。

PagerViewFactory

這裏採用了工廠方法模式來建立Banner底層的核心實現。

首先定義了BannerView實例接口,它將做爲工廠實例的構造方法參數,用於區分建立底層實現。

interface IBannerViewBase {
    fun getCount(): Int

    fun getItemView(context: Context): View

    fun onBindView(itemView: View, position: Int)
}

/** * 定義BannerView實例接口 */
interface IBannerViewInstance : IBannerViewBase {

    fun getContext(): Context

    fun isSmoothMode(): Boolean

    fun getItemViewWidth(): Int

    fun getItemViewMargin(): Int

    fun getItemViewAlign(): Int
}
複製代碼

工廠有個getPagerView()的方法,來建立Banner核心實現

/** * 工廠根據參數建立對應PagerView實例 */
override fun getPagerView(): IPagerViewInstance {
    return if (bannerView.isSmoothMode()) {
        casePagerRecycler(true)
    } else {
        if (intervalUseViewPager) {
            //這裏能夠根據須要用ViewPager作底層實現
            throw IllegalStateException("這裏未使用ViewPager作底層實現")
        } else {
            casePagerRecycler(false)
        }
    }
}
複製代碼

這裏就是建立了以前寫好的PagerRecyclerView,其實就是建立配置使用一個RecyclerView的過程。

/** * 處理PagerRecyclerView */
private fun casePagerRecycler(isSmoothMode: Boolean): IPagerViewInstance {
    val recyclerView = PagerRecyclerView(bannerView.getContext())
    recyclerView.layoutManager = LinearLayoutManager(bannerView.getContext(), LinearLayoutManager.HORIZONTAL, false)
    recyclerView.adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
        override fun getItemCount(): Int {
            return Int.MAX_VALUE
        }

        override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
            if (!isActivityDestroyed(holder.itemView.context)) {
                val realPos = position % bannerView.getCount()
                bannerView.onBindView(holder.itemView.findViewById(R.id.id_real_item_view), realPos)
            }
        }

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
            val itemWrapper = LayoutInflater.from(parent.context).inflate(
                R.layout.layout_banner_item_wrapper,
                parent,
                false
            ) as RelativeLayout

            //處理ItemViewWrapper的寬
            itemWrapper.layoutParams.width = bannerView.getItemViewWidth() + bannerView.getItemViewMargin()

            //外部實際的ItemView
            val itemView = bannerView.getItemView(parent.context)
            itemView.id = R.id.id_real_item_view
            val ivParams = RelativeLayout.LayoutParams(
                bannerView.getItemViewWidth(),
                ViewGroup.LayoutParams.MATCH_PARENT
            )
            ivParams.addRule(bannerView.getItemViewAlign())

            //添加ItemView到Wrapper
            itemWrapper.addView(itemView, ivParams)
            return object : RecyclerView.ViewHolder(itemWrapper) {}
        }
    }

    //初始化位置
    recyclerView.scrollToPosition(bannerView.getCount() * 100)
    recyclerView.setSmoothMode(isSmoothMode)

    return recyclerView
}
複製代碼

Indicator的解耦實現

解耦的慣用套路就是抽象方法定義接口。因此咱們定義了兩個接口,一個是指示器實例需實現的接口,一個是指示器依賴的外部實現。因此使用這兩個接口,能夠自定義實現想要的樣式。

/** * 指示器實例需實現的接口 */
interface IIndicatorInstance {

    /** * 設置外部實現 */
    fun setIndicator(impl: IIndicator)

    /** * 從新佈局 */
    fun doRequestLayout()

    /** * 從新繪製 */
    fun doInvalidate()

}

/** * 指示器依賴的外部實現 */
interface IIndicator {

    /** * 獲取adapter總數目 */
    fun getCount(): Int

    /** * 獲取當前選中頁面的索引 */
    fun getCurrentIndex(): Int

}
複製代碼

對於咱們此次實現的CrossBarIndicator,它就是一個常規的自定義View,這裏已沒有什麼好說的啦。重點要說的是需求中有一條且能靈活控制指示器位置,如何實現呢?需求分析時說了,咱們的BannerView是一個RelativeLayout,Indicator做爲其子View能夠很方便的控制其位置。

而後,看下BannerView中的關鍵代碼:

override fun onFinishInflate() {
    super.onFinishInflate()
    findIndicator()
}

/** * 在子View中找到指示器 */
private fun findIndicator() {
    for (i in 0 until childCount) {
        val child = getChildAt(i)
        if (child is IIndicatorInstance) {
            //佈局填充完畢時,找到子View中的Indicator,並保存下來
            mIndicator = child
            return
        }
    }
}

/** * 初始化view */
private fun initView() {
    if (mBannerViewImpl != null && mWidth > 0) {
        val bvImpl = mBannerViewImpl!!
        removeAllViews()

        ... 省略代碼

        //初始化指示器
        if (mIndicator != null) {
            mIndicator?.setIndicator(object : IIndicator {

                override fun getCount(): Int {
                    return bvImpl.getCount()
                }

                override fun getCurrentIndex(): Int {
                    return mPagerViewInstance.getRealCurrentPosition(bvImpl.getCount())
                }

            })
            //把指示器再添加回去
            addView(mIndicator as View)
        }
    }

}
複製代碼

文末

到這裏總體要說的就完結了,整個BannerView的實現細節、邏輯仍是不少的,不過複雜度倒沒那麼高,建議食用源碼,若有疑問歡迎留言~ O(∩_∩)O哈哈~

我的能力有限,若有不正之處歡迎你們批評指出,我會虛心接受並第一時間修改,以不誤導你們

拜讀的文章

個人其它文章

相關文章
相關標籤/搜索