一個(gè)基于ViewPager2與Paging3的無(wú)限輪播Banner

一、已有方案分析

在項(xiàng)目中經(jīng)常有無(wú)限輪播 Banner 的需求, 用過(guò)別人開(kāi)源的, 也有自己基于 ViewPager2 寫過(guò)類似的模塊, 無(wú)限輪播據(jù)我所知的有兩種思路:

  1. 在原始數(shù)據(jù)的首尾各添加一個(gè)(第一個(gè)為原始數(shù)據(jù)最后一個(gè), 最后一個(gè)為原始數(shù)據(jù)第一個(gè)), 然后輪播到末尾時(shí)偷偷切換到原始的第一個(gè), 用戶滑動(dòng)到第一個(gè)后則偷偷跳到原始的最后一個(gè);

  2. 在適配器中聲明數(shù)據(jù)數(shù)量為 Int.MAX_VALUE , 然后在一開(kāi)始時(shí)切換到 Int.MAX_VALUE / 2 , 當(dāng)需要獲取數(shù)據(jù)時(shí)則通過(guò)虛擬的 position 對(duì)原始數(shù)據(jù)數(shù)量取余來(lái)獲取真實(shí)的數(shù)據(jù)的 index, 從而取得真實(shí)的數(shù)據(jù).

這兩種方案都能實(shí)現(xiàn)無(wú)限輪播, 但是實(shí)際上還是不夠優(yōu)雅, 例如第一個(gè)方案中, 用戶在持續(xù)滑動(dòng)不松手的情況下, 還是有可能夠到邊界(可以多加幾個(gè)假數(shù)據(jù)來(lái)規(guī)避), 而第二個(gè)方案則要進(jìn)行繁瑣的虛擬位置映射, 調(diào)試起來(lái)也挺費(fèi)心思.

那么有沒(méi)有什么更加優(yōu)雅的解決方案呢?

在使用過(guò) Paging3 進(jìn)行分頁(yè)加載后, 我發(fā)現(xiàn)它不僅能夠做到向后加載, 也能夠做到向前加載, 而它提供了一個(gè)可以用在 RecyclerView 的適配器, 巧的是, ViewPager2 正是基于 RecyclerView 來(lái)實(shí)現(xiàn)的, 可以接受這個(gè)適配器!

理論存在, 那么就開(kāi)始實(shí)踐!

注: 在此前未使用過(guò) Paging3 的同學(xué), 請(qǐng)務(wù)必前往官網(wǎng)了解一下這個(gè)框架, 官網(wǎng)提供了中文文檔, 介紹得非常詳細(xì)! 文檔鏈接

二似踱、制定目標(biāo)

在開(kāi)始編碼前, 最好先定下本次編碼的目標(biāo), 目標(biāo)清晰了才能做到有的放矢.

在經(jīng)過(guò)思考之后, 我定下了這些目標(biāo), 如果這些目標(biāo)與你需要的一致, 那么也許你可以參考這個(gè)方案.

目標(biāo):

  1. 支持使用 Layout XML 配置;

  2. 能夠?qū)崿F(xiàn)無(wú)限輪播;

  3. 應(yīng)該具備生命周期感知能力, 在 Resume 后開(kāi)始輪播, 在 Pause 后自動(dòng)停止輪播;

  4. 用戶觸摸 Banner 時(shí), 應(yīng)停止輪播;

  5. 使用 Paging3 為 BannerView 提供數(shù)據(jù), 將有限的數(shù)據(jù)映射為無(wú)限;

  6. 在系統(tǒng)發(fā)生 Configuration Change (配置變更) 時(shí), 能夠保存與恢復(fù)狀態(tài)(例如: 白天黑夜模式切換/ 橫豎屏切換等);

  7. 能夠響應(yīng)數(shù)據(jù)數(shù)量變化, 動(dòng)態(tài)更新 BannerItem ;

  8. 能夠作為 Item Header (頭部Item) , 嵌入 RecyclerView 中;

  9. 在目標(biāo)8的基礎(chǔ)上, 支持基于條件動(dòng)態(tài)地顯示/隱藏 Banner (例如數(shù)據(jù)為空時(shí)隱藏);

  10. 在目標(biāo)8的基礎(chǔ)上, 如果外部的 RecyclerView 也是基于 Paging3 , 也需要支持作為 Item Header (頭部Item) 嵌入.

