打造一个接入简单的通用banner组件
最近需求需要搞个banner组件,已有的不满足要求。组件本身不涉及业务逻辑,所以可以放心拿出来水博客。

banner是一个常见的活动入口形式,给我们的印象一般是一些不断轮播的图片。但,有些事情,远没有看上去那么简单。

banner使用

特点:

  1. 支持不同类型卡片混排
  2. 支持比较复杂的轮播逻辑
  3. 布局和展示逻辑完全交给业务方ViewHolder控制,可以灵活定制。

先看最终使用效果,也许你能提起一些兴趣。

https://www.bilibili.com/video/BV1Ed4y137T7/

  1. 将banner添加到你的布局中,
<top.ntutn.zerohelper.view.banner.BannerView  
    android:id="@+id/banner_view"  
    android:layout_width="match_parent"  
    android:layout_height="300dp"  
    android:layout_gravity="center"  
    android:layout_margin="16dp"  
    tools:background="@tools:sample/backgrounds/scenic"  
    app:cardCornerRadius="8dp"/>
  1. 准备一个或多个ViewHolder,实现业务展示和播控逻辑。
class ImageBannerData(var url: String = ""): BannerData {  
    override val holderKey: String  
        get() = ImageBannerViewHolder.key  
}  

class ImageBannerViewHolder(val binding: ItemImageBannerBinding) : BannerViewHolder(binding.root) {  
    companion object: IBannerViewHolderFactory {  
        override val key: String  
            get() = "_image"  

        override fun newInstance(parent: ViewGroup): BannerViewHolder {  
            val inflater = LayoutInflater.from(parent.context)  
            return ImageBannerViewHolder(  
                ItemImageBannerBinding.inflate(inflater, parent, false)  
            )  
        }  
    }  

    override fun onBind(data: BannerData) {  
        if (data !is ImageBannerData) {  
            return  
        }  
        Glide.with(binding.bannerImageView)  
            .load(data.url)  
            .placeholder(R.drawable.ic_baseline_photo_24)  
            .into(binding.bannerImageView)  
    }  

    override fun onSelect() {  
        super.onSelect()  
        // 3s后标记播放完成
        selectScope.launch {  
            delay(3000L)  
            playDone()  
        }  
    }  
}
  1. 初始化Banner和绑定数据,记得最后要销毁哦
class BannerViewActivity : AppCompatActivity() {  
    private lateinit var binding: ActivityBannerViewBinding  

    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
        binding = ActivityBannerViewBinding.inflate(layoutInflater)  
        setContentView(binding.root)  
        bindView()  
        bindData()  
    }  

    private fun bindView() {  
        // 初始化banner
        binding.bannerView.initViewPager2()  
        // 注册VH
        binding.bannerView.registerViewHolder(ImageBannerViewHolder.Companion)  
        binding.bannerView.registerViewHolder(TextBannerViewHolder.Companion)  
    }  

    private fun bindData() { 
        // 绑定数据 
        binding.bannerView.setData(  
            buildList {  
                repeat(20) {  
                    if (listOf(true, false).random()) {  
                        add(ImageBannerData("https://fakeimg.pl/400x300/${randomColor()}/?text=$it"))  
                    } else {  
                        add(TextBannerData("This is #$it ViewHolder."))  
                    }  
                }  
            }        )  
    }  

    private fun randomColor(): String {  
        val characters = "0123456789ABCDEF"  
        return buildString { repeat(6) { append(characters.random()) } }  
    }  

    override fun onStart() {  
        super.onStart()  
        // 开启自动轮播
        binding.bannerView.enableAutoSwitch()  
    }  

    override fun onStop() {  
        super.onStop()  
        // 禁止自动轮播
        binding.bannerView.disableAutoSwitch()  
    }  

    override fun onDestroy() {  
        super.onDestroy()
        // 记得销毁控件  
        binding.bannerView.release()  
    }  
}

banner设计

banner结构

banner整体上使用一个自定义ViewGroup嵌套一个ViewPager2实现。每张图片是一个ViewHolder,业务方可以实现自己需要的任何布局。

VH创建解耦

