前言 ------ 为什么不用 ExpandableListView
ExpandableListView
大家肯定很熟悉(没关系,你就当你很熟悉)。
它是这样的(随便从别人博客里扒了一张相对好看的图,为表尊敬,附:原文地址)------

但是它有什么问题呢?
- 仅有二级
- 它是ListView(在recyclerView当道甚至要被compose威胁地位的今天,ListView早该退出舞台了)
- 它的动画可能......它可能没有动画这个概念
- 基于第二点,它在和其他内容协作时可能有很多bug ,比如嵌套滚动时
实现目标
- 无限层级(每层什么样式取决于你对itemView的定制,和动画无关)
- "掉下来"的动效
实现思路 - 界面结构和数据结构
数据结构
显然,需要定义对应的数据结构才好做到展开和收缩。 定义了树形结构的数据后,有两条路线二选一:
- 添加和删除数据后,将树形结构按需铺平成另一个list,所有界面数据都来自这个list。
- 为该树形数据结构的读写定制专门的方法,让树形结构可以像条形的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是因为它自动实现了
equals
、toString
和copy
方法,我们稍后会用到copy
- 使用data class是因为它自动实现了
- 界面操作 data class Op
- 界面操作队列 open class AnimSet : TreeSet<Op>
- 动画实现:start / end
- 动画实现:add / remove / move
- 界面裁剪计算逻辑