[英]Reorder LazyColumn items with drag & drop
我想创建一个包含可以通过拖放重新排序的项目的LazyColumn
。 如果没有撰写,我的方法是使用ItemTouchHelper.SimpleCallback
,但我还没有找到类似的撰写。
我尝试过使用Modifier.longPressDragGestureFilter
和Modifier.draggable
,但这仅允许我使用偏移量拖动卡片。 它没有给我一个列表索引(如fromPosition
ItemTouchHelper.SimpleCallback
toPosition
,我需要交换列表中的项目。
是否有相当于ItemTouchHelper.SimpleCallback
的onMove
function 的组合? 如果没有,这是计划中的功能吗?
自己尝试和实施这种事情是否可能/可行?
到目前为止,我可以告诉 Compose 还没有提供处理这个问题的方法,尽管我假设这将在工作中,因为他们已经添加了draggable 和 longPressDragGestureFilter 修饰符,正如您已经提到的。 因为他们已经添加了这些,也许这是在惰性列中拖放的前兆。
2021 年 2 月,谷歌提出了一个问题,他们的回应是他们不会为 1.0 版本提供官方解决方案,尽管在此期间他们提供了一些有关如何处理解决方案的指导。 现在看起来最好的解决方案是使用 ItemTouchHelper 使用 RecyclerView。
这是提到的问题: https : //issuetracker.google.com/issues/181282427
可以使用detectDragGesturesAfterLongPress
和rememberLazyListState
构建一个简单(不完美)的可重排序列表。
基本思想是向 LazyColumn 添加拖动手势修改器并检测我们自己的拖动项,而不是为每个项添加修改器。
val listState: LazyListState = rememberLazyListState()
...
LazyColumn(
state = listState,
modifier = Modifier.pointerInput(Unit) {
detectDragGesturesAfterLongPress(....)
使用 LazyListState 提供的 layoutInfo 查找项目:
var position by remember {
mutableStateOf<Float?>(null)
}
...
onDragStart = { offset ->
listState.layoutInfo.visibleItemsInfo
.firstOrNull { offset.y.toInt() in it.offset..it.offset + it.size }
?.also {
position = it.offset + it.size / 2f
}
}
更新每次拖动的位置:
onDrag = { change, dragAmount ->
change.consumeAllChanges()
position = position?.plus(dragAmount.y)
// Start autoscrolling if position is out of bounds
}
为了在滚动时支持重新排序,我们不能在 onDrag 中进行onDrag
。 为此,我们创建了一个流程来在每个位置/滚动更新中找到最近的项目:
var draggedItem by remember {
mutableStateOf<Int?>(null)
}
....
snapshotFlow { listState.layoutInfo }
.combine(snapshotFlow { position }.distinctUntilChanged()) { state, pos ->
pos?.let { draggedCenter ->
state.visibleItemsInfo
.minByOrNull { (draggedCenter - (it.offset + it.size / 2f)).absoluteValue }
}?.index
}
.distinctUntilChanged()
.collect { near -> ...}
更新拖动的项目索引并移动 MutableStateList 中的项目。
draggedItem = when {
near == null -> null
draggedItem == null -> near
else -> near.also { items.move(draggedItem, it) }
}
fun <T> MutableList<T>.move(fromIdx: Int, toIdx: Int) {
if (toIdx > fromIdx) {
for (i in fromIdx until toIdx) {
this[i] = this[i + 1].also { this[i + 1] = this[i] }
}
} else {
for (i in fromIdx downTo toIdx + 1) {
this[i] = this[i - 1].also { this[i - 1] = this[i] }
}
}
}
计算相对项目偏移量:
val indexWithOffset by derivedStateOf {
draggedItem
?.let { listState.layoutInfo.visibleItemsInfo.getOrNull(it - listState.firstVisibleItemIndex) }
?.let { Pair(it.index, (position ?: 0f) - it.offset - it.size / 2f) }
}
然后可以用于将偏移应用于拖动的项目(不要使用项目键!):
itemsIndexed(items) { idx, item ->
val offset by remember {
derivedStateOf { state.indexWithOffset?.takeIf { it.first == idx }?.second }
}
Column(
modifier = Modifier
.zIndex(offset?.let { 1f } ?: 0f)
.graphicsLayer {
translationY = offset ?: 0f
}
)
....
}
可以在此处找到示例实现
基于谷歌示例和 Medium 中的一些帖子,我想出了这个实现:
DragDropColumn.kt ->
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T : Any> DragDropColumn(
items: List<T>,
onSwap: (Int, Int) -> Unit,
itemContent: @Composable LazyItemScope.(item: T) -> Unit
) {
var overscrollJob by remember { mutableStateOf<Job?>(null) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val dragDropState = rememberDragDropState(listState) { fromIndex, toIndex ->
onSwap(fromIndex, toIndex)
}
LazyColumn(
modifier = Modifier
.pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
if (overscrollJob?.isActive == true)
return@detectDragGesturesAfterLongPress
dragDropState
.checkForOverScroll()
.takeIf { it != 0f }
?.let {
overscrollJob =
scope.launch {
dragDropState.state.animateScrollBy(
it*1.3f, tween(easing = FastOutLinearInEasing)
)
}
}
?: run { overscrollJob?.cancel() }
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = {
dragDropState.onDragInterrupted()
overscrollJob?.cancel()
},
onDragCancel = {
dragDropState.onDragInterrupted()
overscrollJob?.cancel()
}
)
},
state = listState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(items = items) { index, item ->
DraggableItem(
dragDropState = dragDropState,
index = index
) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Card(elevation = elevation) {
itemContent(item)
}
}
}
}
}
DragDropState.kt ->
class DragDropState internal constructor(
val state: LazyListState,
private val scope: CoroutineScope,
private val onSwap: (Int, Int) -> Unit
) {
private var draggedDistance by mutableStateOf(0f)
private var draggingItemInitialOffset by mutableStateOf(0)
internal val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggedDistance - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == currentIndexOfDraggedItem }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
// used to obtain initial offsets on drag start
private var initiallyDraggedElement by mutableStateOf<LazyListItemInfo?>(null)
var currentIndexOfDraggedItem by mutableStateOf<Int?>(null)
private val initialOffsets: Pair<Int, Int>?
get() = initiallyDraggedElement?.let { Pair(it.offset, it.offsetEnd) }
private val currentElement: LazyListItemInfo?
get() = currentIndexOfDraggedItem?.let {
state.getVisibleItemInfoFor(absoluteIndex = it)
}
fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
draggingItemInitialOffset = it.offset
}
}
fun onDragInterrupted() {
if (currentIndexOfDraggedItem != null) {
previousIndexOfDraggedItem = currentIndexOfDraggedItem
// val startOffset = draggingItemOffset
scope.launch {
//previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f,
tween(easing = FastOutLinearInEasing)
)
previousIndexOfDraggedItem = null
}
}
draggingItemInitialOffset = 0
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
}
fun onDrag(offset: Offset) {
draggedDistance += offset.y
initialOffsets?.let { (topOffset, bottomOffset) ->
val startOffset = topOffset + draggedDistance
val endOffset = bottomOffset + draggedDistance
currentElement?.let { hovered ->
state.layoutInfo.visibleItemsInfo
.filterNot { item -> item.offsetEnd < startOffset || item.offset > endOffset || hovered.index == item.index }
.firstOrNull { item ->
val delta = (startOffset - hovered.offset)
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
scope.launch {
onSwap.invoke(
current,
item.index
)
}
}
currentIndexOfDraggedItem = item.index
}
}
}
}
fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
return@let when {
draggedDistance > 0 -> (endOffset - state.layoutInfo.viewportEndOffset+50f).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - state.layoutInfo.viewportStartOffset-50f).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
}
DragDropExt.kt ->
@Composable
fun rememberDragDropState(
lazyListState: LazyListState,
onSwap: (Int, Int) -> Unit
): DragDropState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState) {
DragDropState(
state = lazyListState,
onMove = onSwap,
scope = scope
)
}
return state
}
fun LazyListState.getVisibleItemInfoFor(absoluteIndex: Int): LazyListItemInfo? {
return this
.layoutInfo
.visibleItemsInfo
.getOrNull(absoluteIndex - this.layoutInfo.visibleItemsInfo.first().index)
}
val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
@ExperimentalFoundationApi
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
) {
val current: Float by animateFloatAsState(dragDropState.draggingItemOffset * 0.67f)
val previous: Float by animateFloatAsState(dragDropState.previousItemOffset.value * 0.67f)
val dragging = index == dragDropState.currentIndexOfDraggedItem
val draggingModifier = if (dragging) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = current
}
} else if (index == dragDropState.previousIndexOfDraggedItem) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = previous
}
} else {
Modifier.animateItemPlacement(
tween(easing = FastOutLinearInEasing)
)
}
Column(modifier = modifier.then(draggingModifier)) {
content(dragging)
}
}
使用它看起来像:
@Composable
fun SectionListUI() {
val viewModel: SectionListViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsState()
DragDropColumn(items = uiState.sections, onSwap = viewModel::swapSections) { item ->
Card(
modifier = Modifier
.clickable { viewModel.sectionClick(item) },
) {
Text(
text = item.title,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
}
}
此实现非常适合我的用例。但是,请随时发表评论并进行改进
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.