我们知道ViewPager2实际上就是用RecyclerView实现的,而ViewHolder要交给业务方实现,那么ViewHolder的创建逻辑就要从我们的BannerAdapter中分离出去了。

ViewHolder创建解耦

interface BannerData {  
    // holderFactory.key  
    val holderKey: String  
}

interface IBannerViewHolderFactory {  
    val key: String  
    fun newInstance(parent: ViewGroup): BannerViewHolder  
}

class BannerAdapter(private val bannerOperator: BannerOperator) :  
    RecyclerView.Adapter<BannerViewHolder>() {  

    private val viewHolderFactoryMap = mutableMapOf<String, IBannerViewHolderFactory>()  
    private val viewHolderFactoryList = mutableListOf<IBannerViewHolderFactory>()  

    var dataSet: List<BannerData> = listOf()  
        set(value) {  
            field = value  
            notifyDataSetChanged()  
        }  

    fun registerViewHolder(factory: IBannerViewHolderFactory) {  
        viewHolderFactoryMap[factory.key] = factory  
        viewHolderFactoryList.add(factory)  
    }  

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BannerViewHolder {  
        val viewHolder = viewHolderFactoryList[viewType].newInstance(parent)  
        viewHolder.bannerOperator = bannerOperator  
        return viewHolder  
    }  

    override fun getItemViewType(position: Int): Int {  
        val key = if (dataSet.isEmpty()) {  
            StubViewHolder.key  
        } else {  
            dataSet[position % dataSet.size].holderKey  
        }  
        val factory = viewHolderFactoryMap[key]  
        return viewHolderFactoryList.indexOf(factory)  
    }  

    ...
}

无限横滑

这个实现并不难,只要理解RecyclerView.Adapter的几个回调就可以。只要 getItemCount() 返回 Int.MAX_VALUE ,然后所有用到position的地方替换为 position % dataSet.size 即可。我不相信我会遇到头铁滑了2147483647次的头铁用户。

卡片选中事件传递

我预期一个ViewHolder要拿到自己被选中/取消选中状态的回调,用来处理播控状态。

选中事件产生

选中事件是通过对ViewPager2页面事件监听来实现的。

private val pagerChangeCallback = object : ViewPager2.OnPageChangeCallback() {  
    override fun onPageSelected(position: Int) {  
        super.onPageSelected(position)  
        // 选中页面发生变化
    }  
}  

fun initViewPager2() {  
    ...
    viewPager2.registerOnPageChangeCallback(pagerChangeCallback)  
}  

fun release() {  
    ...
    viewPager2.unregisterOnPageChangeCallback(pagerChangeCallback)  
}

使用LiveData传递选中事件

为了将选中事件传递给VH,我决定让VH监听一个在BannerView中的LiveData来实现。但ViewHolder并不是LifecycleOwner,我们需要手动注册和取消监听。这里我们通常是写在attach和detach回调中来实现的。

// ViewHolder
class DemoViewHolder(val liveData) {
    val observer = ...

    fun onAttach() {
        liveData.observeForever(observer)
    }

    fun onDetach() {
        liveData.removeObserver(observer)
    }
}

// Adapter
class DemoAdapter {
    ...

    override fun onViewAttachedToWindow(holder: BannerViewHolder) {  
        super.onViewAttachedToWindow(holder)  
        holder.onAttach()  
    }  

    override fun onViewDetachedFromWindow(holder: BannerViewHolder) {  
        super.onViewDetachedFromWindow(holder)  
        holder.onDetach()  
    }
}

但这样其实是有问题的, 如果Activity直接退出,onViewDetachedFromWindow其实并不会执行,这样就会带来潜在问题。 参考 # RecyclerView#Adapter使用中的两个陷阱

我的办法是使用 WeakHashMap,不影响holder销毁的同时记录未detach的holder。

