Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

kuring-227 드래그 LazyList, 카테고리 순서 DB 구현 #404

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package com.ku_stacks.ku_ring.designsystem.components.dragdrop

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch

@Composable
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState, onMove) {
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
}
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
lazyListState.scrollBy(diff)
}
}
return state
}

class DragDropState internal constructor(
private val state: LazyListState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit
) {
var draggingItemIndex by mutableStateOf<Int?>(null)
private set

internal val scrollChannel = Channel<Float>()

private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0)
internal val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f

private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }

internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set

internal var previousItemOffset = Animatable(0f)
private set

internal fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
?.also {
draggingItemIndex = it.index
draggingItemInitialOffset = it.offset
}
}

internal fun onDragInterrupted() {
if (draggingItemIndex != null) {
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
targetValue = 0f,
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f),
)
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0
}

internal fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y

val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f

val targetItem =
state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd &&
draggingItem.index != item.index
}
if (targetItem != null) {
if (draggingItem.index == state.firstVisibleItemIndex || targetItem.index == state.firstVisibleItemIndex) {
state.requestScrollToItem(
state.firstVisibleItemIndex,
state.firstVisibleItemScrollOffset
)
}
onMove.invoke(draggingItem.index, targetItem.index)
draggingItemIndex = targetItem.index
} else {
val overscroll =
when {
draggingItemDraggedDelta > 0 ->
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)

draggingItemDraggedDelta < 0 ->
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)

else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll * 1.3f)
}
}
}

private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.ku_stacks.ku_ring.designsystem.components.dragdrop

internal fun <T> MutableList<T>.move(from: Int, to: Int) {
if (from == to) return
val element = this.removeAt(from)
this.add(to, element)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.ku_stacks.ku_ring.designsystem.components.dragdrop

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.zIndex

@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable BoxScope.(isDragging: Boolean) -> Unit,
) {
val dragging = index == dragDropState.draggingItemIndex
val draggingModifier = if (dragging) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = dragDropState.draggingItemOffset
alpha = 0.5f
}
} else if (index == dragDropState.previousIndexOfDraggedItem) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = dragDropState.previousItemOffset.value
}
} else {
Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
}

Box(modifier = modifier.then(draggingModifier)) {
content(dragging)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package com.ku_stacks.ku_ring.designsystem.components.dragdrop

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.ku_stacks.ku_ring.designsystem.components.LightAndDarkPreview
import com.ku_stacks.ku_ring.designsystem.kuringtheme.KuringTheme
import com.ku_stacks.ku_ring.designsystem.kuringtheme.values.Pretendard

/**
* Draggable lazy column, inspired by [Android Code Search](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt).
* Each item becomes draggable when long-clicked.
* When all pointers are up, the item is released and becomes non-draggable.
*
* @param items List of items to display.
* @param onMove Callback invoked when an item is moved.
* @param key An unique key of the item.
* @param modifier Modifier to be applied to the layout.
* @param contentPadding Padding around the content.
* @param reverseLayout Whether the items should be reversed. When `true`, items are laid out
* in the reverse order and `LazyListState.firstVisibleItemIndex == 0` means that colum is scrolled
* to the bottom.
* @param verticalArrangement The vertical arrangement of the layout's children.
* @param horizontalAlignment The horizontal alignment applied to the items.
* @param flingBehavior logic describing fling behavior.
* @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions
* is allowed. The list can be scrolled programmatically even when this value is `false`.
* @param contentType a factory of the content types for the item. The item compositions of the same
* type could be reused more efficiently. Note that null is a valid type and items of such type
* will be considered compatible.
* @param content a block which describes the content.
*/
@Composable
fun DraggableLazyColumn(
items: List<DraggableLazyColumnItem>,
onMove: (Int, Int) -> Unit,
key: ((index: Int, item: DraggableLazyColumnItem) -> Any),
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
contentType: (index: Int, item: DraggableLazyColumnItem) -> Any? = { _, _ -> null },
content: @Composable LazyItemScope.(index: Int, item: DraggableLazyColumnItem, isDraggable: Boolean) -> Unit,
) {
val lazyListState = rememberLazyListState()
val dragDropState = rememberDragDropState(lazyListState) { from, to ->
onMove(from, to)
}

LazyColumn(
modifier = modifier.dragContainer(dragDropState),
state = lazyListState,
contentPadding = contentPadding,
flingBehavior = flingBehavior,
horizontalAlignment = horizontalAlignment,
verticalArrangement = verticalArrangement,
reverseLayout = reverseLayout,
userScrollEnabled = userScrollEnabled,
) {
itemsIndexed(
items = items,
key = key,
contentType = contentType,
) { index, item ->
DraggableItem(
dragDropState = dragDropState,
index = index,
) { isDraggable ->
content(index, item, isDraggable)
}
}
}
}

fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
return pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() }
)
}
}

@LightAndDarkPreview
@Composable
private fun DraggableLazyColumnPreview() {
data class Item(val id: Int) : DraggableLazyColumnItem

var items by remember {
mutableStateOf(List(20) { Item(it) })
}

KuringTheme {
DraggableLazyColumn(
items = items,
onMove = { from, to ->
items = items.toMutableList().apply { move(from, to) } // Important!
},
key = { index, item ->
(item as Item).id
},
modifier = Modifier
.background(KuringTheme.colors.background)
.fillMaxSize(),
) { index, draggableLazyColumnItem, isDraggable ->
Text(
text = (draggableLazyColumnItem as Item).id.toString(),
modifier = Modifier
.border(width = 1.dp, color = Color.Red)
.padding(start = 10.dp, top = 20.dp, bottom = 20.dp)
.fillMaxWidth(),
style = TextStyle(
fontSize = 15.sp,
lineHeight = 24.45.sp,
fontFamily = Pretendard,
fontWeight = FontWeight(500),
color = KuringTheme.colors.textBody,
)
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.ku_stacks.ku_ring.designsystem.components.dragdrop

/**
* This interface is a marker for classes which can be dragged from a [DraggableLazyColumn].
*/
interface DraggableLazyColumnItem
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.ku_stacks.ku_ring.domain

data class CategoryOrder(
val koreanName: String,
val shortName: String,
val order: Int,
)
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,35 @@ object DBModule {
}
}

private val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS CategoryOrderEntity(
koreanName TEXT PRIMARY KEY NOT NULL,
shortName TEXT NOT NULL,
categoryOrder INTEGER NOT NULL
)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO CategoryOrderEntity (koreanName, shortName, categoryOrder)
VALUES
('학과', 'dep', 0),
('학사', 'bch', 1),
('장학', 'sch', 2),
('도서관', 'lib', 3),
('취창업', 'emp', 4),
('국제', 'nat', 5),
('학생', 'stu', 6),
('산학', 'ind', 7),
('일반', 'nor', 8);
""".trimIndent()
)
}
}

@Singleton
@Provides
fun provideKuRingDatabase(
Expand All @@ -100,6 +129,7 @@ object DBModule {
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
).build()

@Singleton
Expand All @@ -125,4 +155,8 @@ object DBModule {
@Singleton
@Provides
fun provideKuringBotMessageDao(database: KuRingDatabase) = database.kuringBotMessageDao()

@Singleton
@Provides
fun provideCategoryOrderDao(database: KuRingDatabase) = database.categoryOrderDao()
}
Loading
Loading