banner是一个常见的活动入口形式,给我们的印象一般是一些不断轮播的图片。但,有些事情,远没有看上去那么简单。
banner使用
特点:
- 支持不同类型卡片混排
- 支持比较复杂的轮播逻辑
- 布局和展示逻辑完全交给业务方ViewHolder控制,可以灵活定制。
先看最终使用效果,也许你能提起一些兴趣。
https://www.bilibili.com/video/BV1Ed4y137T7/
- 将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"/>
- 准备一个或多个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()
}
}
}
- 初始化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整体上使用一个自定义ViewGroup嵌套一个ViewPager2实现。每张图片是一个ViewHolder,业务方可以实现自己需要的任何布局。
VH创建解耦
我们知道ViewPager2实际上就是用RecyclerView实现的,而ViewHolder要交给业务方实现,那么ViewHolder的创建逻辑就要从我们的BannerAdapter中分离出去了。
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
}
}
这里写这么啰嗦是为了处理两个特殊情况:
- 数据未走到onBind,但position先发生了变化。
- 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