class BannerAdapter(private val bannerOperator: BannerOperator) :  
    RecyclerView.Adapter<BannerViewHolder>() {

    ...

    var detachHolderTasks = WeakHashMap<RecyclerView.ViewHolder, Runnable>()

    override fun onViewAttachedToWindow(holder: BannerViewHolder) {  
        super.onViewAttachedToWindow(holder)  
        holder.onAttach()  
        detachHolderTasks[holder] = Runnable { holder.onDetach() }  
    }  

    override fun onViewDetachedFromWindow(holder: BannerViewHolder) {  
        super.onViewDetachedFromWindow(holder)  
        holder.onDetach()  
        detachHolderTasks.remove(holder)  
    }  

    fun release() {  
        detachHolderTasks.forEach {  
            it.value.run()  
        }  
    }
}

接下来就是实际监听LiveData并转换为事件了。

abstract class BannerViewHolder(view: View) : RecyclerView.ViewHolder(view) {  
    protected var isSelected = false  
    var bannerOperator: BannerOperator? = null // 实际上就是BannerView  
    var currentPosition = -1  

    ...

    private val selectPositionObserver = Observer<Int> {  
        checkSelectStatus()  
    }  

    @CallSuper  
    fun onBind(position: Int, data: BannerData) {  
        currentPosition = position  

        onBind(data)  

        checkSelectStatus(force = true)  
    }  

    abstract fun onBind(data: BannerData)  

    /**  
     * @param force 重新bind后,必定触发一次选择事件  
     */  
    private fun checkSelectStatus(force: Boolean = false) {  
        if (bannerOperator?.selectedPosition?.value == currentPosition) {  
            if (force || !isSelected) {  
                onSelect()  
            }  
        }  
        if (bannerOperator?.selectedPosition?.value != currentPosition) {  
            if (force || isSelected) {  
                onUnSelect()  
            }  
        }  
    }  

    @CallSuper  
    open fun onSelect() {  
        isSelected = true  
    }  

    @CallSuper  
    open fun onUnSelect() {  
        isSelected = false  
    }   
}

这里写这么啰嗦是为了处理两个特殊情况:

  1. 数据未走到onBind,但position先发生了变化。
  2. position未发生变化,但重新bind了不一样的数据。

保证select事件总在bind之后,且只触发一次。

卡片完播事件传递

卡片完播是banner切换的前置条件,完播时机由VH决定,因而又要将这个事件传递到BannerView,以决定是否切换。

这里也是我这个banner组件的最大特点, 完播事件产生交给业务方灵活控制,消费则回到组件进行统一管理。

完播事件产生

卡片可以认为自己是在onSelect时开始播放,即每次onSelect触发一次playDone。具体何时完播就可以进行灵活的业务逻辑控制了,如5s后完播,视频播放完成后完播,甚至第一次曝光10s完播第二次5s完播等。

class ImageBannerViewHolder(val binding: ItemImageBannerBinding) : BannerViewHolder(binding.root) {  
    override fun onBind(data: BannerData) {  
        if (data !is ImageBannerData) {  
            return  
        }  
        ...
    }  

    override fun onSelect() {  
        super.onSelect()  
        ThreadUtils.postDelayed({ playDone() }, 5_000L)  
    }  
}

因为VH持有BannerView的引用,所以检查下当前是选中卡片就可以直接把完播事件传递给BannerView了。

abstract class BannerViewHolder(view: View) : RecyclerView.ViewHolder(view) {  
    ...

    protected fun playDone() {  
        if (isSelected) {  
            bannerOperator?.currentItemPlayDone()  
        }  
    }  
}

完播事件的消费

我用一个变量标识当前卡片已经完播,并有一个方法来检查并切换到下一张卡片。

class BannerView @JvmOverloads constructor(  
    context: Context,  
    attrs: AttributeSet? = null,  
    defStyleAttr: Int = 0  
) : CardView(context, attrs, defStyleAttr), BannerOperator {  

    ...
    private var pageInvalid = false  

    private fun switchNext() {  
        viewPager2.setCurrentItem(viewPager2.currentItem + 1, true)  
    }  

    override fun currentItemPlayDone() {  
        pageInvalid = true  
        checkAndSwitchPage()  
    }  

    private fun checkAndSwitchPage() {  
        ...
        if (pageInvalid) {  
            switchNext()  
        }
    }  
}