三隅熙、開(kāi)始編碼

目標(biāo)1: 支持使用 Layout XML 配置

為了使 BannerView 支持 Layout XML , 需要先創(chuàng)建一個(gè)自定義 ViewGroup , 方便起見(jiàn), 我繼承了 FrameLayout :

class BannerView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
    defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes)

然后編寫一個(gè)xml, 創(chuàng)建一個(gè)ViewPager2:

<?xml version="1.0" encoding="utf-8"?>
<androidx.viewpager2.widget.ViewPager2
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/vpBanner"
    android:saveEnabled="true"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:nestedScrollingEnabled="true"
    android:orientation="horizontal" />

將 xml 通過(guò) inflate 置入到 BannerView 中, 作為它的子View:

private val binding: ViewBannerBinding by lazy {
    ViewBannerBinding.inflate(
        LayoutInflater.from(context),
        this,
        true
    )
}

目標(biāo)2: 能夠?qū)崿F(xiàn)無(wú)限輪播

關(guān)于目標(biāo)2, 我準(zhǔn)備采用 Kotlin Coroutins(協(xié)程) 來(lái)實(shí)現(xiàn).

無(wú)限輪播功能非常簡(jiǎn)單, 只需在協(xié)程中創(chuàng)建一個(gè)無(wú)限循環(huán), 不停地切換到下一個(gè)即可, 因?yàn)榧磳⒁?Paging3 , 所以事實(shí)上 Banner Item 的位置是能夠自動(dòng)對(duì)應(yīng)到具體數(shù)據(jù)的.

為了能夠在 xml 中配置是否啟用無(wú)限輪播以及輪播時(shí)間間隔, 需要引入一些 Attribute :

./res/values/attrs

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="BannerView">
        <!--是否啟用自動(dòng)輪播-->
        <attr name="autoSwipe" format="boolean" />
        <!--自動(dòng)輪播時(shí)間間隔-->
        <attr name="swipeInterval" format="integer" />
    </declare-styleable>
</resources>

讀取使用者的屬性配置, 并提供用于代碼控制的接口:

companion object {
    val MIN_SWIPE_INTERVAL = 1.seconds // 最小輪播時(shí)間間隔
    val DEFAULT_SWIPE_INTERVAL = 5.seconds // 默認(rèn)輪播時(shí)間間隔
}

private val _autoSwipe = MutableStateFlow(true)

var autoSwipe: Boolean
    get() = _autoSwipe.value
    set(auto) = _autoSwipe.update { auto }

private val _swipeInterval = MutableStateFlow(DEFAULT_SWIPE_INTERVAL)

var swipeInterval: Duration
    get() = _swipeInterval.value
    set(duration) = _swipeInterval.update { duration }

init {
    context.obtainStyledAttributes(
        attrs,
        R.styleable.BannerView
    ).let { ta ->
        // 自動(dòng)滑動(dòng)開(kāi)關(guān)
        ta.getBoolean(
            R.styleable.BannerView_autoSwipe,
            true
        ).also { enable ->
            _autoSwipe.update { enable }
        }

        // 自動(dòng)滑動(dòng)間隔
        ta.getInt(
            R.styleable.BannerView_swipeInterval,
            DEFAULT_SWIPE_INTERVAL.inWholeMilliseconds.toInt()
        ).milliseconds.also { duration ->
            _swipeInterval.update {
                when {
                    duration > MIN_SWIPE_INTERVAL -> duration
                    else -> MIN_SWIPE_INTERVAL
                }
            }
        }
        ta.recycle()
    }
}

最后編寫一個(gè)方法, 讓它在合適的時(shí)機(jī)可以開(kāi)始輪播:

private suspend fun loop(interval: Duration) {
    while (true) {
        delay(interval)
        val curr = binding.vpBanner.currentItem
        binding.vpBanner.currentItem = curr + 1
    }
}

目標(biāo)3: 應(yīng)該具備生命周期感知能力, 在 Resume 后開(kāi)始輪播, 在 Pause 后自動(dòng)停止輪播

