洋蔥數學同款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
看下最終手感的效果圖: ide
踩坑過程:佈局
線性Scroller實例
替換了ViewPager的mScroller
實例,由於以每五秒定時切換一頁,且mScroller
執行一次動畫設置爲五秒,這樣看起來就實現了『從右向左勻速移動的效果』...DOWN
UP
等不斷切換mScroller
的實例,以保持手指切換時的手感...RecyclerView
),性能問題、卡頓Bug就來了!ViewPager
很差處理畫廊效果(我不喜歡clipChildren的方式)因而:post
成長總結:性能
NOTE:動畫
PagerSnapHelper
的原理部分不在範圍內,但在我拜讀的文章中貼出了一份連接,你們可自行食用。前路漫漫,咱們先梳理下需求:ui
🤩這麼多需求,不要怕,咱們根據需求來理一遍核心技術點:
平滑滾動模式
可使用RecyclerView+PagerSnapHelper
實現,間隔滾動模式
能夠繼續使用ViewPager實現,也可使用前者方式實現。(本文統一使用RecyclerView+PagerSnapHelper
方式,不過代碼中也留出了接口,可用ViewPager作實現)Xfermode
作裁剪合成便可。(該方式在以前的文章ShadowLayout中使用過,故本文再也不贅述)這樣一來,實現咱們想要的BannerView只是耐心+時間的問題了。如下,我會挑本次實現中重要的幾點來作說明,以下:
看名字便知這是一個用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
較爲關鍵的點,其它部分均爲業務邏輯的處理與實現,你們可打開源碼自行食用。
這裏採用了工廠方法模式來建立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
}
複製代碼
解耦的慣用套路就是抽象方法定義接口。因此咱們定義了兩個接口,一個是指示器實例需實現的接口,一個是指示器依賴的外部實現。因此使用這兩個接口,能夠自定義實現想要的樣式。
/** * 指示器實例需實現的接口 */
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哈哈~
我的能力有限,若有不正之處歡迎你們批評指出,我會虛心接受並第一時間修改,以不誤導你們。