Skip to content

Commit 83c6ce4

Browse files
authored
Merge pull request #22 from YAPP-Github/feature/NDGL-86
[NDGL-86] 내 여행 화면 장소 재배치, 교통수단 변경 추가
2 parents f5c1e76 + 4c05dd9 commit 83c6ce4

File tree

14 files changed

+705
-209
lines changed

14 files changed

+705
-209
lines changed

core/ui/src/main/java/com/yapp/ndgl/core/ui/designsystem/NDGLBottomSheet.kt

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,52 @@
1+
@file:OptIn(ExperimentalMaterial3Api::class)
2+
13
package com.yapp.ndgl.core.ui.designsystem
24

5+
import androidx.compose.foundation.clickable
36
import androidx.compose.foundation.layout.Column
47
import androidx.compose.foundation.layout.ColumnScope
8+
import androidx.compose.foundation.layout.Row
9+
import androidx.compose.foundation.layout.Spacer
510
import androidx.compose.foundation.layout.fillMaxWidth
611
import androidx.compose.foundation.layout.padding
12+
import androidx.compose.foundation.layout.size
13+
import androidx.compose.foundation.shape.CircleShape
714
import androidx.compose.foundation.shape.RoundedCornerShape
815
import androidx.compose.material3.BottomSheetDefaults
916
import androidx.compose.material3.ExperimentalMaterial3Api
17+
import androidx.compose.material3.Icon
1018
import androidx.compose.material3.ModalBottomSheet
19+
import androidx.compose.material3.SheetState
1120
import androidx.compose.material3.Text
1221
import androidx.compose.material3.rememberModalBottomSheetState
1322
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.rememberCoroutineScope
1424
import androidx.compose.ui.Modifier
25+
import androidx.compose.ui.draw.clip
26+
import androidx.compose.ui.graphics.vector.ImageVector
27+
import androidx.compose.ui.res.vectorResource
1528
import androidx.compose.ui.tooling.preview.Preview
1629
import androidx.compose.ui.unit.dp
30+
import com.yapp.ndgl.core.ui.R
1731
import com.yapp.ndgl.core.ui.theme.NDGLTheme
32+
import kotlinx.coroutines.launch
1833

19-
@OptIn(ExperimentalMaterial3Api::class)
2034
@Composable
2135
fun NDGLBottomSheet(
2236
modifier: Modifier = Modifier,
2337
onDismissRequest: () -> Unit,
38+
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
2439
showDragHandle: Boolean = true,
40+
title: String? = null,
2541
content: @Composable ColumnScope.() -> Unit,
2642
) {
27-
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
43+
val coroutineScope = rememberCoroutineScope()
44+
val hideSheet: () -> Unit = {
45+
coroutineScope.launch {
46+
sheetState.hide()
47+
onDismissRequest()
48+
}
49+
}
2850

2951
ModalBottomSheet(
3052
modifier = modifier,
@@ -37,8 +59,33 @@ fun NDGLBottomSheet(
3759
},
3860
containerColor = NDGLTheme.colors.white,
3961
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
40-
content = content,
41-
)
62+
) {
63+
title?.let { title ->
64+
Row(
65+
modifier = Modifier
66+
.fillMaxWidth()
67+
.padding(vertical = 18.dp, horizontal = 24.dp),
68+
) {
69+
Text(
70+
title,
71+
color = NDGLTheme.colors.black400,
72+
style = NDGLTheme.typography.bodyLgMedium,
73+
)
74+
Spacer(Modifier.weight(1f))
75+
Icon(
76+
imageVector = ImageVector.vectorResource(R.drawable.ic_24_close),
77+
contentDescription = null,
78+
tint = NDGLTheme.colors.black600,
79+
modifier = Modifier
80+
.size(24.dp)
81+
.clip(shape = CircleShape)
82+
.clickable { hideSheet() },
83+
)
84+
}
85+
}
86+
87+
content()
88+
}
4289
}
4390