目標(biāo)2實(shí)現(xiàn)了無(wú)限輪播, 但是什么時(shí)候啟動(dòng)它呢? 答案是在 Resume 的時(shí)候!

為了讓 BannerView 能夠具備生命周期感知能力, 我需要為它成為一個(gè) LifecycleOwner 并在相關(guān)事件產(chǎn)生時(shí)改變生命周期狀態(tài).

關(guān)于生命周期, 可以查看 Jetpack Lifecycle 了解詳情.

在目標(biāo)1中, 我們簡(jiǎn)單地將 BannerViw 繼承了 FrameLayout , FrameLayout 不具備生命周期感知能力, 為了讓它具有這種能力, 我需要改造一下它.

普通的View自身其實(shí)能夠感知大部分的生命周期事件, 例如:

  • 構(gòu)造方法: Lifecycle.Event.ON_CREATE

  • onAttachedToWindow: Lifecycle.Event.ON_START

  • onWindowVisibilityChanged#VISIBLE: Lifecycle.Event.ON_RESUME

  • onWindowVisibilityChanged#INVISIBLE/GONE: Lifecycle.Event. ON_PAUSE

  • onDetachedFromWindow: Lifecycle.Event.ON_STOP

唯獨(dú) Lifecycle.Event.ON_DESTROY 無(wú)法自行感知, 不過(guò)管理 View 的控制器 Activity / Fragment 可以為它提供此事件, 所以我定義了一個(gè)接口(withLifecycle)用于控制器將 Lifecycle 傳入, 用于 BannerView 同步控制器的銷毀事件:

open class LifecycleFrameLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
    defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes), LifecycleOwner {

    private val lifecycleRegistry by lazy { LifecycleRegistry(this) }
    override val lifecycle: Lifecycle get() = lifecycleRegistry

    init {
        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
    }

    @CallSuper
    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
    }

    @CallSuper
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
    }

    override fun onWindowVisibilityChanged(visibility: Int) {
        val event = when (visibility) {
            VISIBLE -> Lifecycle.Event.ON_RESUME
            else -> Lifecycle.Event.ON_PAUSE
        }
        lifecycleRegistry.handleLifecycleEvent(event)
    }

    fun withLifecycle(controllerLifecycle: Lifecycle) {
        controllerLifecycle.addObserver(object : DefaultLifecycleObserver {
            override fun onDestroy(owner: LifecycleOwner) {
                lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
            }
        })
    }
}

這里使用者需要注意的是: 如果控制器是 Activity , 則直接通過(guò) withLifecycle 傳入 lifecycle 即可. 但是如果控制器為 Fragment, 則需要傳入 viewLifecycleOwner.lifecycle .

接下來(lái)將 BannerView 的父類更改為 LIfecycleFrameLayout 即可讓它也擁有生命周期感知能力:

class BannerView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
    defStyleRes: Int = 0,
) : LifecycleFrameLayout(context, attrs, defStyleAttr, defStyleRes)

一旦擁有了生命周期感知能力, 我們就可以很方便地利用 repeatOnLifecycle(Lifecycle.State.RESUMED) 在可見(jiàn)的時(shí)候開(kāi)始輪播, 不可見(jiàn)的時(shí)候停止輪播:

init {
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.RESUMED) {
            combine(
                _autoSwipe,
                _swipeInterval,
                ::Pair
            ).collectLatest { (autoSwipe, swipeInterval) ->
                when {
                    !autoSwipe -> Unit
                    else -> loop(swipeInterval)
                }
            }
        }
    }
}

此處需要注意的是, 必須要使用 collectLatest 而不是 collect 來(lái)收集狀態(tài)變化, 否則無(wú)限循環(huán)不會(huì)被取消!

目標(biāo)4: 用戶觸摸 Banner 時(shí), 應(yīng)停止輪播

這個(gè)目標(biāo)也很簡(jiǎn)單, 只需要在觸摸事件分發(fā)到 BannerView 時(shí), 根據(jù) Action 來(lái)修改觸摸狀態(tài):

private val touching = MutableStateFlow(false)

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    val p = parent
    require(p is ViewGroup)
    ev?.let {
        when (it.action) {
            MotionEvent.ACTION_DOWN -> touching.update { true }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> touching.update { false }
        }
    }
    return super.dispatchTouchEvent(ev)
}

