diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b5ee274f..7b956eeb3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -252,6 +252,8 @@ dependencies { implementation(libs.insetter) // Dead Dependency + implementation(libs.reorderable) + implementation(platform(libs.arrow.stack)) implementation(libs.arrow.fx.coroutines) diff --git a/app/src/main/kotlin/androidx/compose/foundation/DragAndDrop.kt b/app/src/main/kotlin/androidx/compose/foundation/DragAndDrop.kt deleted file mode 100644 index b11587443..000000000 --- a/app/src/main/kotlin/androidx/compose/foundation/DragAndDrop.kt +++ /dev/null @@ -1,213 +0,0 @@ -/* - * 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. - */ - -package androidx.compose.foundation - -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.input.pointer.pointerInput -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, -): DragDropState { - val scope = rememberCoroutineScope() - val state = remember(lazyListState) { - DragDropState( - state = lazyListState, - onMove = onMove, - 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, -) { - 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 { - 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 - } - - 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) { - val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) { - draggingItem.index - } else if (draggingItem.index == state.firstVisibleItemIndex) { - targetItem.index - } else { - null - } - if (scrollToIndex != null) { - scope.launch { - // this is needed to neutralize automatic keeping the first item first. - state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) - onMove.invoke(draggingItem.index, targetItem.index) - } - } else { - onMove.invoke(draggingItem.index, targetItem.index) - } - draggingItemIndex = targetItem.index - } 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 this.pointerInput(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 LazyItemScope.DraggableItem( - dragDropState: DragDropState, - index: Int, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.(isDragging: Boolean) -> Unit, -) { - val dragging = index == dragDropState.draggingItemIndex - val draggingModifier = if (dragging) { - Modifier - .zIndex(1f) - .graphicsLayer { - translationY = dragDropState.draggingItemOffset - } - } else if (index == dragDropState.previousIndexOfDraggedItem) { - Modifier.zIndex(1f) - .graphicsLayer { - translationY = dragDropState.previousItemOffset.value - } - } else { - Modifier.animateItemPlacement() - } - Column(modifier = modifier.then(draggingModifier)) { - content(dragging) - } -} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListFragment.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListFragment.kt index 5b5957b69..c0aa10c6b 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListFragment.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListFragment.kt @@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.scene import android.graphics.Bitmap import android.net.Uri import android.os.Bundle +import android.view.HapticFeedbackConstants import android.view.LayoutInflater import android.view.View import android.view.ViewConfiguration @@ -10,9 +11,7 @@ import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween -import androidx.compose.foundation.DraggableItem import androidx.compose.foundation.clickable -import androidx.compose.foundation.dragContainer import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,11 +32,10 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState -import androidx.compose.foundation.rememberDragDropState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text2.input.rememberTextFieldState import androidx.compose.foundation.text2.input.setTextAndPlaceCursorAtEnd @@ -70,6 +68,7 @@ import androidx.compose.material3.rememberDismissState import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable @@ -94,6 +93,8 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource @@ -171,6 +172,9 @@ import com.hippo.ehviewer.util.AppConfig import com.hippo.ehviewer.util.ExceptionUtils import com.hippo.ehviewer.util.findActivity import com.hippo.ehviewer.util.getParcelableCompat +import com.hippo.ehviewer.util.isAtLeastOMR1 +import com.hippo.ehviewer.util.isAtLeastR +import com.hippo.ehviewer.util.isAtLeastU import com.hippo.ehviewer.util.pickVisualMedia import com.ramcosta.composedestinations.annotation.Destination import eu.kanade.tachiyomi.util.lang.launchIO @@ -188,6 +192,8 @@ import kotlin.math.roundToInt import kotlinx.coroutines.delay import kotlinx.coroutines.launch import moe.tarsin.coroutines.runSuspendCatching +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyColumnState @Destination @Composable @@ -392,10 +398,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: NavController) { val quickSearch = urlBuilder.toQuickSearch(text) quickSearch.position = quickSearchList.size - quickSearchList.add(quickSearch) - launchIO { + // Insert to DB first to update the id + withIOContext { EhDB.insertQuickSearch(quickSearch) } + quickSearchList.add(quickSearch) saveProgress = checked return@awaitInputTextWithCheckBox null } @@ -409,9 +416,12 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: NavController) { }, windowInsets = WindowInsets(0), ) + val view = LocalView.current Box(modifier = Modifier.fillMaxSize()) { val quickSearchListState = rememberLazyListState() - val dragDropState = rememberDragDropState(quickSearchListState) { fromIndex, toIndex -> + val reorderableLazyListState = rememberReorderableLazyColumnState(quickSearchListState) { from, to -> + val fromIndex = from.index + val toIndex = to.index quickSearchList.apply { add(toIndex, removeAt(fromIndex)) } @@ -421,30 +431,39 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: NavController) { list.zip(range).forEach { it.first.position = it.second } EhDB.updateQuickSearch(list) } + val feedbackConstant = if (isAtLeastU) { + HapticFeedbackConstants.SEGMENT_FREQUENT_TICK + } else if (isAtLeastOMR1) { + HapticFeedbackConstants.TEXT_HANDLE_MOVE + } else { + HapticFeedbackConstants.CLOCK_TICK + } + view.performHapticFeedback(feedbackConstant) } LazyColumn( - modifier = Modifier.fillMaxSize().dragContainer(dragDropState), + modifier = Modifier.fillMaxSize(), state = quickSearchListState, ) { - itemsIndexed(quickSearchList) { index, item -> - val dismissState = rememberDismissState( - confirmValueChange = { - if (it == DismissValue.DismissedToStart) { - quickSearchList.removeAt(index) - coroutineScope.launchIO { - EhDB.deleteQuickSearch(item) + items(quickSearchList, key = { it.id!! }) { item -> + ReorderableItem(reorderableLazyListState, key = item.id!!) { + val dismissState = rememberDismissState( + confirmValueChange = { + if (it == DismissValue.DismissedToStart) { + quickSearchList.remove(item) + coroutineScope.launchIO { + EhDB.deleteQuickSearch(item) + } } - } - true - }, - ) - LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) { - SwipeToDismissBox( - state = dismissState, - backgroundContent = {}, - directions = setOf(DismissDirection.EndToStart), - ) { - DraggableItem(dragDropState = dragDropState, index = index) { + true + }, + ) + val viewConfiguration = LocalViewConfiguration.current + LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) { + SwipeToDismissBox( + state = dismissState, + backgroundContent = {}, + directions = setOf(DismissDirection.EndToStart), + ) { Row( modifier = Modifier.clickable { if (urlBuilder.mode == MODE_WHATS_HOT) { @@ -454,17 +473,47 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: NavController) { } showSearchLayout = false coroutineScope.launch { sheetState.close() } - }.fillMaxWidth().minimumInteractiveComponentSize().padding(horizontal = 16.dp), + }.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = item.name, modifier = Modifier.weight(1f), ) - Icon( - imageVector = Icons.Default.Reorder, - contentDescription = null, - ) + CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) { + IconButton( + onClick = {}, + modifier = Modifier.draggableHandle( + onDragStarted = { + val feedbackConstant = if (isAtLeastU) { + HapticFeedbackConstants.DRAG_START + } else if (isAtLeastR) { + HapticFeedbackConstants.GESTURE_START + } else if (isAtLeastOMR1) { + HapticFeedbackConstants.KEYBOARD_PRESS + } else { + HapticFeedbackConstants.VIRTUAL_KEY + } + view.performHapticFeedback(feedbackConstant) + }, + onDragStopped = { + val feedbackConstant = if (isAtLeastR) { + HapticFeedbackConstants.GESTURE_END + } else if (isAtLeastOMR1) { + HapticFeedbackConstants.KEYBOARD_RELEASE + } else { + HapticFeedbackConstants.VIRTUAL_KEY_RELEASE + } + view.performHapticFeedback(feedbackConstant) + }, + ), + ) { + Icon( + imageVector = Icons.Default.Reorder, + contentDescription = null, + ) + } + } } } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/util/SDKUtils.kt b/app/src/main/kotlin/com/hippo/ehviewer/util/SDKUtils.kt index 64c943654..71f2f6ab2 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/util/SDKUtils.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/util/SDKUtils.kt @@ -5,6 +5,7 @@ import android.os.ext.SdkExtensions val isAtLeastN = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N val isAtLeastO = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O +val isAtLeastOMR1 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 val isAtLeastP = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P val isAtLeastQ = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q val isAtLeastR = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9b61c7d7..b45d9e04b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -124,6 +124,8 @@ photoview = "com.github.chrisbanes:PhotoView:2.3.0" r8 = { module = "com.android.tools:r8", version.ref = "android-tools-r8" } +reorderable = "sh.calvin.reorderable:reorderable:1.0.1" + splitties-appctx = { module = "com.louiscad.splitties:splitties-appctx", version.ref = "splitties" } splitties-arch-room = { module = "com.louiscad.splitties:splitties-arch-room", version.ref = "splitties" } splitties-preferences = { module = "com.louiscad.splitties:splitties-preferences", version.ref = "splitties" }