Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,30 +1,52 @@
@file:OptIn(ExperimentalMaterial3Api::class)

package com.yapp.ndgl.core.ui.designsystem

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.yapp.ndgl.core.ui.R
import com.yapp.ndgl.core.ui.theme.NDGLTheme
import kotlinx.coroutines.launch

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NDGLBottomSheet(
modifier: Modifier = Modifier,
onDismissRequest: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
showDragHandle: Boolean = true,
title: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
val hideSheet: () -> Unit = {
coroutineScope.launch {
sheetState.hide()
onDismissRequest()
}
}

ModalBottomSheet(
modifier = modifier,
Expand All @@ -37,8 +59,33 @@ fun NDGLBottomSheet(
},
containerColor = NDGLTheme.colors.white,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
content = content,
)
) {
title?.let { title ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 18.dp, horizontal = 24.dp),
) {
Text(
title,
color = NDGLTheme.colors.black400,
style = NDGLTheme.typography.bodyLgMedium,
)
Spacer(Modifier.weight(1f))
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_24_close),
contentDescription = null,
tint = NDGLTheme.colors.black600,
modifier = Modifier
.size(24.dp)
.clip(shape = CircleShape)
.clickable { hideSheet() },
)
}
}

content()
}
}

@Preview(showBackground = true)
Expand All @@ -48,20 +95,15 @@ private fun NDGLBottomSheetWithHandlePreview() {
NDGLBottomSheet(
onDismissRequest = {},
showDragHandle = true,
title = "바텀시트 제목",
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
) {
Text(
text = "바텀시트 제목",
style = NDGLTheme.typography.subtitleLgSemiBold,
color = NDGLTheme.colors.black900,
)
Text(
text = "바텀시트 내용입니다.",
modifier = Modifier.padding(top = 16.dp),
style = NDGLTheme.typography.bodyLgMedium,
color = NDGLTheme.colors.black500,
)
Expand All @@ -70,27 +112,23 @@ private fun NDGLBottomSheetWithHandlePreview() {
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
private fun NDGLBottomSheetWithoutHandlePreview() {
NDGLTheme {
NDGLBottomSheet(
onDismissRequest = {},
showDragHandle = false,
title = "바텀시트 제목",
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
) {
Text(
text = "바텀시트 제목",
style = NDGLTheme.typography.subtitleLgSemiBold,
color = NDGLTheme.colors.black900,
)
Text(
text = "바텀시트 내용입니다.",
modifier = Modifier.padding(top = 16.dp),
style = NDGLTheme.typography.bodyLgMedium,
color = NDGLTheme.colors.black500,
)
Expand Down
200 changes: 200 additions & 0 deletions core/ui/src/main/java/com/yapp/ndgl/core/ui/util/ReorderableList.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package com.yapp.ndgl.core.ui.util

import androidx.compose.foundation.gestures.detectVerticalDragGestures
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.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

private fun <T> SnapshotStateList<T>.reorder(
from: Int,
to: Int,
): Boolean {
if (from == to || from < 0 || to < 0 || from >= this.size || to >= this.size) return false
add(to, removeAt(from))
return true
}

fun Modifier.reorderable(
state: ReorderableState,
onUpdateList: (from: Int?, to: Int?) -> Unit = { _, _ -> },
): Modifier = this.pointerInput(state) {
detectVerticalDragGestures(
onDragStart = { state.onDragStart(it) },
onDragEnd = {
val from = state.dragStartIndex
val to = state.currentIndex
state.onDragEnd()
onUpdateList(from, to)
},
onDragCancel = { state.onDragEnd() },
onVerticalDrag = { _, amount -> state.onDrag(amount) },
)
}

@Composable
fun <T> rememberReorderableState(
list: SnapshotStateList<T>,
lazyListState: LazyListState = rememberLazyListState(),
offset: Int = 0,
isReorderable: (key: Any?) -> Boolean = { true },
): ReorderableState {
val scope = rememberCoroutineScope()
val currentList by rememberUpdatedState(list)
val onReorder: (Int, Int) -> Boolean = { from, to ->
currentList.reorder(from - offset, to - offset)
}

return remember {
ReorderableState(
scope = scope,
lazyListState = lazyListState,
onReorder = onReorder,
isReorderable = isReorderable,
)
}
Comment on lines +65 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

remember 블록에 키가 없어 isReorderable 변경 시 반영되지 않습니다.

rememberReorderableStateisReorderable 파라미터가 변경되어도 remember {} 블록에 키가 없으므로 ReorderableState가 재생성되지 않습니다. onReorderrememberUpdatedState로 최신 list를 참조하지만, isReorderable은 최초 캡처된 값이 계속 사용됩니다.

♻️ 수정 제안
+    val currentIsReorderable by rememberUpdatedState(isReorderable)
+    val currentOnReorder: (Int, Int) -> Boolean = { from, to ->
+        currentList.reorder(from - offset, to - offset)
+    }
+
     return remember {
         ReorderableState(
             scope = scope,
             lazyListState = lazyListState,
-            onReorder = onReorder,
-            isReorderable = isReorderable,
+            onReorder = currentOnReorder,
+            isReorderable = { currentIsReorderable(it) },
         )
     }
🤖 Prompt for AI Agents
In `@core/ui/src/main/java/com/yapp/ndgl/core/ui/util/ReorderableList.kt` around
lines 65 - 72, The remember block in rememberReorderableState captures
isReorderable once so subsequent changes are ignored; update the remember call
to include isReorderable as a key (e.g., remember(isReorderable) {
ReorderableState(...) }) so a new ReorderableState is created when isReorderable
changes; keep using rememberUpdatedState(for onReorder) as-is and reference the
rememberReorderableState function, ReorderableState class, and the isReorderable
parameter when making this change.

}

class ReorderableState(
private val scope: CoroutineScope,
val lazyListState: LazyListState,
private val onReorder: (Int, Int) -> Boolean,
private val isReorderable: (key: Any?) -> Boolean = { true },
) {
// 드래그 시작한 아이템 상하단 y값
private var initialYBounds by mutableStateOf(0 to 0)

// 드래그 거리
private var distance by mutableFloatStateOf(0f)

// 드래그 중인 아이템 정보
private var info: LazyListItemInfo? by mutableStateOf(null)

// 시작 상하단 y값 + 드래그 거리
private val currentYBounds: Pair<Int, Int> get() = initialYBounds.let { (topY, bottomY) -> topY + distance.toInt() to bottomY + distance.toInt() }

// 순서 변경 임계값
private val threshold: Int get() = initialYBounds.let { (it.first + it.second) / 2 + distance.toInt() }

// 드래그 중인 아이템의 변화하는 인덱스
val currentIndex: Int? get() = info?.index

// 드래그 시작 시점의 원본 LazyColumn 인덱스
var dragStartIndex: Int? = null
private set

// 오토 스크롤 Job
private var autoScrollJob by mutableStateOf<Job?>(null)

// 드래그 시작 - isReorderable이 false인 아이템은 드래그 불가
fun onDragStart(offset: Offset) {
lazyListState.layoutInfo.visibleItemsInfo
.firstOrNull { item ->
offset.y.toInt() > item.offset && offset.y.toInt() < (item.offset + item.size)
}
?.takeIf { isReorderable(it.key) }
?.let {
initialYBounds = it.offset to (it.offset + it.size)
info = it
dragStartIndex = it.index
}
}

// 아이템 인덱스 업데이트
private fun updateItemIndex() {
val itemInfo = info ?: return
when {
currentYBounds.first < itemInfo.offset && threshold < itemInfo.offset ->
tryMoveUp(itemInfo)
currentYBounds.second > itemInfo.offset + itemInfo.size && threshold > itemInfo.offset + itemInfo.size ->
tryMoveDown(itemInfo)
}
}

// 위로 드래그할 때
private fun tryMoveUp(itemInfo: LazyListItemInfo) {
val target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemInfo.index - 1 }
if (target != null) {
// 대상 아이템이 reorderable일 때만 이동
if (isReorderable(target.key) && onReorder(itemInfo.index, itemInfo.index - 1)) {
info = target
}
} else {
// 오토 스크롤 중 아이템이 화면에 보이지 않을 때
val firstItem = lazyListState.layoutInfo.visibleItemsInfo.first()
if (isReorderable(firstItem.key) && onReorder(itemInfo.index, firstItem.index)) {
info = lazyListState.layoutInfo.visibleItemsInfo.first()
}
}
}

// 아래로 드래그할 때
private fun tryMoveDown(itemInfo: LazyListItemInfo) {
val target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemInfo.index + 1 }
if (target != null) {
if (onReorder(itemInfo.index, itemInfo.index + 1)) {
info = target
}
} else {
// 오토 스크롤 중 아이템이 화면에 보이지 않을 때
val lastItem = lazyListState.layoutInfo.visibleItemsInfo.last()
if (onReorder(itemInfo.index, lastItem.index)) {
info = lazyListState.layoutInfo.visibleItemsInfo.last()
}
}
}
Comment on lines +148 to +162
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

tryMoveDown에서 isReorderable 체크가 누락되었습니다.

tryMoveUp (Line 136)에서는 대상 아이템의 isReorderable(target.key)를 확인하지만, tryMoveDown에서는 이 검증이 빠져있습니다. 아래로 드래그 시 재배치 불가능한 아이템(헤더, 구분선 등)과 위치가 바뀔 수 있습니다.

🐛 수정 제안
     private fun tryMoveDown(itemInfo: LazyListItemInfo) {
         val target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemInfo.index + 1 }
         if (target != null) {
-            if (onReorder(itemInfo.index, itemInfo.index + 1)) {
+            if (isReorderable(target.key) && onReorder(itemInfo.index, itemInfo.index + 1)) {
                 info = target
             }
         } else {
             // 오토 스크롤 중 아이템이 화면에 보이지 않을 때
             val lastItem = lazyListState.layoutInfo.visibleItemsInfo.last()
-            if (onReorder(itemInfo.index, lastItem.index)) {
+            if (isReorderable(lastItem.key) && onReorder(itemInfo.index, lastItem.index)) {
                 info = lazyListState.layoutInfo.visibleItemsInfo.last()
             }
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 아래로 드래그할 때
private fun tryMoveDown(itemInfo: LazyListItemInfo) {
val target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemInfo.index + 1 }
if (target != null) {
if (onReorder(itemInfo.index, itemInfo.index + 1)) {
info = target
}
} else {
// 오토 스크롤 중 아이템이 화면에 보이지 않을 때
val lastItem = lazyListState.layoutInfo.visibleItemsInfo.last()
if (onReorder(itemInfo.index, lastItem.index)) {
info = lazyListState.layoutInfo.visibleItemsInfo.last()
}
}
}
// 아래로 드래그할 때
private fun tryMoveDown(itemInfo: LazyListItemInfo) {
val target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemInfo.index + 1 }
if (target != null) {
if (isReorderable(target.key) && onReorder(itemInfo.index, itemInfo.index + 1)) {
info = target
}
} else {
// 오토 스크롤 중 아이템이 화면에 보이지 않을 때
val lastItem = lazyListState.layoutInfo.visibleItemsInfo.last()
if (isReorderable(lastItem.key) && onReorder(itemInfo.index, lastItem.index)) {
info = lazyListState.layoutInfo.visibleItemsInfo.last()
}
}
}
🤖 Prompt for AI Agents
In `@core/ui/src/main/java/com/yapp/ndgl/core/ui/util/ReorderableList.kt` around
lines 148 - 162, tryMoveDown is missing the same isReorderable check used in
tryMoveUp, so non-reorderable targets can be moved when dragging down; update
tryMoveDown to verify isReorderable(target.key) before calling onReorder in both
the visible-next-item branch and the else/autoscroll branch (mirror the guard in
tryMoveUp), and only set info when the target passes isReorderable and onReorder
returns true.


// 드래그 중
fun onDrag(amount: Float) {
if (dragStartIndex == null) return
distance += amount
updateItemIndex()
onAutoScroll()
}

// 드래그 종료
fun onDragEnd() {
initialYBounds = 0 to 0
distance = 0f
info = null
dragStartIndex = null
autoScrollJob?.cancel()
autoScrollJob = null
}
Comment thread
mj010504 marked this conversation as resolved.

// 오토 스크롤
private fun onAutoScroll() {
if (autoScrollJob?.isActive == true) return
autoScrollJob = scope.launch(Dispatchers.Main) {
while (true) {
val scrollOffset = when {
currentYBounds.first < lazyListState.layoutInfo.viewportStartOffset -> -16f
currentYBounds.second > lazyListState.layoutInfo.viewportEndOffset -> 16f
else -> null
}

scrollOffset?.let {
lazyListState.scrollBy(it)
delay(8)
} ?: break
}
}
}
}
5 changes: 5 additions & 0 deletions core/ui/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,9 @@
<string name="place_detail_modal_change_description">%s -> %s</string>
<string name="place_detail_modal_change_confirm">변경하기</string>
<string name="place_detail_modal_change_cancel">아니요</string>

<!-- Transport Bottom Sheet -->
<string name="transport_bottom_sheet_title">이동수단 변경</string>
<string name="transport_bottom_sheet_button">확인</string>

</resources>
16 changes: 16 additions & 0 deletions core/util/src/main/java/com/yapp/ndgl/core/util/IntUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,21 @@ package com.yapp.ndgl.core.util

import java.text.NumberFormat
import java.util.Locale
import java.util.Locale.getDefault

fun Int.formatDecimal(): String = NumberFormat.getInstance(Locale.US).format(this)

fun Int.formatDistance(): String {
return when {
this >= 1000 -> {
val km = this / 1000.0
if (km % 1 == 0.0) {
"${km.toInt()}km"
} else {
String.format(getDefault(), "%.1fkm", km)
}
}

else -> "${this}m"
}
}
Loading