然后稍微修改目標(biāo)3中控制輪播的收集源, 將 touching 狀態(tài)也添加到狀態(tài)源中:

init {
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            combine(
                touching,
                _autoSwipe,
                _swipeInterval,
                ::Triple
            ).collectLatest { (touching, autoSwipe, swipeInterval) ->
                when {
                    touching -> Unit
                    !autoSwipe -> Unit
                    else -> loop(swipeInterval)
                }
            }
        }
    }
}

目標(biāo)5: 使用 Paging3 為 BannerView 提供數(shù)據(jù), 將有限的數(shù)據(jù)映射為無(wú)限

此處是整個(gè)功能的核心, 假設(shè)我們的原始數(shù)據(jù)的數(shù)量為4個(gè), 我們要怎樣將這些數(shù)據(jù)擴(kuò)展到無(wú)限個(gè)呢?

Paging3 可以提供了根據(jù) PageKey 來(lái)加載分頁(yè)的能力, 為了實(shí)現(xiàn)無(wú)限的數(shù)據(jù), 每一頁(yè)我們都將原始數(shù)據(jù)稍加處理然后作為一個(gè) Page 返回, 只要 PageKey 足夠多, 那么就可以達(dá)到無(wú)限分頁(yè)的效果了!

而 PageKey 可以是任何值, 只要每個(gè)分頁(yè)的 key 不一樣即可, 我們簡(jiǎn)單地用遞增/遞減的 Int 值來(lái)提供 key.

在處理數(shù)據(jù)源之前, 我們需要考慮是否可以使用原始數(shù)據(jù), 因?yàn)?RecyclerView 會(huì)使用一個(gè)名為 DiffUtil 的工具來(lái)判斷 Item 的差異, 而我們的列表中的數(shù)據(jù)是基于原始數(shù)據(jù)列表擴(kuò)展而來(lái), 這可能會(huì)帶來(lái)一些問(wèn)題, 所以我決定將原始數(shù)據(jù)包裝一下, 讓每個(gè)數(shù)據(jù)跟當(dāng)前的頁(yè)面產(chǎn)生一些聯(lián)系從而將每個(gè)數(shù)據(jù)區(qū)分開(kāi)來(lái), 要做到這點(diǎn)很簡(jiǎn)單, 只要將原始數(shù)據(jù)丟進(jìn)這個(gè)數(shù)據(jù)類中即可:

data class BannerData<T>(
    val id: String,
    val data: T,
)

然后提供一個(gè)比較器用于比較數(shù)據(jù)是否相同:

data class BannerData<T>(...) {

    class Comparator<T> : DiffUtil.ItemCallback<BannerData<T>>() {
        override fun areItemsTheSame(oldItem: BannerData<T>, newItem: BannerData<T>) =
            oldItem.id == newItem.id

        override fun areContentsTheSame(oldItem: BannerData<T>, newItem: BannerData<T>) =
            oldItem == newItem
    }

}

我們需要為原始數(shù)據(jù)重新提供一個(gè) id , 用于區(qū)分不同分頁(yè)中的同一個(gè)數(shù)據(jù), 這個(gè) id 只需要與 PageKey 關(guān)聯(lián)即可.

好了, 準(zhǔn)備妥當(dāng), 接下來(lái)就是數(shù)據(jù)轉(zhuǎn)換的步驟了, 我們繼承 PagingSource<Key : Any, Value : Any> 來(lái)將數(shù)據(jù)進(jìn)行轉(zhuǎn)化:

class BannerPagingSource<Data : Any, DataId>(
    private val list: List<Data>,
    private val dataID: Data.() -> DataId
) : PagingSource<Int, BannerData<Data>>() {

    override suspend fun load(params: LoadParams<Int>) =
        when (list.isEmpty()) {
            true -> LoadResult.Error(DataEmptyException())
            false -> {
                val key = params.key ?: 0 // 起始的PageKey
                val transform = list.map { data ->
                    val id = "$key-${data.dataId()}"
                    BannerData(id, data)
                }
                LoadResult.Page(transform, key - 1, key + 1)
            }
        }
    ....
}

是不是非常驚訝, 竟然如此簡(jiǎn)單!

