前言
Android View体系中,有很多方法可以实现拖拽效果的方法,比如ViewDragHelper、NestScrolling机制或者最原始的事件处理都是可以的。
在之前的一篇文章中,我们使用RecyclerView + ItemTouchHelper实现了拖拽,于是有同学就问,Compose版本的什么时候才能实现,实际上Compose版本的官方早就实现了。

前奏
本篇方案之前,我自己曾尝试利用自定义宫格去实现,核心逻辑参考了ItemTouchHelper#findSwapTragets方法和ItemTouchHelper#chooseDropTarget
- ItemTouchHelper#findSwapTragets: 负责筛选边缘相交的Item,并计算被拖拽的Item和相交的Item中心点的距离,以此从小到大排序
- ItemTouchHelper#chooseDropTarget:负责从ItemTouchHelper#findSwapTragets的结果中,通过打分筛选出最符合交换的Item
大体功能是实现了,但是不知道为什么交换数据后,被拖拽的Composable最后无法拖动了,目前还没找到原因。于是就找到了官方版本的实现,也就是本篇最终的方案。
实际上Compose UI的拖拽相对而言,代码量上属实不多,但是难度要比以往的传统View难一点,主要涉及到状态重组这部分控制难道较大。
本篇实现
本篇内容完全依赖Compose中的LazyVerticalGrid实现,下面是实现的完整逻辑
官方demo
本篇参考官方版本的demo实现,在官方demo中,分别有LazyVerticalGrid和LazyColumn的实现,具体点击如下链接查看源码。
但是需要注意的坑点是,尽量使用稳定的Compose版本,而不是最新版本,目前最新的compose foundation 1.7.0-beta版本存在缺陷,【按压Item】触发重组后,会导致被拖拽的Item绘制不出来,原因是新版的graphicLayer函数实现存在缺陷。
本篇我们使用的是Compose 1.6.5 版本,下面是官方demo
组件实现
在这部分,官方的核心逻辑如下,首先暴露数据源接口,这是必须的。另外Key的生成函数,官方的demo中并没暴露,因此部分数据类型会引发crash,本篇在官方的基础上,将itemKey函数暴露出去,来防止Item类型不支持被Compose Key持有而引发Crash,本篇是建立映射关系修复了此问题的。
这里提到的Crash还是比较难以定位的,因为compose的堆栈不能明确的知道异常位置。
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T : Any> DraggableGrid(
items: List<T>,
itemKey:(Int,T) -> Any,
onMove: (Int, Int) -> Unit,
content: @Composable (T, Boolean) -> Unit,
) {
// Grid状态,常规做法
val gridState = rememberLazyGridState()
//记录拖拽状态
val dragDropState = rememberGridDragDropState(gridState, onMove)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.dragContainer(dragDropState),
state = gridState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
itemsIndexed(items, key = { index, item ->
itemKey(index,item)
}) { index, item ->
DraggableItem(dragDropState, index) { isDragging ->
content(item, isDragging)
}
}
}
}
//核心方法,事件监听
fun Modifier.dragContainer(dragDropState: GridDragDropState): Modifier {
return pointerInput(key1 = dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset ->
dragDropState.onDragStart(offset)
},
onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() }
)
}
}
@ExperimentalFoundationApi
@Composable
fun LazyGridItemScope.DraggableItem(
dragDropState: GridDragDropState,
index: Int,
content: @Composable (isDragging: Boolean) -> Unit,
) {
val dragging = index == dragDropState.draggingItemIndex
val draggingModifier = if (dragging) {
//被拖拽时
Modifier
.zIndex(1f) //防止被遮挡
.graphicsLayer {
translationX = dragDropState.draggingItemOffset.x
translationY = dragDropState.draggingItemOffset.y
}
} else if (index == dragDropState.previousIndexOfDraggedItem) {
//松手后的"回归"动画
Modifier
.zIndex(1f) //防止被遮挡
.graphicsLayer {
translationX = dragDropState.previousItemOffset.value.x
translationY = dragDropState.previousItemOffset.value.y
}
} else {
//idle状态
Modifier.animateItemPlacement()
}
Box(modifier = Modifier.then(draggingModifier) , propagateMinConstraints = true) {
content(dragging)
}
}
另外,上面最重要的还是事件监听和绘制。
通过Modifier监听长按事件,进而已发状态更新。
fun Modifier.dragContainer(dragDropState: GridDragDropState): Modifier {
return pointerInput(key1 = dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset ->
dragDropState.onDragStart(offset)
},
onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() }
)
}
}
另一个点就是绘制了,绘制我们要注意,被拖拽的Item不能被其他Item遮挡,因此需要设置zIndex在其他Item上面。
另一个就是GraphicLayer偏移,这里要注意的,这种偏移是「图像」的偏移,Item本身的位置没有任何变化。
Modifier
.zIndex(1f) //防止被遮挡
.graphicsLayer {
translationX = dragDropState.draggingItemOffset.x
translationY = dragDropState.draggingItemOffset.y
}
状态管理
实现Compose UI最重要的是状态管理,因为状态负责数据刷新、UI刷新两大主要作用,由于篇幅的原因,我在代码中添加了注释,方便大家理解。
当然,还有remember的闭包用来存储状态,这里我们不再赘述。
@Composable
fun rememberGridDragDropState(
gridState: LazyGridState,
onMove: (Int, Int) -> Unit,
): GridDragDropState {
val scope = rememberCoroutineScope()
val state = remember(gridState) {
GridDragDropState(
state = gridState,
onMove = onMove,
scope = scope
)
}
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
gridState.scrollBy(diff)
}
}
return state
}
class GridDragDropState internal constructor(
private val state: LazyGridState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit,
) {
//事件通道,辅助LazyVertialGrid整体滑动
internal val scrollChannel = Channel<Float>()
//触摸事件偏移的距离,不是触摸位置
private var draggingItemDraggedDelta by mutableStateOf(Offset.Zero)
//触摸的item在布局中的偏移位置
private var draggingItemInitialOffset by mutableStateOf(Offset.Zero)
//当前被触摸的Item索引
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
/**
* 这里有2个原因
* 1.由于会出现数据交换和索引交换,因此需要重新计算draggingItemOffset位置
* 2.Grid自身也滑动,这里也可以做到矫正
*/
internal val draggingItemOffset: Offset
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset.toOffset()
} ?: Offset.Zero
//当前被触摸的Item的布局信息
private val draggingItemLayoutInfo: LazyGridItemInfo?
get() = state.layoutInfo.visibleItemsInfo
.firstOrNull {
it.index == draggingItemIndex
}
// touch cancel或者touch up 之后继续保存被拖拽的Item,辅助通过动画方式将其Item偏移到指定位置
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
// 辅助 previousIndexOfDraggedItem 进行位置移动
internal var previousItemOffset = Animatable(Offset.Zero, Offset.VectorConverter)
private set
internal fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo
.firstOrNull { item ->
/**
* 查找当前触摸的Item
*/
offset.x.toInt() in item.offset.x..item.offsetEnd.x &&
offset.y.toInt() in item.offset.y..item.offsetEnd.y
}?.also {
draggingItemIndex = it.index //当前被触摸Item索引
draggingItemInitialOffset = it.offset.toOffset() //当前Item的在布局中的偏移位置
}
}
internal fun onDragInterrupted() {
if (draggingItemIndex != null) {
//touch up 或者 touch cancel后保存位置,辅助之前被拖拽的Item通过动画到指定的位置
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset //目标位置
scope.launch {
//启动协程,进行偏移
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
Offset.Zero,
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Offset.VisibilityThreshold
)
)
//snapTo 和 animateTo是suspend函数,因此到这里是执行完成
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = Offset.Zero
draggingItemIndex = null
draggingItemInitialOffset = Offset.Zero
}
internal fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset
//是否检测到Item被拖拽,空白区域的拖拽无效
val draggingItem = draggingItemLayoutInfo ?: return
//开始位置,类似传统View的left和top
val startOffset = draggingItem.offset.toOffset() + draggingItemOffset
//结束位置,类似传统View的right和bottom
val endOffset = startOffset + draggingItem.size.toSize()
//centerX和centerY
val middleOffset = startOffset + (endOffset - startOffset) / 2f //运算符重载,计算出中心点
/**
* 查找相交的Item,这和RecyclerView的ItemTouchHelper有些区别,后者会先过滤相交
* 的Item,然后按中心点距离排序,距离越小越优先,排序之后进行打分,偏离的距离越远越优先,
* 因此,理论上ItemTouchHelper稳定性要高一些,而Compose的灵敏度更高
*/
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.x.toInt() in item.offset.x..item.offsetEnd.x &&
middleOffset.y.toInt() in item.offset.y..item.offsetEnd.y &&
draggingItem.index != item.index
}
if (targetItem != null) {
val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) {
draggingItem.index //如果交换的Item是第一个位置展示,那么需要尝试滑动Grid
} else if (draggingItem.index == state.firstVisibleItemIndex) {
targetItem.index //如果被拖拽的Item是第一个位置展示,那么尝试滑动Grid
} else {
null
}
if (scrollToIndex != null) {
scope.launch {
// this is needed to neutralize automatic keeping the first item first.
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
//回调到ViewModel层面,进行数据交换
onMove.invoke(draggingItem.index, targetItem.index)
}
} else {
//回调到ViewModel层面,进行数据交换
onMove.invoke(draggingItem.index, targetItem.index)
}
/**
* 这里不太好理解,这行代码的意思是被拖拽的Item索引已经变了
* 因此需要重新更新布局信息,而draggingItemIndex是mutableStateOf委托的,设置后会触发状态更新
*/
draggingItemIndex = targetItem.index
} else {
/**
* 尝试滑动布局
*/
val overscroll = when {
draggingItemDraggedDelta.y > 0 ->
(endOffset.y - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta.y < 0 ->
(startOffset.y - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll)
}
}
}
private val LazyGridItemInfo.offsetEnd: IntOffset
get() = this.offset + this.size
}
operator fun IntOffset.plus(size: IntSize): IntOffset {
return IntOffset(x + size.width, y + size.height)
}
operator fun Offset.plus(size: Size): Offset {
return Offset(x + size.width, y + size.height)
}
本段代码中还有Offset的运算符的重载,属于kotlin特性,这里我们不多讲。我们重点看下执行流程。
- 触发onDragStart,记录Item索引和在布局中的偏移位置
- 触摸滑动时触发onDrag,通过draggingItemOffset和draggingItemLayoutInfo计算出被拖拽的Item图像滑动位置
- 通过draggingItemLayoutInfo计算出与被拖拽Item相交的targetItem,这里主要是利用中心点判断是不是在另一个其他Item范围内,如果是就能筛选出targetItem
- 通过onMove进行数据和展示位置交换
- 更新被拖转的Item索引,这个变化会再次矫正draggingItemOffset和draggingItemLayoutInfo的getter函数
- 继续拖拽,重复上面步骤
- 如果松手,那么触发onDragInterrupted,通过previousIndexOfDraggedItem保存当前被拖拽的Item索引,最终触发动画让被拖拽的Item以弹簧动画的方式回归到目标位置
- 所有操作完整完成之后就会处于默认状态
用法
以上就是核心逻辑了,用法也很简单,为什么要提用法呢,其实上面的核心逻辑缺少对数据的更新,因此,用法中最重要的是对数据更新,从而触发LazyVerticalGrid的动画。
那么,如何更新数据呢?实际上就是下面的用法,这点和RecyclerView+ItemTouchHelper一样,只不过触发动画的方式不一样,后者通过notifyItemMoved实现,而本篇通过状态触发重组。
val mutableList = items.toMutableList().apply {
add(targetIndex, removeAt(dragingIndex)) // 交换位置
}
tems = mutableList // 更新状态,触发动画
当然,不过上面的代码有一定的性能问题,容易产生内存碎片,同时时间复杂度也较高。在评论区网友的强烈建议下,这里我们稍作优化
- 使用mutableStateList
- 不适用 tems = mutableList 更新状态
val dataList = createItems(18).toMutableStateList()
var items by remember {
mutableStateOf(dataList)
}
那么后续的操作更简单,通过下面的代码就能触发状态刷新
items.apply {
add(targetIndex, removeAt(dragingIndex)) // 交换位置
}
完整的用法,通过下面的代码就能实现本篇开头的效果。
class DragAndDropActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val dataList = createItems(18).toMutableStateList()
var items by remember { mutableStateOf(dataList) }
DraggableGrid(items = items, itemKey = { index, item ->
item.id
}, onMove = { dragingIndex, targetIndex ->
val mutableList = items.toMutableList().apply {
add(targetIndex, removeAt(dragingIndex)) // 交换位置,触发重组
}
}) { item, isDragging ->
Box(modifier = Modifier
.background(item.color)
.height(100.dp),
contentAlignment = Alignment.Center
) {
Text(text = "${item.id}")
}
}
}
}
fun createItems(count: Int): List<Item> {
return (1..count).map {
Item(it, colors[it % colors.size])
}
}
}
data class Item(
val id: Int,
val color: Color
)
private val colors = listOf(
Color(0xFFF44336),
Color(0xFFE91E63),
Color(0xFF9C27B0),
Color(0xFF673AB7),
Color(0xFF3F51B5),
Color(0xFF2196F3),
Color(0xFF03A9F4),
Color(0xFF00BCD4),
Color(0xFF009688),
Color(0xFF4CAF50),
Color(0xFF8BC34A),
Color(0xFFCDDC39),
Color(0xFFFFEB3B),
Color(0xFFFFC107),
Color(0xFFFF9800),
Color(0xFFFF5722)
)
以上就是完整代码了
总结
本篇到这里就结束了,我们的Compose组件自定义相关的文章也要告一段落了。总体而言,Compose的状态机制管理是一个难点,如果要想非常熟练的自定义Compose 组件,状态问题是绕不过去的,当然,这点也考量对Kotlin的理解深度。
本篇就到这里,希望对你有所帮助。