Embed submodule reorderable into project for simplicity

This commit is contained in:
Him188
2024-11-25 13:44:05 +00:00
parent d33a9836b5
commit ed37398be4
17 changed files with 1448 additions and 14 deletions

3
.gitmodules vendored
View File

@ -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

1
.idea/vcs.xml generated
View File

@ -21,7 +21,6 @@
</component>
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/shared/thirdparty/reorderable" vcs="Git" />
<mapping directory="$PROJECT_DIR$/torrent/anitorrent/anitorrent-native/libs/boost" vcs="Git" />
</component>
</project>

View File

@ -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)

View File

@ -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.

View File

@ -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:<latest_version>")
}
```
## 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.
```

View File

@ -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"
}

View File

@ -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())
}
}
}
}

View File

@ -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<ItemPosition?>(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
}
}

View File

@ -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?)

View File

@ -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()
}
},
)

View File

@ -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<IntOffset> = 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)
}
}

View File

@ -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<LazyGridItemInfo>(
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<LazyGridItemInfo>
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)
}
}

View File

@ -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<LazyListItemInfo>(
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<LazyListItemInfo>
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<LazyListItemInfo>,
curX: Int,
curY: Int
) =
if (isVerticalScroll) {
super.chooseDropItem(draggedItemInfo, items, 0, curY)
} else {
super.chooseDropItem(draggedItemInfo, items, curX, 0)
}
}

View File

@ -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<LazyStaggeredGridItemInfo>(
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<LazyStaggeredGridItemInfo>
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)
}
}

View File

@ -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<T>(
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<Int?>(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<T>
protected abstract val firstVisibleItemIndex: Int
protected abstract val firstVisibleItemScrollOffset: Int
protected abstract val viewportStartOffset: Int
protected abstract val viewportEndOffset: Int
internal val scrollChannel = Channel<Float>()
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<T?>(null)
private var autoscroller: Job? = null
private val targets = mutableListOf<T>()
private val distances = mutableListOf<Int>()
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<T> {
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<T>, 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
}
}
}
}
}

View File

@ -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 {
}

View File

@ -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"))
}
}