小白也能懂的RecyclerView界面动效定制,让ExpandableListView彻底退出舞台!(上)

前言 ------ 为什么不用 ExpandableListView

ExpandableListView大家肯定很熟悉(没关系,你就当你很熟悉)。

它是这样的(随便从别人博客里扒了一张相对好看的图,为表尊敬,附:原文地址)------

但是它有什么问题呢?

  • 仅有二级
  • 它是ListView(在recyclerView当道甚至要被compose威胁地位的今天,ListView早该退出舞台了)
  • 它的动画可能......它可能没有动画这个概念
  • 基于第二点,它在和其他内容协作时可能有很多bug ,比如嵌套滚动

实现目标

  • 无限层级(每层什么样式取决于你对itemView的定制,和动画无关)
  • "掉下来"的动效

实现思路 - 界面结构和数据结构

数据结构

显然,需要定义对应的数据结构才好做到展开和收缩。 定义了树形结构的数据后,有两条路线二选一:

  1. 添加和删除数据后,将树形结构按需铺平成另一个list,所有界面数据都来自这个list。
  2. 为该树形数据结构的读写定制专门的方法,让树形结构可以像条形的list一样遍历。

显然写出2比写出1并没有性能更好,主要是看起来更cooooooool ,那我选择了2,代价是写的时候找数据一顿好找 (对现在的程序员有难度,对高中大学生刚刚好)

各位可以试试写成1,读写处理的时候就会简便很多,代价是每次改变数据结构时需要new List。------这点性能洒洒水啦!

不说了,想说的都在注释里------

kotlin 复制代码
/**
 * @param title 标题,你要复杂的数据就自己替换掉,demo就只要标题
 * @param isExpand 是否是展开状态
 * @param child 我孩子竟是我自己
 */
data class MenuData(
    val title: String, 
    var isExpand: Boolean = false, 
    val child: List<MenuData> = emptyList()
) {
    /**
     * 仅在加入adapter的数据中时被自动赋值
     */
    internal var level: Int = 0

    /**
     * 是否可展开------取决于它有没有child,以及child有没有内容
     */
    val canExpand: Boolean
        get() = child.isNotEmpty()

    /**
     * 到底有多少子item,这里得进行一个
     */
    val childSize: Int
        get() {
            if (!isExpand) return 0

            var count = child.size // 先加上每个child
            // 每个child再调用自己的相同方法递归计算
            child.forEach {
                count += it.childSize 
            }
            return count
        }

    /**
     * 获取指定位置的child,这个position将假定数据已经从树形变成条形,
     * 然后获取条形数据中对应的[MenuData]
     * 
     * @return 返回空是对调用者水平的不尊重,没错,我就不尊重了
     */
    fun getChildAt(position: Int): MenuData? {
        if (position < 0 || !isExpand) {
            return null
        }
        var count = 0 // 计数器
        // 问问子女们
        child.forEach {// for child
            if (count == position) { // 先看看自己是不是目标
                return it
            }
            count++ // 哦,我不是
            // count还剩position-count项,尝试找一下
            val result = it.getChildAt(position - count) 自己孩子
            if (result != null) { // 如果我有孩子、且我在孩子中找到了
                return result // good,找到了,交差!
            }
            // 没找到,把我孩子都加上,找下一个兄弟姐妹问问
            count += it.childSize 
        }
        // 问完了子女们
        return null //确实没找到
    }
}

Adapter实现

实现这么一个列表,第一重要的就是支持二级 乃至多级状态

展开状态 能够正确且自由切换adapter

数据

不另外构建列表了,直接用已经定义好的数据结构即可。

实现后面的item相关方法时,可以让root节点透明。

kotlin 复制代码
private val rootMenu = MenuData("root", true)

界面

kotlin 复制代码
/**
 * 给外界在expand时做事的接口
 */
var onExpandStateChange: (position: Int, isExpand: Boolean) -> Unit 
    = { position, isExpande -> }

override fun onCreateViewHolder(
    parent: ViewGroup, 
    viewType: Int
): MultiLevelMenuVH {
    // itemView就用compose快速实现吧
    // 用view也很简单,但简单美化样式会需要付出很多额外精力
    // 所以算了算了用compose
    val itemView = ComposeView(parent.context)
    return MultiLevelMenuVH(itemView)
}

override fun onBindViewHolder(
    holder: MultiLevelMenuVH,
    position: Int
) {
    val data = getItemAtPosition(position) ?: return
    var rotate by mutableStateOf(if (data.isExpand) 90f else 0f)
    // compose 大法好,就不抽象和管那么多了
    holder.root.setContent {
        val rotateAmin by animateFloatAsState(rotate, label = "rotate")
        Row(Modifier.fillMaxWidth()
            .defaultMinSize(minHeight = 32.dp)
            .padding(vertical = 1.dp)
            // 每级item的缩进不同就源于此
            // 界面上我希望每次一级目录都比上一级多缩进个20dp
            .padding(horizontal = (data.level * 20).dp) 
            .clip(RoundedCornerShape(8.dp))
            // 自己定的颜色,你没有这个颜色正常,随便整一个你的颜色
            .background(MaterialDesignColor.LightBlue300) 
            .clickable {
                // 点击处理
                if (!data.canExpand) { // 不可expand就别添乱
                    return@clickable
                }
                // 调用对应切换方法
                switchItemExpandState(holder.bindingAdapterPosition, !data.isExpand) 
                // 更新布局状态
                rotate = if (data.isExpand) 90f else 0f 
                // 调用布局状态改变的监听
                onExpandStateChange(position, data.isExpand) 
            }, 
            verticalAlignment = Alignment.CenterVertically
        ) {
            // 文字居中这么套一层会更舒服,相信我,让Text()保持纯粹
            Box(Modifier.weight(1f), Alignment.Center) { 
                Text(data.title)
            }
            if (data.canExpand) { // 不可展开的item就别显示图标了
                Image(
                    Icons.Filled.KeyboardArrowRight, 
                    "expand", 
                    Modifier.size(28.dp)
                        .rotate(rotateAmin)
                )
            }
        }
    }
}

