一、已有方案分析
在項(xiàng)目中經(jīng)常有無(wú)限輪播 Banner 的需求, 用過(guò)別人開(kāi)源的, 也有自己基于 ViewPager2 寫過(guò)類似的模塊, 無(wú)限輪播據(jù)我所知的有兩種思路:
在原始數(shù)據(jù)的首尾各添加一個(gè)(第一個(gè)為原始數(shù)據(jù)最后一個(gè), 最后一個(gè)為原始數(shù)據(jù)第一個(gè)), 然后輪播到末尾時(shí)偷偷切換到原始的第一個(gè), 用戶滑動(dòng)到第一個(gè)后則偷偷跳到原始的最后一個(gè);
在適配器中聲明數(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):
支持使用 Layout XML 配置;
能夠?qū)崿F(xiàn)無(wú)限輪播;
應(yīng)該具備生命周期感知能力, 在 Resume 后開(kāi)始輪播, 在 Pause 后自動(dòng)停止輪播;
用戶觸摸 Banner 時(shí), 應(yīng)停止輪播;
使用 Paging3 為 BannerView 提供數(shù)據(jù), 將有限的數(shù)據(jù)映射為無(wú)限;
在系統(tǒng)發(fā)生 Configuration Change (配置變更) 時(shí), 能夠保存與恢復(fù)狀態(tài)(例如: 白天黑夜模式切換/ 橫豎屏切換等);
能夠響應(yīng)數(shù)據(jù)數(shù)量變化, 動(dòng)態(tài)更新 BannerItem ;
能夠作為 Item Header (頭部Item) , 嵌入 RecyclerView 中;
在目標(biāo)8的基礎(chǔ)上, 支持基于條件動(dòng)態(tài)地顯示/隱藏 Banner (例如數(shù)據(jù)為空時(shí)隱藏);
在目標(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)單, 但是我還想啰嗦地解釋一下:
第一個(gè)入?yún)?
list
就是原始數(shù)據(jù), 我們將每一頁(yè)都視為原始數(shù)據(jù), 所以需要持有它們.第二個(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ū)分.最后, 合成 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ù)覽一下效果:
在自動(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 上了, 需要的話可以去查看.