雖然代碼簡(jiǎn)單, 但是我還想啰嗦地解釋一下:

  1. 第一個(gè)入?yún)?list 就是原始數(shù)據(jù), 我們將每一頁(yè)都視為原始數(shù)據(jù), 所以需要持有它們.

  2. 第二個(gè)參數(shù) dataId , 這是一個(gè)函數(shù), 因?yàn)槲覀儾磺宄紨?shù)據(jù)的 id 是什么類型的, 所以定義了一個(gè)泛型參數(shù) DataId 用來(lái)泛化它, dataId 的作用是在將原始數(shù)據(jù)轉(zhuǎn)化為 BannerData 的時(shí)候, 與同一分頁(yè)中的其它數(shù)據(jù)做區(qū)分.

  3. 最后, 合成 BannerData 時(shí), 提供的 id 即 "$key-${data.dataId()}" , 它能將每一頁(yè)中相同的數(shù)據(jù)區(qū)分開(kāi)來(lái).

接下來(lái), 就是將數(shù)據(jù)源轉(zhuǎn)化為數(shù)據(jù)流, 從而提供給適配器, 這里需要你為你的控制器 (Activity / Fragment) 創(chuàng)建一個(gè) ViewModel, 在里面將從上游接收到的原始數(shù)據(jù)包裝為可以提供給 Paging3 適配器的數(shù)據(jù)流 :

class BannerVm : ViewModel() {
    private val repo = BannerRepository()

    private val sourceStateFlow = repo.bannerListFlow.scan(
        null as BannerPagingSource<Banner, Long>?
    ) { lastSource, list ->
        lastSource?.invalidate()
        BannerPagingSource(list, Banner::id)
    }.filterNotNull().stateIn(
        viewModelScope,
        SharingStarted.Eagerly,
        BannerPagingSource(emptyList(), Banner::id)
    )

    val pagingDataFlow = Pager(PagingConfig(1)) {
        sourceStateFlow.value
    }.flow.cachedIn(viewModelScope)
}

回到 BannerView 中, 為了將數(shù)據(jù)流設(shè)置到 ViewPager2 中, 我們最好將 ViewPager2 的 Adapter 設(shè)置與獲取接口對(duì)外暴露:

var adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>?
    get() = binding.vpBanner.adapter
    set(value) {
        binding.vpBanner.adapter = value
    }

接下來(lái)就是常規(guī)地創(chuàng)建 ViewHolder 了, 這個(gè)你可以自由發(fā)揮, 我假定這個(gè) ViewHolder 名為 BannerContentHolder .

然后創(chuàng)建一個(gè)繼承了 PagingDataAdapter<T : Any, VH : RecyclerView.ViewHolder> 的適配器:

class BannerContentAdapter : PagingDataAdapter<BannerData<Banner>, BannerContentHolder>(
    BannerData.Comparator()
) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        BannerContentHolder(parent)

    override fun onBindViewHolder(holder: BannerContentHolder, position: Int) {
        val data = getItem(position)
        holder.refresh(data?.data)
    }
}

最后, 在合適的時(shí)機(jī)將適配器提供給 BannerView, 并且開(kāi)始監(jiān)聽(tīng)數(shù)據(jù)流

val adapter = BannerContentAdapter()
binding.vBanner.adapter = adapter
launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        vm.pagingDataFlow.collectLatest { pagingData ->
            adapter.submitData(pagingData)
        }
    }
}

至此, 一個(gè)基礎(chǔ)的無(wú)限輪播 Banner 就已經(jīng)開(kāi)發(fā)完成!

我放置了幾張圖片在 assets 目錄, 我們來(lái)預(yù)覽一下效果:

基礎(chǔ)的BannerView

在自動(dòng)輪播中途我嘗試拖拽它一段時(shí)間, 它也確實(shí)停止了輪播, 等到我放開(kāi)后它又恢復(fù)了輪播, 符合了 目標(biāo)4 的需求.

目標(biāo)6: 在系統(tǒng)發(fā)生 Configuration Change (配置變更) 時(shí), 能夠保存與恢復(fù)狀態(tài)(例如: 白天黑夜模式切換/ 橫豎屏切換等)