用户正在拖动,或者用户刚刚松手我们是不能切换的,否则会干扰用户操作。这里我们通过监听dispatchTouchEvent来实现。

为什么不是 onInterceptTouchEvent 回调呢?因为ViewPager2父类RecyclerView在onTouch中开始滑动后会调用getParent().requestDisallowInterceptTouchEvent()阻止父布局继续拦截消息。放在dispatchTouchEvent中可以保证被调用到。

实现上,用户未松手检查不通过,用户松手时补充一次检查即可。

class BannerView @JvmOverloads constructor(  
    context: Context,  
    attrs: AttributeSet? = null,  
    defStyleAttr: Int = 0  
) : CardView(context, attrs, defStyleAttr), BannerOperator {  

    private var lastUserTouchTime = -1L  

    var delayAfterUserTouch = 5000L  

    fun release() {  
        clearCheckTask()  
        ...
    }  

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {  
        when (ev?.action) {  
            MotionEvent.ACTION_DOWN -> {  
                userDragging = true  
            }  
            MotionEvent.ACTION_UP -> {  
                userDragging = false  
                lastUserTouchTime = SystemClock.uptimeMillis()  
                checkAndSwitchPage()  
            }  
            MotionEvent.ACTION_CANCEL -> {  
                userDragging = false  
                lastUserTouchTime = SystemClock.uptimeMillis()  
                checkAndSwitchPage()  
            }  
        }  
        return super.dispatchTouchEvent(ev)  
    }  

    private fun checkAndSwitchPage() {  
        if (userDragging) {  
            return  
        }  
        val timeAfterLastTouch = SystemClock.uptimeMillis() - lastUserTouchTime  
        if (timeAfterLastTouch < delayAfterUserTouch) {  
            // 用户拖动松手5000 ms内,不自动切换,并规划下一次检查  
            handler.postDelayed(checkAutoPlayRunnable, delayAfterUserTouch - timeAfterLastTouch)  
            return  
        }  
        if (pageInvalid) {  
            switchNext()  
        }  
    }  

    private fun clearCheckTask() {  
        handler.removeCallbacks(checkAutoPlayRunnable)  
    }  

    ...
}

我们还可以把是否开启轮播交给外部控制,实现锁屏停止轮播,解锁后又恢复轮播的效果。

class BannerView @JvmOverloads constructor(  
    context: Context,  
    attrs: AttributeSet? = null,  
    defStyleAttr: Int = 0  
) : CardView(context, attrs, defStyleAttr), BannerOperator { 

    private var isAutoSwitchEnabled = false

    fun enableAutoSwitch() {  
        isAutoSwitchEnabled = true  
        checkAndSwitchPage()  
    }  

    fun disableAutoSwitch() {  
        clearCheckTask()  
        isAutoSwitchEnabled = false  
    }  

    private fun checkAndSwitchPage() {  
        if (userDragging) {  
            return  
        }  
        ...
    }  

    private fun clearCheckTask() {  
        handler.removeCallbacks(checkAutoPlayRunnable)  
    }  
}

selectScope

上述完播事件产生中标记完播时使用了这样的代码 ThreadUtils.postDelayed({ playDone() }, 5_000L) ,其实还是在取消播放时取消下比较严谨。因此可以提供一个CoroutineScope,取消选中状态后自动取消。

 abstract class BannerViewHolder(view: View) : RecyclerView.ViewHolder(view) {  
    protected var isSelected = false  
    protected lateinit var selectScope: CoroutineScope  

    @CallSuper  
    open fun onSelect() {  
        selectScope = CoroutineScope(Dispatchers.Main + SupervisorJob())  
        isSelected = true  
    }  

    @CallSuper  
    open fun onUnSelect() {  
        if (::selectScope.isInitialized) {  
            selectScope.cancel()  
        }  
        isSelected = false  
    }  
    ...
}

这样使用方就比较方便了

class ImageBannerViewHolder(val binding: ItemImageBannerBinding) : BannerViewHolder(binding.root) {  
    ...
    override fun onSelect() {  
        super.onSelect()  
        selectScope.launch {  
            delay(3000L)  
            playDone()  
        }  
    }  
}

最后修改于 2022-09-14