Android Compose宫格拖拽效果实现

4,446 阅读9分钟

前言

Android View体系中,有很多方法可以实现拖拽效果的方法,比如ViewDragHelper、NestScrolling机制或者最原始的事件处理都是可以的。

在之前的一篇文章中,我们使用RecyclerView + ItemTouchHelper实现了拖拽,于是有同学就问,Compose版本的什么时候才能实现,实际上Compose版本的官方早就实现了。

fire_168.gif

前奏

本篇方案之前,我自己曾尝试利用自定义宫格去实现,核心逻辑参考了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的理解深度。

本篇就到这里,希望对你有所帮助。