一些处理数据的方法

主要是add和clear,唯一要注意的就是更新其所在层级。

------当然,因为数据单向链接,才需要更新层级。

你可以自行实现双向链接,这样子不仅可以自动生成层级 ,还能增加满足感

kotlin 复制代码
fun add(menuList: List<MenuData>) {
    val oldSize = itemCount
    updateLevel(menuList)
    rootMenu.child.addAll(menuList)
    notifyItemRangeInserted((oldSize - 1).coerceAtLeast(0), itemCount - oldSize)
}

fun insert(position: Int, menuList: List<MenuData>) {
    // todo:懒得写了
    // todo:remove也懒得写了
    // 对于多级菜单而言,insert和remove这两种场景可以忽略
}

/**
 * 递归更新level
 */
private fun updateLevel(menuList: List<MenuData>?, initLevel: Int = 0) {
    menuList?.forEach {
        it.level = initLevel
        updateLevel(it.child, initLevel + 1) // 下一级的level+1
    }
}

fun clear() {
    val dataSize = itemCount
    rootMenu.child.clear()
    notifyItemRangeRemoved(0, dataSize)
}

关键方法:获取总item数、获取指定item

因为前面打下的基础,此处两行即可

kotlin 复制代码
fun getItemAtPosition(position: Int): MenuData? = 
    rootMenu.getChildAt(position)

override fun getItemCount(): Int = 
    rootMenu.childSize

关键方法:切换可展开item的展开状态

kotlin 复制代码
fun switchItemExpandState(position: Int, expand: Boolean) {
    if (recyclerView.isAnimating) {
    // 这个动画执行过程中不能再次启动动画
    // 否则位置就会计算出错了  
    // 你可以解除这里的return,然后修改对应的ItemAnimator
    // 实现更复杂的位置计算,以使得此处的动画可打断。
        return
    }
    val curItem = getItemAtPosition(position) ?: return
    // 不可展开或展开状态不变化的情况下直接返回
    if (!curItem.canExpand || curItem.isExpand == expand) { 
        return
    }
    if (curItem.isExpand) { // 展开 -> 收缩
        // 先统计下有多少孩子,等下isExpand改变会造成统计错误!
        val changeSize = curItem.childSize 
        curItem.isExpand = false
        notifyItemRangeRemoved(position + 1, changeSize)
    } else {                //收缩 -> 展开
        curItem.isExpand = true
        // 等isExpand改变了再找childSize
        notifyItemRangeInserted(position + 1, curItem.childSize) 
        // 你notifyChange了还怎么执行箭头旋转动画?
        // 修改数据后,手动把界面的状态和数据同步的情况下
        // 是不用notifyChange的
        // notifyItemRangeChanged(position + 1 + changeSize, itemCount - position - changeSize + 1)
    }
}

实现思路:动效

实现该动效,需要实现哪些点?

第一点,是继承SimpleAnimator,理清DefaultItemAnimator是怎么重写的。 ------但怎么说呢,谷歌这一段写得不那么容易理解,跟着我来

第二点,是实现这个【掉落效果】,怎么实现呢?

拆解一下

  • 假设 parentView 仅一个 childView ------

嘿嘿随便画了下,如下图

显然不管是增加 还是删除 Item, childView 都存在一个需要被隐藏一部分的时刻。


  • 假设 parentView 不止一个 childView,这就更复杂了------
    • 有的处于完全隐藏状态
    • 有的处于完全可见状态
    • 还有一个 处于部分可见 状态的 childView

如图:


.


.


现在我们已经构建好了正确的界面,动效细节点比较多,下期再讲。

下期预览

  • 界面状态 data class ViewState
    • 使用data class是因为它自动实现了equalstoStringcopy 方法,我们稍后会用到copy
  • 界面操作 data class Op
  • 界面操作队列 open class AnimSet : TreeSet<Op>
  • 动画实现:start / end
  • 动画实现:add / remove / move
  • 界面裁剪计算逻辑
相关推荐
DKPT22 分钟前
数据结构之快速排序、堆排序概念与实现举例
java·数据结构·算法
Hiweir ·36 分钟前
机器翻译之创建Seq2Seq的编码器、解码器
人工智能·pytorch·python·rnn·深度学习·算法·lstm
OkeyProxy40 分钟前
設置Android設備全局代理
android·代理模式·proxy模式·代理服务器·海外ip代理
star数模1 小时前
2024“华为杯”中国研究生数学建模竞赛(E题)深度剖析_数学建模完整过程+详细思路+代码全解析
python·算法·数学建模
Tak1Na1 小时前
2024.9.19
算法
sjsjs111 小时前
【数据结构-扫描线】力扣57. 插入区间
数据结构·算法·leetcode
王哈哈嘻嘻噜噜1 小时前
数据结构中线性表的定义和特点
数据结构·算法
刘志辉1 小时前
vue传参方法
android·vue.js·flutter
一杯茶一道题2 小时前
LeetCode 260. 只出现一次的数字 III
算法·leetcode
MogulNemenis2 小时前
力扣415周赛
java·数据结构·算法·leetcode