目前這個(gè) BannerView 還不完美, 每次系統(tǒng)配置變更時(shí), 它都會(huì)恢復(fù)為第一頁(yè)(PageKey)的第一張圖片.

這是因?yàn)槊看闻渲米兏? Activity / Fragment 都被銷毀重建了, 我們需要在合適的時(shí)機(jī)將 BannerView 的狀態(tài)保存起來(lái), 在重建后恢復(fù)它.

為此我們需要一個(gè)生命周期能夠覆蓋控制器的對(duì)象來(lái)持有這些狀態(tài), ViewModel 就是一個(gè)很好的容器!

我們之前已經(jīng)為了持有 PagingDataAdapter 創(chuàng)建了一個(gè) ViewModel , 這里我們直接利用它來(lái)存儲(chǔ)狀態(tài).

class BannerVm : ViewModel() {
    ...

    val bannerViewState = SparseArray<Parcelable?>()

}

因?yàn)?BannerView 具備聲明周期感知的能力, 我們直接觀察它的生命周期, 在 Pause 時(shí)保存狀態(tài), 并在 Start 時(shí)嘗試恢復(fù)狀態(tài):

binding.vBanner.lifecycle.addObserver(object : DefaultLifecycleObserver {
    override fun onPause(owner: LifecycleOwner) =
        binding.vBanner.saveHierarchyState(vm.bannerViewState)

    override fun onStart(owner: LifecycleOwner) =
        binding.vBanner.restoreHierarchyState(vm.bannerViewState)
})

現(xiàn)在, BannerView 就能在配置變更后, 保持變更前的狀態(tài)了!

目標(biāo)7: 能夠響應(yīng)數(shù)據(jù)數(shù)量變化, 動(dòng)態(tài)更新 BannerItem

我們?cè)趯?shí)現(xiàn) 目標(biāo)5 的時(shí)候, 遵循了 MVI 模式, 所有的數(shù)據(jù)都是從上游的 Repo 提供的數(shù)據(jù)流轉(zhuǎn)化而來(lái)的, 所以它自然而然能響應(yīng)上游數(shù)據(jù)變化.

你可以測(cè)試一下, 一開(kāi)始提供空的數(shù)據(jù)列表, 過(guò)一段時(shí)間后再提供有效的數(shù)據(jù)列表, BannerView 能夠自動(dòng)更新數(shù)據(jù)~

所以這個(gè)目標(biāo)一不小心就被實(shí)現(xiàn)了, 哈哈.

目標(biāo)8: 能夠作為 Item Header (頭部Item) , 嵌入 RecyclerView 中

接下來(lái)進(jìn)入業(yè)務(wù)領(lǐng)域, 在進(jìn)行應(yīng)用開(kāi)發(fā)的時(shí)候大概率不會(huì)傻傻地放一個(gè) Banner 在界面中, 往往都是嵌入到 RecyclerView 中的.

我們的 BannerView 能不能嵌入 RecyclerView 呢? 讓我們?cè)囈幌?

這塊的業(yè)務(wù)代碼比較多, 我就不貼上來(lái)了. 如果感興趣可直接查看源碼: GitHub

總而言之, 嵌入的 BannerView 可以工作, 但是用戶無(wú)法手動(dòng)拖拽它, 這就又回到那個(gè)經(jīng)典的事件分發(fā)問(wèn)題了, 孩子的事件被父親攔截了.

解決的方法也很簡(jiǎn)單, 在產(chǎn)生 ACTION_DOWN 事件的時(shí)候請(qǐng)求 父 View 不攔截事件即可, 我們稍微修改一下之前的觸摸事件分發(fā)邏輯:

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        val p = parent
        require(p is ViewGroup)
        ev?.let {
            when (it.action) {
                MotionEvent.ACTION_DOWN -> {
                    p.requestDisallowInterceptTouchEvent(true)
                    touching.update { true }
                }

                MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                    p.requestDisallowInterceptTouchEvent(false)
                    touching.update { false }
                }
            }
        }
        return super.dispatchTouchEvent(ev)
    }

這個(gè)操作還是比較粗糙的, 如果你有更精細(xì)化的需求, 可以參考 Google 的解決方案, 但是它是用來(lái)解決 ViewPager2 內(nèi)嵌 RecyclerView 的, 所以你需要稍微修改它的代碼. Google的方案(GitHub)

