前言 —— 为什么不必 ExpandableListView

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

它是这样的(随意从别人博客里扒了一张相对美观的图,为表敬重,附:原文地址)——

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

可是它有什么问题呢?

  • 仅有二级
  • 它是ListView(在recyclerView当道乃至要被compose要挟方位的今日,ListView早该退出舞台了)
  • 它的动画或许……它或许没有动画这个概念
  • 根据第二点,它在和其他内容协作时或许有很多bug,比如嵌套翻滚

完成目标

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

完成思路 – 界面结构和数据结构

数据结构

明显,需求界说对应的数据结构才好做到打开和缩短。 界说了树形结构的数据后,有两条路线二选一:

  1. 添加和删去数据后,将树形结构按需铺平成另一个list,一切界面数据都来自这个list。
  2. 为该树形数据结构的读写定制专门的办法,让树形结构能够像条形的list相同遍历。

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

各位能够试试写成1,读写处理的时分就会简便很多,价值是每次改动数据结构时需求new List。——这点功能洒浇水啦!

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

/**
 * @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节点通明。

private val rootMenu = MenuData("root", true)

界面

/**
 * 给外界在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,仅有要注意的就是更新其地点层级。

——当然,由于数据单向链接,才需求更新层级。

你能够自行完成双向链接,这样子不只能够主动生成层级,还能添加满足感

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

由于前面打下的根底,此处两行即可

fun getItemAtPosition(position: Int): MenuData? =
    rootMenu.getChildAt(position)
override fun getItemCount(): Int = 
    rootMenu.childSize

要害办法:切换可打开item的打开状况

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 ——

嘿嘿随意画了下,如下图

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

明显不管是添加还是删去 Item, childView 都存在一个需求被躲藏一部分的时刻。


  • 假定 parentView 不止一个 childView,这就更杂乱了——
    • 有的处于彻底躲藏状况
    • 有的处于彻底可见状况
    • 还有一个处于部分可见状况的 childView

如图:

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


.


.


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

下期预览

  • 界面状况 data class ViewState
    • 使用data class是由于它主动完成了equalstoStringcopy 办法,咱们稍后会用到copy
  • 界面操作 data class Op
  • 界面操作队列 open class AnimSet : TreeSet<Op>
  • 动画完成:start / end
  • 动画完成:add / remove / move
  • 界面裁剪计算逻辑