[英]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.