目標(biāo)9: 在目標(biāo)8的基礎(chǔ)上, 支持基于條件動(dòng)態(tài)地顯示/隱藏 Banner (例如數(shù)據(jù)為空時(shí)隱藏)

現(xiàn)在的 BannerView 已經(jīng)基本夠用了, 但是在用例上還需要繼續(xù)擴(kuò)展: 在某些情況下需要隱藏 BannerView (如: Banner數(shù)據(jù)列表為空).

在這種情況下, 我們需要將數(shù)據(jù)進(jìn)行一次包裝, 讓 Adapter 能夠根據(jù)數(shù)據(jù)來(lái)識(shí)別 ViewType , 我們使用 sealed interface 來(lái)描述這兩種類型的數(shù)據(jù):

sealed interface ItemData

data object BannerItem : ItemData

data class NormalItem(val data: Int) : ItemData

在我們的例子中, Banner 只有一個(gè), 我們只需要為他創(chuàng)建一個(gè)占位的對(duì)象, 方便區(qū)分普通類型與 Banner 類型, 我們直接用 data object 來(lái)聲明它. 如果你有多個(gè) Banner , 那么你需要使用 data class , 然后為不同的 Banner 提供一些信息, 用于綁定數(shù)據(jù)時(shí)選擇不同的數(shù)據(jù)源.

區(qū)分了數(shù)據(jù)后, 就可以在 Adapter 中根據(jù)類型來(lái)處理數(shù)據(jù)了:

class RvAdapter(...) ... {

    override fun getItemViewType(position: Int) =
        when (getItem(position)) {
            BannerItem -> 0
            is NormalItem -> 1
        }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        when (viewType) {
            0 -> TODO("create banner holder")
            else -> TODO("create normal holder")
        }

    override fun onBindViewHolder(holder: RvHolder<*>, position: Int) {
        when (holder) {
            is BannerHolder -> Unit
            is NormalHolder -> {
                val itemData = when (val it = getItem(position)) {
                    is NormalItem -> it
                    else -> null
                }
                holder.refresh(itemData?.data)
            }
        }
    }
}

最后, 根據(jù)上游的提供的信息來(lái)判斷是否應(yīng)該展示 Banner , 合并為最終要使用的數(shù)據(jù)流:

combine(
    bannerVm.bannerVisibility,
    normalItemVm.dataListFlow,
    ::Pair
).map { (visibility, normalList) ->
    when (visibility) {
        false -> normalList.map { data -> NormalItem(data) }
        true -> normalList.fold(
            listOf<ItemData>(BannerItem)
        ) { banner, normalData ->
            banner + NormalItem(normalData)
        }
    }
}.collect { list ->
    rvAdapter.submitList(list) {
        val withBanner = list.any { it is BannerItem }
        if (withBanner) {
            binding.rvList.scrollToPosition(0)
        }
    }
}

同樣的業(yè)務(wù)代碼比較多, 更多細(xì)節(jié)請(qǐng)參考源碼: GitHub

目標(biāo)10: 在目標(biāo)8的基礎(chǔ)上, 如果外部的 RecyclerView 也是基于 Paging3 , 也需要支持作為 Item Header (頭部Item) 嵌入

終于到最后的時(shí)刻了!

這個(gè)目標(biāo)就是我目前開(kāi)發(fā)的項(xiàng)目上的實(shí)際需求了, Paging3 不愧為大廠出品, 考慮了非常多的情況, 例如: 為數(shù)據(jù)流插入頭部/尾部/中間條目, 這些都是考慮周到的, 用起來(lái)非常方便.

在我們的例子中, 我們添加的是一個(gè) Header , 所以我們?cè)谑盏狡胀?Item 的分頁(yè)數(shù)據(jù)后, 再判斷一下是否需要顯示 Banner , 如果需要, 則調(diào)用 insertHeaderItem 來(lái)添加 Header :

combine(
    bannerVm.bannerVisibility,
    loadMoreVm.pagingDataFlow,
    ::Pair
).collectLatest { (visible, paging) ->
    val finalPagingData = when (visible) {
        false -> paging
        true -> paging.insertHeaderItem(
            TerminalSeparatorType.SOURCE_COMPLETE,
            BannerItem
        )
    }
    rvAdapter.submitData(finalPagingData)
    if (visible) binding.rvList.scrollToPosition(0)
}

