diff --git a/.gitmodules b/.gitmodules index 8a254ab5e..7eada7a23 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "app/shared/reorderable"] - path = app/shared/thirdparty/reorderable - url = https://github.com/open-ani/ComposeReorderable.git [submodule "torrent/anitorrent/anitorrent-native/libs/boost"] path = torrent/anitorrent/anitorrent-native/libs/boost url = https://github.com/open-ani/boost.git diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 86c2c18db..77715d62e 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -21,7 +21,6 @@ - \ No newline at end of file diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 9d613d4c9..4f0fd18d6 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -85,7 +85,7 @@ kotlin { api(libs.compose.navigation.runtime) api(libs.compose.material3.adaptive.navigation.suite) implementation(compose.components.resources) - implementation(libs.reorderable) + implementation(projects.app.shared.reorderable) // Data sources api(projects.datasource.datasourceApi) diff --git a/app/shared/thirdparty/reorderable/LICENSE b/app/shared/thirdparty/reorderable/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/app/shared/thirdparty/reorderable/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/app/shared/thirdparty/reorderable/README.md b/app/shared/thirdparty/reorderable/README.md new file mode 100644 index 000000000..45b34c39a --- /dev/null +++ b/app/shared/thirdparty/reorderable/README.md @@ -0,0 +1,124 @@ +**Note:** *gitflow will be used for this project. Make sure your PRs are against the develop +branch.* + +# Compose LazyList/Grid reorder + +[![Latest release](https://img.shields.io/github/v/release/aclassen/ComposeReorderable?color=brightgreen&label=latest%20release)](https://github.com/aclassen/ComposeReorderable/releases/latest) + +A Jetpack Compose (Android + Desktop) modifier enabling reordering by drag and drop in a LazyList +and LazyGrid. + +![Sample](readme/sample.gif) + +## Download + +``` +dependencies { + implementation("org.burnoutcrew.composereorderable:reorderable:") +} +``` + +## How to use + +- Create a reorderable state by `rememberReorderableLazyListState` for LazyList or + `rememberReorderableLazyGridState` for LazyGrid +- Add the `reorderable(state)` modifier to your list/grid +- Inside the list/grid itemscope create a `ReorderableItem(state, key = )` for a keyed lists or + `ReorderableItem(state, index = )` for a indexed only list. (Animated items only work with keyed + lists) +- Apply the `detectReorderAfterLongPress(state)` or `detectReorder(state)` modifier to the list. + If only a drag handle is needed apply the detect modifier to any child composable inside the item + layout. + +`ReorderableItem` provides the item dragging state, use this to apply elevation , scale etc. + +```kotlin +@Composable +fun VerticalReorderList() { + val data = remember { mutableStateOf(List(100) { "Item $it" }) } + val state = rememberReorderableLazyListState(onMove = { from, to -> + data.value = data.value.toMutableList().apply { + add(to.index, removeAt(from.index)) + } + }) + LazyColumn( + state = state.listState, + modifier = Modifier + .reorderable(state) + .detectReorderAfterLongPress(state) + ) { + items(data.value, { it }) { item -> + ReorderableItem(state, key = item) { isDragging -> + val elevation = animateDpAsState(if (isDragging) 16.dp else 0.dp) + Column( + modifier = Modifier + .shadow(elevation.value) + .background(MaterialTheme.colors.surface) + ) { + Text(item) + } + } + } + } +} + +``` + +The item placement and drag cancelled animation can be changed or disabled by +`dragCancelledAnimation` and `defaultDraggingModifier` + +```kotlin +@Composable +fun VerticalReorderGrid() { + val data = remember { mutableStateOf(List(100) { "Item $it" }) } + val state = rememberReorderableLazyGridState(dragCancelledAnimation = NoDragCancelledAnimation(), + onMove = { from, to -> + data.value = data.value.toMutableList().apply { + add(to.index, removeAt(from.index)) + } + }) + LazyVerticalGrid( + columns = GridCells.Fixed(4), + state = state.gridState, + modifier = Modifier.reorderable(state) + ) { + items(data.value, { it }) { item -> + ReorderableItem(state, key = item, defaultDraggingModifier = Modifier) { isDragging -> + Box( + modifier = Modifier + .aspectRatio(1f) + .background(MaterialTheme.colors.surface) + ) { + Text(text = item, + modifier = Modifier.detectReorderAfterLongPress(state) + ) + } + } + } + } +} +``` + +Check out the sample app for different implementation samples. + +## Notes + +It's a known issue that the first visible item does not animate. + +## License + +``` +Copyright 2022 André Claßen + +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 + + https://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. +``` diff --git a/app/shared/thirdparty/reorderable/build.gradle.kts b/app/shared/thirdparty/reorderable/build.gradle.kts new file mode 100644 index 000000000..f68cda900 --- /dev/null +++ b/app/shared/thirdparty/reorderable/build.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +plugins { + kotlin("multiplatform") + id("com.android.library") + kotlin("plugin.compose") + id("org.jetbrains.compose") + + `ani-mpp-lib-targets` +} + +group = "org.burnoutcrew.composereorderable" +version = "0.9.7" + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(compose.foundation) + implementation(compose.animation) + implementation(compose.uiUtil) + } + } + } +} + +android { + namespace = "me.him188.ani.app.reorderable" +} diff --git a/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt new file mode 100644 index 000000000..88b6c2a7d --- /dev/null +++ b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ +package org.burnoutcrew.reorderable + +import androidx.compose.foundation.gestures.awaitDragOrCancellation +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow + +fun Modifier.detectReorder(state: ReorderableState<*>) = detect(state) { + awaitDragOrCancellation(it) +} + +fun Modifier.detectReorderAfterLongPress(state: ReorderableState<*>) = detect(state) { + awaitLongPressOrCancellation(it) +} + + +private fun Modifier.detect( + state: ReorderableState<*>, + detect: suspend AwaitPointerEventScope.(PointerId) -> PointerInputChange? +) = composed { + + val itemPosition = remember { mutableStateOf(Offset.Zero) } + + Modifier.onGloballyPositioned { itemPosition.value = it.positionInWindow() }.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val start = detect(down.id) + + if (start != null) { + val relativePosition = itemPosition.value - state.layoutWindowPosition.value + start.position + state.onDragStart(relativePosition.x.toInt(), relativePosition.y.toInt()) + } + } + } +} diff --git a/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragCancelledAnimation.kt b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragCancelledAnimation.kt new file mode 100644 index 000000000..432f7a371 --- /dev/null +++ b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragCancelledAnimation.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ +package org.burnoutcrew.reorderable + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset + +interface DragCancelledAnimation { + suspend fun dragCancelled(position: ItemPosition, offset: Offset) + val position: ItemPosition? + val offset: Offset +} + +class NoDragCancelledAnimation : DragCancelledAnimation { + override suspend fun dragCancelled(position: ItemPosition, offset: Offset) {} + override val position: ItemPosition? = null + override val offset: Offset = Offset.Zero +} + +class SpringDragCancelledAnimation(private val stiffness: Float = Spring.StiffnessMediumLow) : DragCancelledAnimation { + private val animatable = Animatable(Offset.Zero, Offset.VectorConverter) + override val offset: Offset + get() = animatable.value + + override var position by mutableStateOf(null) + private set + + override suspend fun dragCancelled(position: ItemPosition, offset: Offset) { + this.position = position + animatable.snapTo(offset) + animatable.animateTo( + Offset.Zero, + spring(stiffness = stiffness, visibilityThreshold = Offset.VisibilityThreshold), + ) + this.position = null + } +} \ No newline at end of file diff --git a/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ItemPosition.kt b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ItemPosition.kt new file mode 100644 index 000000000..e86c0dbbb --- /dev/null +++ b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ItemPosition.kt @@ -0,0 +1,12 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package org.burnoutcrew.reorderable + +data class ItemPosition(val index: Int, val key: Any?) \ No newline at end of file diff --git a/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt new file mode 100644 index 000000000..9b3e3be58 --- /dev/null +++ b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ +package org.burnoutcrew.reorderable + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow + +fun Modifier.reorderable( + state: ReorderableState<*> +) = then( + Modifier.onGloballyPositioned { state.layoutWindowPosition.value = it.positionInWindow() }.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + + val dragResult = drag(down.id) { + if (state.draggingItemIndex != null) { + state.onDrag(it.positionChange().x.toInt(), it.positionChange().y.toInt()) + it.consume() + } + } + + if (dragResult) { + // consume up if we quit drag gracefully with the up + currentEvent.changes.forEach { + if (it.changedToUp()) it.consume() + } + } + + state.onDragCanceled() + } + }, +) \ No newline at end of file diff --git a/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableItem.kt b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableItem.kt new file mode 100644 index 000000000..8df680957 --- /dev/null +++ b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableItem.kt @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ +package org.burnoutcrew.reorderable + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.zIndex + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LazyItemScope.ReorderableItem( + reorderableState: ReorderableState<*>, + key: Any?, + modifier: Modifier = Modifier, + index: Int? = null, + orientationLocked: Boolean = true, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit +) = ReorderableItem( + reorderableState, + key, + modifier, + Modifier.animateItemPlacement(), + orientationLocked, + index, + content, +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LazyGridItemScope.ReorderableItem( + reorderableState: ReorderableState<*>, + key: Any?, + modifier: Modifier = Modifier, + index: Int? = null, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit +) = ReorderableItem( + reorderableState, + key, + modifier, + Modifier.animateItemPlacement(), + false, + index, + content, +) + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LazyStaggeredGridItemScope.ReorderableItem( + reorderableState: ReorderableState<*>, + key: Any?, + modifier: Modifier = Modifier, + index: Int? = null, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit +) = ReorderableItem( + reorderableState, + key, + modifier, + Modifier.animateDraggeableItemPlacement(), + false, + index, + content, +) + +/** + * XXX LazyGridItemScope.Modifier.animateItemPlacement is missing from LazyStaggeredGridItemScope + * XXX Replace this when added to the compose library. + * + * @param animationSpec a finite animation that will be used to animate the item placement. + */ +@ExperimentalFoundationApi +fun Modifier.animateDraggeableItemPlacement( + animationSpec: FiniteAnimationSpec = spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold, + ) +): Modifier = this + +@Composable +fun ReorderableItem( + state: ReorderableState<*>, + key: Any?, + modifier: Modifier = Modifier, + defaultDraggingModifier: Modifier = Modifier, + orientationLocked: Boolean = true, + index: Int? = null, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit +) { + val isDragging = if (index != null) { + index == state.draggingItemIndex + } else { + key == state.draggingItemKey + } + val draggingModifier = + if (isDragging) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationX = + if (!orientationLocked || !state.isVerticalScroll) state.draggingItemLeft else 0f + translationY = + if (!orientationLocked || state.isVerticalScroll) state.draggingItemTop else 0f + } + } else { + val cancel = if (index != null) { + index == state.dragCancelledAnimation.position?.index + } else { + key == state.dragCancelledAnimation.position?.key + } + if (cancel) { + Modifier.zIndex(1f) + .graphicsLayer { + translationX = + if (!orientationLocked || !state.isVerticalScroll) state.dragCancelledAnimation.offset.x else 0f + translationY = + if (!orientationLocked || state.isVerticalScroll) state.dragCancelledAnimation.offset.y else 0f + } + } else { + defaultDraggingModifier + } + } + Box(modifier = modifier.then(draggingModifier)) { + content(isDragging) + } +} diff --git a/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyGridState.kt b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyGridState.kt new file mode 100644 index 000000000..29d2c6374 --- /dev/null +++ b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyGridState.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ +package org.burnoutcrew.reorderable + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope + +@Composable +fun rememberReorderableLazyGridState( + onMove: (ItemPosition, ItemPosition) -> Unit, + gridState: LazyGridState = rememberLazyGridState(), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + maxScrollPerFrame: Dp = 20.dp, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() +): ReorderableLazyGridState { + val maxScroll = with(LocalDensity.current) { maxScrollPerFrame.toPx() } + val scope = rememberCoroutineScope() + val state = remember(gridState) { + ReorderableLazyGridState( + gridState, + scope, + maxScroll, + onMove, + canDragOver, + onDragStart, + onDragEnd, + dragCancelledAnimation, + ) + } + LaunchedEffect(state) { + state.visibleItemsChanged() + .collect { state.onDrag(0, 0) } + } + + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + gridState.scrollBy(diff) + } + } + return state +} + +class ReorderableLazyGridState( + val gridState: LazyGridState, + scope: CoroutineScope, + maxScrollPerFrame: Float, + onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() +) : ReorderableState( + scope, + maxScrollPerFrame, + onMove, + canDragOver, + onDragStart, + onDragEnd, + dragCancelledAnimation, +) { + override val isVerticalScroll: Boolean + get() = gridState.layoutInfo.orientation == Orientation.Vertical + override val LazyGridItemInfo.left: Int + get() = offset.x + override val LazyGridItemInfo.right: Int + get() = offset.x + size.width + override val LazyGridItemInfo.top: Int + get() = offset.y + override val LazyGridItemInfo.bottom: Int + get() = offset.y + size.height + override val LazyGridItemInfo.width: Int + get() = size.width + override val LazyGridItemInfo.height: Int + get() = size.height + override val LazyGridItemInfo.itemIndex: Int + get() = index + override val LazyGridItemInfo.itemKey: Any + get() = key + override val visibleItemsInfo: List + get() = gridState.layoutInfo.visibleItemsInfo + override val viewportStartOffset: Int + get() = gridState.layoutInfo.viewportStartOffset + override val viewportEndOffset: Int + get() = gridState.layoutInfo.viewportEndOffset + override val firstVisibleItemIndex: Int + get() = gridState.firstVisibleItemIndex + override val firstVisibleItemScrollOffset: Int + get() = gridState.firstVisibleItemScrollOffset + + override suspend fun scrollToItem(index: Int, offset: Int) { + gridState.scrollToItem(index, offset) + } +} \ No newline at end of file diff --git a/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt new file mode 100644 index 000000000..d294e5461 --- /dev/null +++ b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ +package org.burnoutcrew.reorderable + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope + + +@Composable +fun rememberReorderableLazyListState( + onMove: (ItemPosition, ItemPosition) -> Unit, + listState: LazyListState = rememberLazyListState(), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + maxScrollPerFrame: Dp = 20.dp, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() +): ReorderableLazyListState { + val maxScroll = with(LocalDensity.current) { maxScrollPerFrame.toPx() } + val scope = rememberCoroutineScope() + val state = remember(listState) { + ReorderableLazyListState( + listState, + scope, + maxScroll, + onMove, + canDragOver, + onDragStart, + onDragEnd, + dragCancelledAnimation, + ) + } + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + LaunchedEffect(state) { + state.visibleItemsChanged() + .collect { state.onDrag(0, 0) } + } + + LaunchedEffect(state) { + var reverseDirection = !listState.layoutInfo.reverseLayout + if (isRtl && listState.layoutInfo.orientation != Orientation.Vertical) { + reverseDirection = !reverseDirection + } + val direction = if (reverseDirection) 1f else -1f + while (true) { + val diff = state.scrollChannel.receive() + listState.scrollBy(diff * direction) + } + } + return state +} + +class ReorderableLazyListState( + val listState: LazyListState, + scope: CoroutineScope, + maxScrollPerFrame: Float, + onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() +) : ReorderableState( + scope, + maxScrollPerFrame, + onMove, + canDragOver, + onDragStart, + onDragEnd, + dragCancelledAnimation, +) { + override val isVerticalScroll: Boolean + get() = listState.layoutInfo.orientation == Orientation.Vertical + override val LazyListItemInfo.left: Int + get() = when { + isVerticalScroll -> 0 + listState.layoutInfo.reverseLayout -> listState.layoutInfo.viewportSize.width - offset - size + else -> offset + } + override val LazyListItemInfo.top: Int + get() = when { + !isVerticalScroll -> 0 + listState.layoutInfo.reverseLayout -> listState.layoutInfo.viewportSize.height - offset - size + else -> offset + } + override val LazyListItemInfo.right: Int + get() = when { + isVerticalScroll -> 0 + listState.layoutInfo.reverseLayout -> listState.layoutInfo.viewportSize.width - offset + else -> offset + size + } + override val LazyListItemInfo.bottom: Int + get() = when { + !isVerticalScroll -> 0 + listState.layoutInfo.reverseLayout -> listState.layoutInfo.viewportSize.height - offset + else -> offset + size + } + override val LazyListItemInfo.width: Int + get() = if (isVerticalScroll) 0 else size + override val LazyListItemInfo.height: Int + get() = if (isVerticalScroll) size else 0 + override val LazyListItemInfo.itemIndex: Int + get() = index + override val LazyListItemInfo.itemKey: Any + get() = key + override val visibleItemsInfo: List + get() = listState.layoutInfo.visibleItemsInfo + override val viewportStartOffset: Int + get() = listState.layoutInfo.viewportStartOffset + override val viewportEndOffset: Int + get() = listState.layoutInfo.viewportEndOffset + override val firstVisibleItemIndex: Int + get() = listState.firstVisibleItemIndex + override val firstVisibleItemScrollOffset: Int + get() = listState.firstVisibleItemScrollOffset + + override suspend fun scrollToItem(index: Int, offset: Int) { + listState.scrollToItem(index, offset) + } + + override fun onDragStart(offsetX: Int, offsetY: Int): Boolean = + if (isVerticalScroll) { + super.onDragStart(0, offsetY) + } else { + super.onDragStart(offsetX, 0) + } + + override fun findTargets(x: Int, y: Int, selected: LazyListItemInfo) = + if (isVerticalScroll) { + super.findTargets(0, y, selected) + } else { + super.findTargets(x, 0, selected) + } + + override fun chooseDropItem( + draggedItemInfo: LazyListItemInfo?, + items: List, + curX: Int, + curY: Int + ) = + if (isVerticalScroll) { + super.chooseDropItem(draggedItemInfo, items, 0, curY) + } else { + super.chooseDropItem(draggedItemInfo, items, curX, 0) + } +} \ No newline at end of file diff --git a/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyStaggeredGridState.kt b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyStaggeredGridState.kt new file mode 100644 index 000000000..030550140 --- /dev/null +++ b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyStaggeredGridState.kt @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ +package org.burnoutcrew.reorderable + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalDensity +import kotlinx.coroutines.CoroutineScope + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun rememberReorderableLazyHorizontalStaggeredGridState( + onMove: (ItemPosition, ItemPosition) -> Unit, + gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + maxScrollPerFrame: Float = 20f, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation(), +) = rememberReorderableLazyStaggeredGridState( + onMove = onMove, + gridState = gridState, + canDragOver = canDragOver, + onDragEnd = onDragEnd, + maxScrollPerFrame = maxScrollPerFrame, + dragCancelledAnimation = dragCancelledAnimation, + orientation = Orientation.Horizontal, +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun rememberReorderableLazyVerticalStaggeredGridState( + onMove: (ItemPosition, ItemPosition) -> Unit, + gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + maxScrollPerFrame: Float = 20f, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation(), +) = rememberReorderableLazyStaggeredGridState( + onMove = onMove, + gridState = gridState, + canDragOver = canDragOver, + onDragEnd = onDragEnd, + maxScrollPerFrame = maxScrollPerFrame, + dragCancelledAnimation = dragCancelledAnimation, + orientation = Orientation.Vertical, +) + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun rememberReorderableLazyStaggeredGridState( + onMove: (ItemPosition, ItemPosition) -> Unit, + gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + maxScrollPerFrame: Float = 20F, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation(), + orientation: Orientation +): ReorderableLazyStaggeredGridState { + val maxScroll = with(LocalDensity.current) { maxScrollPerFrame } + val scope = rememberCoroutineScope() + val state = remember(gridState) { + ReorderableLazyStaggeredGridState( + gridState = gridState, + scope = scope, + maxScrollPerFrame = maxScrollPerFrame, + onMove = onMove, + onDragStart = onDragStart, + canDragOver = canDragOver, + onDragEnd = onDragEnd, + dragCancelledAnimation = dragCancelledAnimation, + orientation = orientation, + ) + } + LaunchedEffect(state) { + state.visibleItemsChanged() + .collect { state.onDrag(0, 0) } + } + + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + gridState.scrollBy(diff) + } + } + return state +} + +@OptIn(ExperimentalFoundationApi::class) +class ReorderableLazyStaggeredGridState( + val gridState: LazyStaggeredGridState, + scope: CoroutineScope, + maxScrollPerFrame: Float, + onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), + canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, + onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, + onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, + dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation(), + val orientation: Orientation +) : ReorderableState( + scope = scope, + maxScrollPerFrame = maxScrollPerFrame, + onMove = onMove, + onDragStart = onDragStart, + canDragOver = canDragOver, + onDragEnd = onDragEnd, + dragCancelledAnimation = dragCancelledAnimation, +) { + override val isVerticalScroll: Boolean + get() = orientation == Orientation.Vertical // XXX gridState.isVertical is not accessible + override val LazyStaggeredGridItemInfo.left: Int + get() = offset.x + override val LazyStaggeredGridItemInfo.right: Int + get() = offset.x + size.width + override val LazyStaggeredGridItemInfo.top: Int + get() = offset.y + override val LazyStaggeredGridItemInfo.bottom: Int + get() = offset.y + size.height + override val LazyStaggeredGridItemInfo.width: Int + get() = size.width + override val LazyStaggeredGridItemInfo.height: Int + get() = size.height + override val LazyStaggeredGridItemInfo.itemIndex: Int + get() = index + override val LazyStaggeredGridItemInfo.itemKey: Any + get() = key + override val visibleItemsInfo: List + get() = gridState.layoutInfo.visibleItemsInfo + override val viewportStartOffset: Int + get() = gridState.layoutInfo.viewportStartOffset + override val viewportEndOffset: Int + get() = gridState.layoutInfo.viewportEndOffset + override val firstVisibleItemIndex: Int + get() = gridState.firstVisibleItemIndex + override val firstVisibleItemScrollOffset: Int + get() = gridState.firstVisibleItemScrollOffset + + override suspend fun scrollToItem(index: Int, offset: Int) { + gridState.scrollToItem(index, offset) + } +} diff --git a/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt new file mode 100644 index 000000000..7b0bf1af9 --- /dev/null +++ b/app/shared/thirdparty/reorderable/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt @@ -0,0 +1,346 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ +package org.burnoutcrew.reorderable + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.util.fastForEach +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue +import kotlin.math.min +import kotlin.math.sign + + +abstract class ReorderableState( + private val scope: CoroutineScope, + private val maxScrollPerFrame: Float, + private val onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), + private val canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)?, + private val onDragStart: ((startIndex: Int, x: Int, y: Int) -> (Unit))? = null, + private val onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))?, + val dragCancelledAnimation: DragCancelledAnimation +) { + var layoutWindowPosition = mutableStateOf(Offset.Zero) + + var draggingItemIndex by mutableStateOf(null) + private set + val draggingItemKey: Any? + get() = selected?.itemKey + protected abstract val T.left: Int + protected abstract val T.top: Int + protected abstract val T.right: Int + protected abstract val T.bottom: Int + protected abstract val T.width: Int + protected abstract val T.height: Int + protected abstract val T.itemIndex: Int + protected abstract val T.itemKey: Any + protected abstract val visibleItemsInfo: List + protected abstract val firstVisibleItemIndex: Int + protected abstract val firstVisibleItemScrollOffset: Int + protected abstract val viewportStartOffset: Int + protected abstract val viewportEndOffset: Int + internal val scrollChannel = Channel() + val draggingItemLeft: Float + get() = if (draggingItemKey != null) draggingLayoutInfo?.let { item -> + (selected?.left ?: 0) + draggingDelta.x - item.left + } ?: 0f else 0f + val draggingItemTop: Float + get() = if (draggingItemKey != null) draggingLayoutInfo?.let { item -> + (selected?.top ?: 0) + draggingDelta.y - item.top + } ?: 0f else 0f + abstract val isVerticalScroll: Boolean + private val draggingLayoutInfo: T? + get() = visibleItemsInfo + .firstOrNull { it.itemIndex == draggingItemIndex } + private var draggingDelta by mutableStateOf(Offset.Zero) + private var selected by mutableStateOf(null) + private var autoscroller: Job? = null + private val targets = mutableListOf() + private val distances = mutableListOf() + + protected abstract suspend fun scrollToItem(index: Int, offset: Int) + + @OptIn(ExperimentalCoroutinesApi::class) + internal fun visibleItemsChanged() = + snapshotFlow { draggingItemIndex != null } + .flatMapLatest { if (it) snapshotFlow { visibleItemsInfo } else flowOf(null) } + .filterNotNull() + .distinctUntilChanged { old, new -> old.firstOrNull()?.itemIndex == new.firstOrNull()?.itemIndex && old.count() == new.count() } + + internal open fun onDragStart(offsetX: Int, offsetY: Int): Boolean { + val x: Int + val y: Int + if (isVerticalScroll) { + x = offsetX + y = offsetY + viewportStartOffset + } else { + x = offsetX + viewportStartOffset + y = offsetY + } + return visibleItemsInfo + .firstOrNull { x in it.left..it.right && y in it.top..it.bottom } + ?.also { + selected = it + draggingItemIndex = it.itemIndex + onDragStart?.invoke(it.itemIndex, offsetX, offsetY) + } != null + } + + internal fun onDragCanceled() { + val dragIdx = draggingItemIndex + if (dragIdx != null) { + val position = ItemPosition(dragIdx, selected?.itemKey) + val offset = Offset(draggingItemLeft, draggingItemTop) + scope.launch { + dragCancelledAnimation.dragCancelled(position, offset) + } + } + val startIndex = selected?.itemIndex + val endIndex = draggingItemIndex + selected = null + draggingDelta = Offset.Zero + draggingItemIndex = null + cancelAutoScroll() + onDragEnd?.apply { + if (startIndex != null && endIndex != null) { + invoke(startIndex, endIndex) + } + } + } + + internal fun onDrag(offsetX: Int, offsetY: Int) { + val selected = selected ?: return + draggingDelta = Offset(draggingDelta.x + offsetX, draggingDelta.y + offsetY) + val draggingItem = draggingLayoutInfo ?: return + val startOffset = draggingItem.top + draggingItemTop + val startOffsetX = draggingItem.left + draggingItemLeft + chooseDropItem( + draggingItem, + findTargets(draggingDelta.x.toInt(), draggingDelta.y.toInt(), selected), + startOffsetX.toInt(), + startOffset.toInt(), + )?.also { targetItem -> + if (targetItem.itemIndex == firstVisibleItemIndex || draggingItem.itemIndex == firstVisibleItemIndex) { + scope.launch { + onMove.invoke( + ItemPosition(draggingItem.itemIndex, draggingItem.itemKey), + ItemPosition(targetItem.itemIndex, targetItem.itemKey), + ) + scrollToItem(firstVisibleItemIndex, firstVisibleItemScrollOffset) + } + } else { + onMove.invoke( + ItemPosition(draggingItem.itemIndex, draggingItem.itemKey), + ItemPosition(targetItem.itemIndex, targetItem.itemKey), + ) + } + draggingItemIndex = targetItem.itemIndex + } + + with(calcAutoScrollOffset(0, maxScrollPerFrame)) { + if (this != 0f) autoscroll(this) + } + } + + private fun autoscroll(scrollOffset: Float) { + if (scrollOffset != 0f) { + if (autoscroller?.isActive == true) { + return + } + autoscroller = scope.launch { + var scroll = scrollOffset + var start = 0L + while (scroll != 0f && autoscroller?.isActive == true) { + withFrameMillis { + if (start == 0L) { + start = it + } else { + scroll = calcAutoScrollOffset(it - start, maxScrollPerFrame) + } + } + scrollChannel.trySend(scroll) + } + } + } else { + cancelAutoScroll() + } + } + + private fun cancelAutoScroll() { + autoscroller?.cancel() + autoscroller = null + } + + protected open fun findTargets(x: Int, y: Int, selected: T): List { + targets.clear() + distances.clear() + val left = x + selected.left + val right = x + selected.right + val top = y + selected.top + val bottom = y + selected.bottom + val centerX = (left + right) / 2 + val centerY = (top + bottom) / 2 + visibleItemsInfo.fastForEach { item -> + if ( + item.itemIndex == draggingItemIndex + || item.bottom < top + || item.top > bottom + || item.right < left + || item.left > right + ) { + return@fastForEach + } + if (canDragOver?.invoke( + ItemPosition(item.itemIndex, item.itemKey), + ItemPosition(selected.itemIndex, selected.itemKey), + ) != false + ) { + val dx = (centerX - (item.left + item.right) / 2).absoluteValue + val dy = (centerY - (item.top + item.bottom) / 2).absoluteValue + val dist = dx * dx + dy * dy + var pos = 0 + for (j in targets.indices) { + if (dist > distances[j]) { + pos++ + } else { + break + } + } + targets.add(pos, item) + distances.add(pos, dist) + } + } + return targets + } + + protected open fun chooseDropItem(draggedItemInfo: T?, items: List, curX: Int, curY: Int): T? { + if (draggedItemInfo == null) { + return if (draggingItemIndex != null) items.lastOrNull() else null + } + var target: T? = null + var highScore = -1 + val right = curX + draggedItemInfo.width + val bottom = curY + draggedItemInfo.height + val dx = curX - draggedItemInfo.left + val dy = curY - draggedItemInfo.top + + items.fastForEach { item -> + if (dx > 0) { + val diff = item.right - right + if (diff < 0 && item.right > draggedItemInfo.right) { + val score = diff.absoluteValue + if (score > highScore) { + highScore = score + target = item + } + } + } + if (dx < 0) { + val diff = item.left - curX + if (diff > 0 && item.left < draggedItemInfo.left) { + val score = diff.absoluteValue + if (score > highScore) { + highScore = score + target = item + } + } + } + if (dy < 0) { + val diff = item.top - curY + if (diff > 0 && item.top < draggedItemInfo.top) { + val score = diff.absoluteValue + if (score > highScore) { + highScore = score + target = item + } + } + } + if (dy > 0) { + val diff = item.bottom - bottom + if (diff < 0 && item.bottom > draggedItemInfo.bottom) { + val score = diff.absoluteValue + if (score > highScore) { + highScore = score + target = item + } + } + } + } + return target + } + + private fun calcAutoScrollOffset(time: Long, maxScroll: Float): Float { + val draggingItem = draggingLayoutInfo ?: return 0f + val startOffset: Float + val endOffset: Float + val delta: Float + if (isVerticalScroll) { + startOffset = draggingItem.top + draggingItemTop + endOffset = startOffset + draggingItem.height + delta = draggingDelta.y + } else { + startOffset = draggingItem.left + draggingItemLeft + endOffset = startOffset + draggingItem.width + delta = draggingDelta.x + } + return when { + delta > 0 -> + (endOffset - viewportEndOffset).coerceAtLeast(0f) + + delta < 0 -> + (startOffset - viewportStartOffset).coerceAtMost(0f) + + else -> 0f + } + .let { interpolateOutOfBoundsScroll((endOffset - startOffset).toInt(), it, time, maxScroll) } + } + + + companion object { + private const val ACCELERATION_LIMIT_TIME_MS: Long = 1500 + private val EaseOutQuadInterpolator: (Float) -> (Float) = { + val t = 1 - it + 1 - t * t * t * t + } + private val EaseInQuintInterpolator: (Float) -> (Float) = { + it * it * it * it * it + } + + private fun interpolateOutOfBoundsScroll( + viewSize: Int, + viewSizeOutOfBounds: Float, + time: Long, + maxScroll: Float, + ): Float { + if (viewSizeOutOfBounds == 0f) return 0f + val outOfBoundsRatio = min(1f, 1f * viewSizeOutOfBounds.absoluteValue / viewSize) + val cappedScroll = sign(viewSizeOutOfBounds) * maxScroll * EaseOutQuadInterpolator(outOfBoundsRatio) + val timeRatio = if (time > ACCELERATION_LIMIT_TIME_MS) 1f else time.toFloat() / ACCELERATION_LIMIT_TIME_MS + return (cappedScroll * EaseInQuintInterpolator(timeRatio)).let { + if (it == 0f) { + if (viewSizeOutOfBounds > 0) 1f else -1f + } else { + it + } + } + } + } +} diff --git a/app/shared/ui-settings/build.gradle.kts b/app/shared/ui-settings/build.gradle.kts index 6628d472c..080998026 100644 --- a/app/shared/ui-settings/build.gradle.kts +++ b/app/shared/ui-settings/build.gradle.kts @@ -23,7 +23,7 @@ kotlin { api(projects.app.shared.uiFoundation) api(projects.app.shared.uiAdaptive) implementation(compose.components.resources) - implementation(libs.reorderable) + implementation(projects.app.shared.reorderable) } sourceSets.commonTest.dependencies { } diff --git a/settings.gradle.kts b/settings.gradle.kts index afea40b68..8ac9b9ea9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -65,6 +65,7 @@ includeProject(":app:shared:application") includeProject(":app:shared:placeholder", "app/shared/thirdparty/placeholder") includeProject(":app:shared:paging-compose", "app/shared/thirdparty/paging-compose") includeProject(":app:shared:image-viewer", "app/shared/thirdparty/image-viewer") +includeProject(":app:shared:reorderable", "app/shared/thirdparty/reorderable") includeProject(":app:desktop", "app/desktop") // desktop JVM client for macOS, Windows, and Linux includeProject(":app:android", "app/android") // Android client @@ -119,16 +120,8 @@ fun getMissingSubmoduleMessage(moduleName: String) = """ 2. 使用 Android Studio 的 New Project from Version Control 创建项目, 而不要使用命令行 clone 3. 使用命令行时确保带上 recursive 选项: `git clone --recursive git@github.com:open-ani/ani.git` """.trimIndent() -if (file("app/shared/thirdparty/reorderable").run { !exists() || listFiles().isNullOrEmpty() }) { - error(getMissingSubmoduleMessage("""app/shared/thirdparty/reorderable""")) -} if (file("torrent/anitorrent/anitorrent-native/libs/boost").run { !exists() || listFiles().isNullOrEmpty() }) { error(getMissingSubmoduleMessage("""torrent/anitorrent/anitorrent-native/libs/boost""")) } -includeBuild("app/shared/thirdparty/reorderable") { - dependencySubstitution { - substitute(module("org.burnoutcrew.composereorderable:reorderable")).using(project(":reorderable")) - } -}