4491
@Preview(showBackground = true)
@@ -48,20 +95,15 @@ private fun NDGLBottomSheetWithHandlePreview() {
4895
NDGLBottomSheet(
4996
onDismissRequest = {},
5097
showDragHandle = true,
98+
title = "바텀시트 제목",
5199
) {
52100
Column(
53101
modifier = Modifier
54102
.fillMaxWidth()
55103
.padding(24.dp),
56104
) {
57-
Text(
58-
text = "바텀시트 제목",
59-
style = NDGLTheme.typography.subtitleLgSemiBold,
60-
color = NDGLTheme.colors.black900,
61-
)
62105
Text(
63106
text = "바텀시트 내용입니다.",
64-
modifier = Modifier.padding(top = 16.dp),
65107
style = NDGLTheme.typography.bodyLgMedium,
66108
color = NDGLTheme.colors.black500,
67109
)
@@ -70,27 +112,23 @@ private fun NDGLBottomSheetWithHandlePreview() {
70112
}
71113
}
72114

115+
@OptIn(ExperimentalMaterial3Api::class)
73116
@Preview(showBackground = true)
74117
@Composable
75118
private fun NDGLBottomSheetWithoutHandlePreview() {
76119
NDGLTheme {
77120
NDGLBottomSheet(
78121
onDismissRequest = {},
79122
showDragHandle = false,
123+
title = "바텀시트 제목",
80124
) {
81125
Column(
82126
modifier = Modifier
83127
.fillMaxWidth()
84128
.padding(24.dp),
85129
) {
86-
Text(
87-
text = "바텀시트 제목",
88-
style = NDGLTheme.typography.subtitleLgSemiBold,
89-
color = NDGLTheme.colors.black900,
90-
)
91130
Text(
92131
text = "바텀시트 내용입니다.",
93-
modifier = Modifier.padding(top = 16.dp),
94132
style = NDGLTheme.typography.bodyLgMedium,
95133
color = NDGLTheme.colors.black500,
96134
)
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package com.yapp.ndgl.core.ui.util
2+
3+
import androidx.compose.foundation.gestures.detectVerticalDragGestures
4+
import androidx.compose.foundation.gestures.scrollBy
5+
import androidx.compose.foundation.lazy.LazyListItemInfo
6+
import androidx.compose.foundation.lazy.LazyListState
7+
import androidx.compose.foundation.lazy.rememberLazyListState
8+
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.getValue
10+
import androidx.compose.runtime.mutableFloatStateOf
11+
import androidx.compose.runtime.mutableStateOf
12+
import androidx.compose.runtime.remember
13+
import androidx.compose.runtime.rememberCoroutineScope
14+
import androidx.compose.runtime.rememberUpdatedState
15+
import androidx.compose.runtime.setValue
16+
import androidx.compose.runtime.snapshots.SnapshotStateList
17+
import androidx.compose.ui.Modifier
18+
import androidx.compose.ui.geometry.Offset
19+
import androidx.compose.ui.input.pointer.pointerInput
20+
import kotlinx.coroutines.CoroutineScope
21+
import kotlinx.coroutines.Dispatchers
22+
import kotlinx.coroutines.Job
23+
import kotlinx.coroutines.delay
24+
import kotlinx.coroutines.launch
25+
26+
private fun <T> SnapshotStateList<T>.reorder(
27+
from: Int,
28+
to: Int,
29+
): Boolean {
30+
if (from == to || from < 0 || to < 0 || from >= this.size || to >= this.size) return false
31+
add(to, removeAt(from))
32+
return true
33+
}
34+
35+
fun Modifier.reorderable(
36+
state: ReorderableState,
37+
onUpdateList: (from: Int?, to: Int?) -> Unit = { _, _ -> },
38+
): Modifier = this.pointerInput(state) {
39+
detectVerticalDragGestures(
40+
onDragStart = { state.onDragStart(it) },
41+
onDragEnd = {
42+
val from = state.dragStartIndex
43+
val to = state.currentIndex
44+
state.onDragEnd()
45+
onUpdateList(from, to)
46+
},
47+
onDragCancel = { state.onDragEnd() },
48+
onVerticalDrag = { _, amount -> state.onDrag(amount) },
49+
)
50+
}
51+
52+
@Composable
53+
fun <T> rememberReorderableState(
54+
list: SnapshotStateList<T>,
55+
lazyListState: LazyListState = rememberLazyListState(),
56+
offset: Int = 0,
57+
isReorderable: (key: Any?) -> Boolean = { true },
58+
): ReorderableState {
59+
val scope = rememberCoroutineScope()
60+
val currentList by rememberUpdatedState(list)
61+
val onReorder: (Int, Int) -> Boolean = { from, to ->
62+
currentList.reorder(from - offset, to - offset)
63+
}
64+
65+
return remember {
66+
ReorderableState(
67+
scope = scope,
68+
lazyListState = lazyListState,
69+
onReorder = onReorder,
70+
isReorderable = isReorderable,
71+
)
72+
}
73+
}
74+
75+
class ReorderableState(
76+
private val scope: CoroutineScope,
77+
val lazyListState: LazyListState,
78+
private val onReorder: (Int, Int) -> Boolean,
79+
private val isReorderable: (key: Any?) -> Boolean = { true },
80+
) {
81+
// 드래그 시작한 아이템 상하단 y값
82+
private var initialYBounds by mutableStateOf(0 to 0)
83+
84+
// 드래그 거리
85+
private var distance by mutableFloatStateOf(0f)
86+
87+
// 드래그 중인 아이템 정보
88+
private var info: LazyListItemInfo? by mutableStateOf(null)
89+
90+
// 시작 상하단 y값 + 드래그 거리
91+
private val currentYBounds: Pair<Int, Int> get() = initialYBounds.let { (topY, bottomY) -> topY + distance.toInt() to bottomY + distance.toInt() }
92+
93+
// 순서 변경 임계값
94+
private val threshold: Int get() = initialYBounds.let { (it.first + it.second) / 2 + distance.toInt() }
95+
96+
// 드래그 중인 아이템의 변화하는 인덱스
97+
val currentIndex: Int? get() = info?.index
98+
99+
// 드래그 시작 시점의 원본 LazyColumn 인덱스
100+
var dragStartIndex: Int? = null
101+
private set
102+
103+
// 오토 스크롤 Job
104+
private var autoScrollJob by mutableStateOf<Job?>(null)
105+
106+
// 드래그 시작 - isReorderable이 false인 아이템은 드래그 불가
107+
fun onDragStart(offset: Offset) {
108+
lazyListState.layoutInfo.visibleItemsInfo
109+
.firstOrNull { item ->
110+
offset.y.toInt() > item.offset && offset.y.toInt() < (item.offset + item.size)
111+
}
112+
?.takeIf { isReorderable(it.key) }
113+
?.let {
114+
initialYBounds = it.offset to (it.offset + it.size)
115+
info = it
116+
dragStartIndex = it.index
117+
}
118+
}
119+
120+
// 아이템 인덱스 업데이트
121+
private fun updateItemIndex() {
122+
val itemInfo = info ?: return
123+
when {
124+
currentYBounds.first < itemInfo.offset && threshold < itemInfo.offset ->
125+
tryMoveUp(itemInfo)
126+
currentYBounds.second > itemInfo.offset + itemInfo.size && threshold > itemInfo.offset + itemInfo.size ->
127+
tryMoveDown(itemInfo)
128+
}
129+
}
130+
131+
// 위로 드래그할 때
132+
private fun tryMoveUp(itemInfo: LazyListItemInfo) {
133+
val target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemInfo.index - 1 }
134+
if (target != null) {
135+
// 대상 아이템이 reorderable일 때만 이동
136+
if (isReorderable(target.key) && onReorder(itemInfo.index, itemInfo.index - 1)) {
137+
info = target
138+
}
139+
} else {
140+
// 오토 스크롤 중 아이템이 화면에 보이지 않을 때
141+
val firstItem = lazyListState.layoutInfo.visibleItemsInfo.first()
142+
if (isReorderable(firstItem.key) && onReorder(itemInfo.index, firstItem.index)) {
143+
info = lazyListState.layoutInfo.visibleItemsInfo.first()
144+
}
145+
}
146+
}
147+
148+
// 아래로 드래그할 때
149+
private fun tryMoveDown(itemInfo: LazyListItemInfo) {
150+
val target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemInfo.index + 1 }
151+
if (target != null) {
152+
if (onReorder(itemInfo.index, itemInfo.index + 1)) {
153+
info = target
154+
}
155+
} else {
156+
// 오토 스크롤 중 아이템이 화면에 보이지 않을 때
157+
val lastItem = lazyListState.layoutInfo.visibleItemsInfo.last()
158+
if (onReorder(itemInfo.index, lastItem.index)) {
159+
info = lazyListState.layoutInfo.visibleItemsInfo.last()
160+
}
161+
}
162+
}
163+
164+
// 드래그 중
165+
fun onDrag(amount: Float) {
166+
if (dragStartIndex == null) return
167+
distance += amount
168+
updateItemIndex()
169+
onAutoScroll()
170+
}
171+
172+
// 드래그 종료
173+
fun onDragEnd() {
174+
initialYBounds = 0 to 0
175+
distance = 0f
176+
info = null
177+
dragStartIndex = null
178+
autoScrollJob?.cancel()
179+
autoScrollJob = null
180+
}
181+
182+
// 오토 스크롤
183+
private fun onAutoScroll() {
184+
if (autoScrollJob?.isActive == true) return
185+
autoScrollJob = scope.launch(Dispatchers.Main) {
186+
while (true) {
187+
val scrollOffset = when {
188+
currentYBounds.first < lazyListState.layoutInfo.viewportStartOffset -> -16f
189+
currentYBounds.second > lazyListState.layoutInfo.viewportEndOffset -> 16f
190+
else -> null
191+
}
192+
193+
scrollOffset?.let {
194+
lazyListState.scrollBy(it)
195+
delay(8)
196+
} ?: break
197+
}
198+
}
199+
}
200+
}

core/ui/src/main/res/values/strings.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,9 @@
106106
<string name="place_detail_modal_change_description">%s -> %s</string>
107107
<string name="place_detail_modal_change_confirm">변경하기</string>
108108
<string name="place_detail_modal_change_cancel">아니요</string>
109+
110+
<!-- Transport Bottom Sheet -->
111+
<string name="transport_bottom_sheet_title">이동수단 변경</string>
112+
<string name="transport_bottom_sheet_button">확인</string>
113+
109114
</resources>

core/util/src/main/java/com/yapp/ndgl/core/util/IntUtil.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,21 @@ package com.yapp.ndgl.core.util
22

33
import java.text.NumberFormat
44
import java.util.Locale
5+
import java.util.Locale.getDefault
56

67
fun Int.formatDecimal(): String = NumberFormat.getInstance(Locale.US).format(this)
8+
9+
fun Int.formatDistance(): String {
10+
return when {
11+
this >= 1000 -> {
12+
val km = this / 1000.0
13+
if (km % 1 == 0.0) {
14+
"${km.toInt()}km"
15+
} else {
16+
String.format(getDefault(), "%.1fkm", km)
17+
}
18+
}
19+
20+
else -> "${this}m"
21+
}
22+
}

0 commit comments

Comments
 (0)