Restore quick search item drag and drop animation

This commit is contained in:
FooIbar committed 2023-11-24 15:48:47 +08:00
1 parent 17a9c02f96
commit 6e541be6ca
5 files changed
+85 -244

No files matched your search

+2
View File
@@ -252,6 +252,8 @@ dependencies {
implementation(libs.insetter) // Dead Dependency
implementation(libs.reorderable)
implementation(platform(libs.arrow.stack))
implementation(libs.arrow.fx.coroutines)
@@ -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<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
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<Int?>(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)
}
}
@@ -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,
)
}
}
}
}
}
@@ -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
+2
View File
@@ -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" }