Restore quick search item drag and drop animation
This commit is contained in:
1 parent
17a9c02f96
commit
6e541be6ca
5 files changed
+85
-244
No files matched your search
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in new issue
Block a user