From c4cbf5abbc06ddbd4501ea9af064001aa915bc3a Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 10 Oct 2025 17:26:50 -0300 Subject: [PATCH] Initial implementation of repo list re-ordering --- next/src/main/kotlin/org/fdroid/ui/Main.kt | 36 ++- .../fdroid/ui/repositories/Repositories.kt | 33 +-- .../ui/repositories/RepositoriesList.kt | 80 +++++-- .../ui/repositories/RepositoriesPresenter.kt | 21 ++ .../ui/repositories/RepositoriesViewModel.kt | 60 ++++- .../fdroid/ui/repositories/RepositoryInfo.kt | 14 ++ .../fdroid/ui/repositories/RepositoryRow.kt | 3 +- .../org/fdroid/ui/utils/PreviewUtils.kt | 15 ++ .../kotlin/org/fdroid/utils/DragDropState.kt | 224 ++++++++++++++++++ 9 files changed, 424 insertions(+), 62 deletions(-) create mode 100644 next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesPresenter.kt create mode 100644 next/src/main/kotlin/org/fdroid/ui/repositories/RepositoryInfo.kt create mode 100644 next/src/main/kotlin/org/fdroid/utils/DragDropState.kt diff --git a/next/src/main/kotlin/org/fdroid/ui/Main.kt b/next/src/main/kotlin/org/fdroid/ui/Main.kt index 8bf395cc0..56fbe3044 100644 --- a/next/src/main/kotlin/org/fdroid/ui/Main.kt +++ b/next/src/main/kotlin/org/fdroid/ui/Main.kt @@ -47,6 +47,9 @@ import org.fdroid.ui.lists.AppListType import org.fdroid.ui.lists.AppListViewModel import org.fdroid.ui.repositories.Repositories import org.fdroid.ui.repositories.RepositoriesViewModel +import org.fdroid.ui.repositories.RepositoryInfo +import org.fdroid.ui.repositories.RepositoryItem +import org.fdroid.ui.repositories.RepositoryModel import org.fdroid.ui.settings.Settings import org.fdroid.ui.settings.SettingsViewModel @@ -190,18 +193,29 @@ fun Main(onListeningForIntent: () -> Unit = {}) { }, ) { val viewModel = hiltViewModel() - val repos = viewModel.repos.collectAsStateWithLifecycle(null).value - Repositories( - repositories = repos, - currentRepositoryId = if (isBigScreen) { + val info = object : RepositoryInfo { + override val model: RepositoryModel = + viewModel.model.collectAsStateWithLifecycle().value + + override val currentRepositoryId: Long? = if (isBigScreen) { (backStack.last() as? NavigationKey.RepoDetails)?.repoId - } else null, - onRepositorySelected = { - viewModel.setVisibleRepository(it) - backStack.add(NavigationKey.RepoDetails(it.repoId)) - }, - onAddRepo = viewModel::addRepo, - ) { + } else null + + override fun onRepositorySelected(repositoryItem: RepositoryItem) { + backStack.add(NavigationKey.RepoDetails(repositoryItem.repoId)) + } + + override fun onAddRepo() = viewModel.addRepo() + + override fun onRepositoryMoved(fromIndex: Int, toIndex: Int) = + viewModel.onRepositoriesMoved(fromIndex, toIndex) + + override fun onRepositoriesFinishedMoving( + fromRepoId: Long, + toRepoId: Long, + ) = viewModel.onRepositoriesFinishedMoving(fromRepoId, toRepoId) + } + Repositories(info) { backStack.removeLastOrNull() } } diff --git a/next/src/main/kotlin/org/fdroid/ui/repositories/Repositories.kt b/next/src/main/kotlin/org/fdroid/ui/repositories/Repositories.kt index 29bd0bd99..40701d295 100644 --- a/next/src/main/kotlin/org/fdroid/ui/repositories/Repositories.kt +++ b/next/src/main/kotlin/org/fdroid/ui/repositories/Repositories.kt @@ -7,14 +7,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -23,17 +21,12 @@ import androidx.compose.ui.unit.dp import org.fdroid.fdroid.ui.theme.FDroidContent import org.fdroid.next.R import org.fdroid.ui.utils.BigLoadingIndicator +import org.fdroid.ui.utils.getRepositoriesInfo @Composable -@OptIn( - ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class, - ExperimentalMaterial3ExpressiveApi::class -) +@OptIn(ExperimentalMaterial3Api::class) fun Repositories( - repositories: List?, - currentRepositoryId: Long?, - onRepositorySelected: (RepositoryItem) -> Unit, - onAddRepo: () -> Unit, + info: RepositoryInfo, onBackClicked: () -> Unit, ) { Scaffold( @@ -54,7 +47,7 @@ fun Repositories( }, floatingActionButton = { FloatingActionButton( - onClick = onAddRepo, + onClick = info::onAddRepo, modifier = Modifier.padding(16.dp) ) { Icon( @@ -64,13 +57,9 @@ fun Repositories( } } ) { paddingValues -> - if (repositories == null) BigLoadingIndicator() + if (info.model.repositories == null) BigLoadingIndicator() else RepositoriesList( - repositories = repositories, - currentRepositoryId = currentRepositoryId, - onRepositorySelected = { - onRepositorySelected(it) - }, + info = info, modifier = Modifier .padding(paddingValues), ) @@ -84,7 +73,9 @@ fun Repositories( @Composable fun RepositoriesScaffoldLoadingPreview() { FDroidContent { - Repositories(null, null, {}, {}) { } + val model = RepositoryModel(null) + val info = getRepositoriesInfo(model) + Repositories(info) {} } } @@ -111,10 +102,12 @@ fun RepositoriesScaffoldPreview() { timestamp = System.currentTimeMillis(), lastUpdated = null, weight = 2, - enabled = true, + enabled = false, name = "My second repository", ), ) - Repositories(repos, repos[0].repoId, {}, {}) { } + val model = RepositoryModel(repos) + val info = getRepositoriesInfo(model, repos[0].repoId) + Repositories(info) { } } } diff --git a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesList.kt b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesList.kt index 6c48a533b..d203ac08c 100644 --- a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesList.kt +++ b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesList.kt @@ -1,54 +1,92 @@ package org.fdroid.ui.repositories +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import org.fdroid.utils.DraggableItem +import org.fdroid.utils.dragContainer +import org.fdroid.utils.rememberDragDropState @Composable -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) fun RepositoriesList( - repositories: List, - currentRepositoryId: Long?, - onRepositorySelected: (RepositoryItem) -> Unit, + info: RepositoryInfo, modifier: Modifier = Modifier, ) { + val repositories = info.model.repositories ?: return + val currentRepositoryId = info.currentRepositoryId + val listState = rememberLazyListState() + val dragDropState = rememberDragDropState( + lazyListState = listState, + onMove = info::onRepositoryMoved, + onEnd = { from, to -> + from as? Long ?: error("from $from was not a repoId") + to as? Long ?: error("to $to was not a repoId") + info.onRepositoriesFinishedMoving(from, to) + }, + ) LazyColumn( + state = listState, contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = spacedBy(8.dp), - modifier = modifier.then( - if (currentRepositoryId == null) Modifier - else Modifier.selectableGroup() - ), + modifier = modifier + .then( + if (repositories.size > 1) Modifier.dragContainer(dragDropState) + else Modifier + ) + .then( + if (currentRepositoryId == null) Modifier + else Modifier.selectableGroup() + ), ) { - items(repositories) { repoItem -> + itemsIndexed( + items = repositories, + key = { _, item -> item.repoId }, + ) { index, repoItem -> val isSelected = currentRepositoryId == repoItem.repoId val interactionModifier = if (currentRepositoryId == null) { Modifier.clickable( - onClick = { onRepositorySelected(repoItem) } + onClick = { info.onRepositorySelected(repoItem) } ) } else { Modifier.selectable( selected = isSelected, - onClick = { onRepositorySelected(repoItem) } + onClick = { info.onRepositorySelected(repoItem) } + ) + } + DraggableItem(dragDropState, index) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + RepositoryRow( + repoItem = repoItem, + isSelected = isSelected, + modifier = Modifier + .fillMaxWidth() + .then(interactionModifier) + .let { + if (isDragging) it.dropShadow( + shape = RoundedCornerShape(4.dp), + shadow = Shadow( + radius = 8.dp, + offset = DpOffset(x = elevation, elevation) + ) + ) else it + } ) } - RepositoryRow( - repoItem = repoItem, - isSelected = isSelected, - modifier = Modifier - .fillMaxWidth() - .then(interactionModifier), - ) } } } diff --git a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesPresenter.kt b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesPresenter.kt new file mode 100644 index 000000000..1836af7f2 --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesPresenter.kt @@ -0,0 +1,21 @@ +package org.fdroid.ui.repositories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun RepositoriesPresenter( + repositoriesFlow: Flow>, + repoSortingMapFlow: StateFlow>, +): RepositoryModel { + val repositories = repositoriesFlow.collectAsState(null).value + val repoSortingMap = repoSortingMapFlow.collectAsState().value + return RepositoryModel( + repositories = repositories?.sortedBy { repo -> + // newly added repos will not be in repoSortingMap, so they need fallback + repoSortingMap[repo.repoId] ?: repositories.find { it.repoId == repo.repoId }?.weight + }, + ) +} diff --git a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt index ff06fd579..659defc22 100644 --- a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt @@ -3,30 +3,74 @@ package org.fdroid.ui.repositories import android.app.Application import androidx.core.os.LocaleListCompat import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import app.cash.molecule.AndroidUiDispatcher +import app.cash.molecule.RecompositionMode.ContextClock +import app.cash.molecule.launchMolecule import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map +import mu.KotlinLogging import org.fdroid.index.RepoManager import javax.inject.Inject @HiltViewModel class RepositoriesViewModel @Inject constructor( app: Application, - repoManager: RepoManager, + private val repoManager: RepoManager, ) : AndroidViewModel(app) { + private val log = KotlinLogging.logger { } private val localeList = LocaleListCompat.getDefault() - val repos: Flow> = repoManager.repositoriesState.map { repos -> - repos.map { RepositoryItem(it, localeList) } + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + private val repos: Flow> = repoManager.repositoriesState.map { repos -> + repos.mapNotNull { + if (it.isArchiveRepo) null + else RepositoryItem(it, localeList) + } + } + private val repoSortingMap: MutableStateFlow> + + init { + // just add repos to sortingMap, because they are already pre-sorted by weight + val sortingMap = mutableMapOf() + repoManager.getRepositories().forEachIndexed { index, repository -> + sortingMap[repository.repoId] = index + } + repoSortingMap = MutableStateFlow(sortingMap) } - private val _visibleRepositoryItem = MutableStateFlow(null) - val visibleRepositoryItem = _visibleRepositoryItem.asStateFlow() + // define below init, because this only defines repoSortingMap + val model: StateFlow = moleculeScope.launchMolecule(mode = ContextClock) { + RepositoriesPresenter( + repositoriesFlow = repos, + repoSortingMapFlow = repoSortingMap, + ) + } - fun setVisibleRepository(repositoryItem: RepositoryItem?) { - _visibleRepositoryItem.value = repositoryItem + fun onRepositoriesMoved(fromIndex: Int, toIndex: Int) { + log.info { "onRepositoriesMoved($fromIndex, $toIndex)" } + val repoItems = model.value.repositories ?: error("Model had null repositories") + val fromItem = repoItems[fromIndex] + val toItem = repoItems[toIndex] + repoSortingMap.value = repoSortingMap.value.toMutableMap().apply { + replace(fromItem.repoId, toIndex) + replace(toItem.repoId, fromIndex) + } + } + + fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) { + log.info { "onRepositoriesFinishedMoving($fromRepoId, $toRepoId)" } + val fromRepo = repoManager.getRepository(fromRepoId) + ?: error("No repo for repoId $fromRepoId") + val toRepo = repoManager.getRepository(toRepoId) + ?: error("No repo for repoId $toRepoId") + log.info { " ${fromRepo.address} => ${toRepo.address}" } + repoManager.reorderRepositories(fromRepo, toRepo) } fun addRepo() { diff --git a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoryInfo.kt b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoryInfo.kt new file mode 100644 index 000000000..2e28ce763 --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoryInfo.kt @@ -0,0 +1,14 @@ +package org.fdroid.ui.repositories + +interface RepositoryInfo { + val model: RepositoryModel + val currentRepositoryId: Long? + fun onRepositorySelected(repositoryItem: RepositoryItem) + fun onAddRepo() + fun onRepositoryMoved(fromIndex: Int, toIndex: Int) + fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) +} + +data class RepositoryModel( + val repositories: List?, +) diff --git a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoryRow.kt b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoryRow.kt index 08072696b..a3f52bf44 100644 --- a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoryRow.kt +++ b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoryRow.kt @@ -8,7 +8,6 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import org.fdroid.ui.utils.AsyncShimmerImage @@ -39,7 +38,7 @@ fun RepositoryRow( containerColor = if (isSelected) { MaterialTheme.colorScheme.surfaceVariant } else { - Color.Transparent + MaterialTheme.colorScheme.background } ), modifier = modifier, diff --git a/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt b/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt index c14f40aa6..baa881630 100644 --- a/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt +++ b/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt @@ -19,6 +19,9 @@ import org.fdroid.ui.lists.AppListActions import org.fdroid.ui.lists.AppListInfo import org.fdroid.ui.lists.AppListModel import org.fdroid.ui.lists.AppListType +import org.fdroid.ui.repositories.RepositoryInfo +import org.fdroid.ui.repositories.RepositoryItem +import org.fdroid.ui.repositories.RepositoryModel import java.util.concurrent.TimeUnit.DAYS object Names { @@ -219,3 +222,15 @@ fun getMyAppsInfo(model: MyAppsModel): MyAppsInfo = object : MyAppsInfo { ) { } } + +fun getRepositoriesInfo( + model: RepositoryModel, + currentRepositoryId: Long? = null, +): RepositoryInfo = object : RepositoryInfo { + override val model: RepositoryModel = model + override val currentRepositoryId: Long? = currentRepositoryId + override fun onRepositorySelected(repositoryItem: RepositoryItem) {} + override fun onAddRepo() {} + override fun onRepositoryMoved(fromIndex: Int, toIndex: Int) {} + override fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) {} +} diff --git a/next/src/main/kotlin/org/fdroid/utils/DragDropState.kt b/next/src/main/kotlin/org/fdroid/utils/DragDropState.kt new file mode 100644 index 000000000..10249d639 --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/utils/DragDropState.kt @@ -0,0 +1,224 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Taken from (and then modified to provide onEnd callback): + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt;drc=e6d33dd5d0a60001a5784d84123b05308d35f410 + */ + +package org.fdroid.utils + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +@Composable +fun rememberDragDropState( + lazyListState: LazyListState, + onMove: (Int, Int) -> Unit, + onEnd: (Any, Any) -> Unit +): DragDropState { + val scope = rememberCoroutineScope() + val state = remember(lazyListState) { + DragDropState(state = lazyListState, onMove = onMove, onEnd = onEnd, scope = scope) + } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + lazyListState.scrollBy(diff) + } + } + return state +} + +class DragDropState internal constructor( + private val state: LazyListState, + private val scope: CoroutineScope, + private val onMove: (Int, Int) -> Unit, + private val onEnd: (Any, Any) -> Unit, +) { + private var movedFrom: Int? = null + private var movedFromKey: Any? = null + private var movedToKey: Any? = null + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableFloatStateOf(0f) + private var draggingItemInitialOffset by mutableIntStateOf(0) + internal val draggingItemOffset: Float + get() = + draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f + + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } + + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set + + internal var previousItemOffset = Animatable(0f) + private set + + internal fun onDragStart(offset: Offset) { + state.layoutInfo.visibleItemsInfo + .firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) } + ?.also { + movedFrom = it.index + movedFromKey = it.key + draggingItemIndex = it.index + draggingItemInitialOffset = it.offset + } + } + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, + spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f), + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = 0f + draggingItemIndex = null + draggingItemInitialOffset = 0 + movedFrom = null + movedFromKey = null + movedToKey = null + } + + internal fun onDragEnd() { + val from = movedFromKey ?: error("Moved from was null") + val to = movedToKey ?: from + if (from != to) onEnd(from, to) + onDragInterrupted() + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset.y + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = + state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && + draggingItem.index != item.index + } + if (targetItem != null) { + if ( + draggingItem.index == state.firstVisibleItemIndex || + targetItem.index == state.firstVisibleItemIndex + ) { + state.requestScrollToItem( + state.firstVisibleItemIndex, + state.firstVisibleItemScrollOffset, + ) + } + onMove.invoke(draggingItem.index, targetItem.index) + draggingItemIndex = targetItem.index + movedToKey = targetItem.key + } else { + val overscroll = + when { + draggingItemDraggedDelta > 0 -> + (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + draggingItemDraggedDelta < 0 -> + (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyListItemInfo.offsetEnd: Int + get() = this.offset + this.size +} + +fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { + return pointerInput(dragDropState) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragEnd() }, + onDragCancel = { dragDropState.onDragInterrupted() }, + ) + } +} + +@Composable +fun LazyItemScope.DraggableItem( + dragDropState: DragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(isDragging: Boolean) -> Unit, +) { + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = + if (dragging) { + LocalHapticFeedback.current.performHapticFeedback(HapticFeedbackType.LongPress) + Modifier + .zIndex(1f) + .graphicsLayer { translationY = dragDropState.draggingItemOffset } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationY = dragDropState.previousItemOffset.value + } + } else { + Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) + } + Column(modifier = modifier.then(draggingModifier)) { content(dragging) } +}