至此, 所有目標(biāo)均已達(dá)成, 收工.

四稽煤、總結(jié)

Banner 作為一個(gè)非常常見(jiàn)的 UI 組件, 肯定是越簡(jiǎn)單高效越好, 結(jié)合了 Paging3 后, ViewPager2 完全可以實(shí)現(xiàn)這個(gè)目的, 而且由于用的都是官方組件, 穩(wěn)定性有了非常大的保障, 后續(xù)的更新維護(hù)也不會(huì)突然停止, 好處還是很多的.

當(dāng)前這個(gè)模塊其實(shí)跨越了多個(gè)知識(shí)點(diǎn), 包括了:

  • RecyclerView
  • ViewPager2
  • Paging3
  • ViewState
  • ViewModel
  • Lifecycle
  • Coroutines
  • Functional Programming (函數(shù)式編程)

回想幾年之前, 我還在忙忙碌碌地做我的 UI 仔, 以為 Android 的開(kāi)發(fā)就那么些東西, 隨著學(xué)習(xí)的東西越來(lái)越多, 慢慢發(fā)現(xiàn)可以學(xué)的東西也越來(lái)越多, 所以所還是不要放棄學(xué)習(xí)啊!

如果這篇文章介紹的方案能夠?qū)δ阌兴鶐椭? 那就太好了, 謝謝瀏覽到這!

本文的所有代碼, 我都發(fā)布在 GitHub 上了, 需要的話可以去查看.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市囚戚,隨后出現(xiàn)的幾起案子酵熙,更是在濱河造成了極大的恐慌,老刑警劉巖驰坊,帶你破解...
    沈念sama閱讀 217,542評(píng)論 6 504
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件匾二,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡拳芙,警方通過(guò)查閱死者的電腦和手機(jī)察藐,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,822評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)舟扎,“玉大人分飞,你說(shuō)我怎么就攤上這事《孟蓿” “怎么了浸须?”我有些...
    開(kāi)封第一講書人閱讀 163,912評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)邦泄。 經(jīng)常有香客問(wèn)我,道長(zhǎng)裂垦,這世上最難降的妖魔是什么顺囊? 我笑而不...
    開(kāi)封第一講書人閱讀 58,449評(píng)論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮蕉拢,結(jié)果婚禮上特碳,老公的妹妹穿的比我還像新娘。我一直安慰自己晕换,他們只是感情好午乓,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,500評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著闸准,像睡著了一般益愈。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上夷家,一...
    開(kāi)封第一講書人閱讀 51,370評(píng)論 1 302
  • 那天蒸其,我揣著相機(jī)與錄音,去河邊找鬼库快。 笑死摸袁,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的义屏。 我是一名探鬼主播靠汁,決...
    沈念sama閱讀 40,193評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼蜂大,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了蝶怔?” 一聲冷哼從身側(cè)響起奶浦,我...
    開(kāi)封第一講書人閱讀 39,074評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎添谊,沒(méi)想到半個(gè)月后财喳,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,505評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡斩狱,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,722評(píng)論 3 335
  • 正文 我和宋清朗相戀三年耳高,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片所踊。...
    茶點(diǎn)故事閱讀 39,841評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡泌枪,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出秕岛,到底是詐尸還是另有隱情碌燕,我是刑警寧澤,帶...
    沈念sama閱讀 35,569評(píng)論 5 345
  • 正文 年R本政府宣布继薛,位于F島的核電站修壕,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏遏考。R本人自食惡果不足惜慈鸠,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,168評(píng)論 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望灌具。 院中可真熱鬧青团,春花似錦、人聲如沸咖楣。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 31,783評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)诱贿。三九已至娃肿,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間瘪松,已是汗流浹背咸作。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 32,918評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留宵睦,地道東北人记罚。 一個(gè)月前我還...
    沈念sama閱讀 47,962評(píng)論 2 370
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像壳嚎,于是被迫代替她去往敵國(guó)和親桐智。 傳聞我的和親對(duì)象是個(gè)殘疾皇子末早,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,781評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容