Skip to content

Commit b42d2b9

Browse files
authored
Merge pull request #197 from YAPP-Github/refactor/#196-photo-detail-memo-outside-pager
[refactor] #196 PhotoDetail 메모 UI를 HorizontalPager 밖으로 분리
2 parents a361451 + 34a7437 commit b42d2b9

7 files changed

Lines changed: 165 additions & 121 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="28dp"
3+
android:height="28dp"
4+
android:viewportWidth="28"
5+
android:viewportHeight="28">
6+
<path
7+
android:pathData="M22.496,11.125L15.875,17.747C15.795,17.826 15.731,17.918 15.683,18.019L14.768,19.967C14.569,20.392 15.012,20.833 15.436,20.63L17.373,19.705C17.472,19.658 17.563,19.594 17.64,19.517L24.264,12.892C24.642,12.515 24.642,11.903 24.264,11.526L23.863,11.125C23.486,10.747 22.874,10.747 22.496,11.125Z"
8+
android:fillColor="#4F525F"/>
9+
<path
10+
android:pathData="M19.051,3.033C20.385,3.033 21.467,4.115 21.467,5.449V9.512C21.358,9.593 21.251,9.681 21.152,9.78L14.53,16.402C14.296,16.637 14.105,16.911 13.964,17.211L13.048,19.158C12.09,21.199 14.22,23.315 16.255,22.344L18.192,21.419C18.486,21.279 18.754,21.089 18.984,20.859L21.467,18.376V22.551C21.467,23.885 20.385,24.967 19.051,24.967H4.949C3.615,24.967 2.533,23.885 2.533,22.551V9.967H7.051C8.385,9.967 9.467,8.885 9.467,7.551V3.033H19.051ZM21.467,15.689L17.641,19.516C17.563,19.593 17.472,19.657 17.373,19.704L15.436,20.629L15.356,20.66C14.959,20.777 14.582,20.364 14.769,19.966L15.684,18.019C15.707,17.968 15.736,17.919 15.768,17.873L15.875,17.746L21.467,12.153V15.689ZM6.969,16.649C6.436,16.649 6.004,17.081 6.004,17.614C6.004,18.147 6.436,18.579 6.969,18.579H11.3C11.833,18.579 12.265,18.147 12.265,17.614C12.265,17.081 11.833,16.649 11.3,16.649H6.969ZM6.969,12.649C6.436,12.649 6.004,13.081 6.004,13.614C6.004,14.147 6.436,14.579 6.969,14.579H14.766C15.298,14.579 15.731,14.147 15.731,13.614C15.731,13.082 15.298,12.65 14.766,12.649H6.969ZM7.533,7.551C7.533,7.817 7.317,8.033 7.051,8.033H3.113C3.154,7.985 3.196,7.937 3.241,7.892L7.392,3.741C7.437,3.696 7.485,3.654 7.533,3.612V7.551Z"
11+
android:fillColor="#4F525F"/>
12+
</vector>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="28dp"
3+
android:height="28dp"
4+
android:viewportWidth="28"
5+
android:viewportHeight="28">
6+
<path
7+
android:pathData="M22.996,11.125L16.375,17.747C16.295,17.826 16.231,17.918 16.183,18.019L15.268,19.967C15.069,20.392 15.512,20.833 15.936,20.63L17.873,19.705C17.972,19.658 18.063,19.594 18.14,19.517L24.764,12.892C25.142,12.515 25.142,11.903 24.764,11.526L24.363,11.125C23.986,10.747 23.374,10.747 22.996,11.125Z"
8+
android:fillColor="#4F525F"/>
9+
<path
10+
android:pathData="M19.551,3.033C20.885,3.033 21.967,4.115 21.967,5.449V9.512C21.858,9.593 21.751,9.681 21.652,9.78L20.033,11.399V5.449C20.033,5.183 19.817,4.967 19.551,4.967H9.601C9.472,4.967 9.349,5.018 9.259,5.108L5.108,9.259C5.018,9.349 4.967,9.472 4.967,9.601V22.551C4.967,22.817 5.183,23.033 5.449,23.033H19.551C19.817,23.033 20.033,22.817 20.033,22.551V20.31L21.967,18.376V22.551C21.967,23.885 20.885,24.967 19.551,24.967H5.449C4.115,24.967 3.033,23.885 3.033,22.551V9.601C3.033,8.96 3.288,8.345 3.741,7.892L7.892,3.741C8.345,3.288 8.96,3.033 9.601,3.033H19.551ZM21.967,15.689L20.033,17.622V14.087L21.967,12.153V15.689Z"
11+
android:fillColor="#4F525F"/>
12+
<path
13+
android:pathData="M4,9H7.55C8.351,9 9,8.351 9,7.55V4"
14+
android:strokeWidth="1.93285"
15+
android:fillColor="#00000000"
16+
android:strokeColor="#4F525F"/>
17+
<path
18+
android:pathData="M7.469,13.614H15.265"
19+
android:strokeWidth="1.93"
20+
android:fillColor="#00000000"
21+
android:strokeColor="#4F525F"
22+
android:strokeLineCap="round"/>
23+
<path
24+
android:pathData="M7.469,17.614H11.8"
25+
android:strokeWidth="1.93"
26+
android:fillColor="#00000000"
27+
android:strokeColor="#4F525F"
28+
android:strokeLineCap="round"/>
29+
</vector>

feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailContract.kt

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package com.neki.android.feature.archive.impl.photo_detail
22

33
import com.neki.android.core.model.Photo
4-
import kotlinx.collections.immutable.ImmutableMap
5-
import kotlinx.collections.immutable.persistentMapOf
64

75
enum class MemoMode {
86
Closed,
@@ -18,12 +16,10 @@ data class PhotoDetailState(
1816
val isShowDeleteDialog: Boolean = false,
1917
val isShowOptionPopup: Boolean = false,
2018
val memo: String = "",
21-
val memoModes: ImmutableMap<Long, MemoMode> = persistentMapOf(),
19+
val memoMode: MemoMode = MemoMode.Closed,
2220
) {
2321
val currentIndex get() = if (photos.isEmpty()) 0 else currentPage.coerceIn(0, photos.lastIndex)
2422
val photo: Photo get() = photos.getOrElse(currentIndex) { Photo() }
25-
val currentMemoMode: MemoMode get() = memoModes[photo.id] ?: MemoMode.Closed
26-
fun memoModeOf(photoId: Long): MemoMode = memoModes[photoId] ?: MemoMode.Closed
2723
}
2824

2925
sealed interface PhotoDetailIntent {
@@ -37,6 +33,7 @@ sealed interface PhotoDetailIntent {
3733
data object ClickLeftPhoto : PhotoDetailIntent
3834
data object ClickRightPhoto : PhotoDetailIntent
3935
data class PageChanged(val page: Int) : PhotoDetailIntent
36+
data object PageScrollStarted : PhotoDetailIntent
4037

4138
// ActionBar Intent
4239
data object ClickDownloadIcon : PhotoDetailIntent

feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailScreen.kt

Lines changed: 107 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
package com.neki.android.feature.archive.impl.photo_detail
22

3+
import androidx.compose.animation.AnimatedVisibility
34
import androidx.compose.animation.core.spring
5+
import androidx.compose.animation.expandVertically
6+
import androidx.compose.animation.fadeIn
7+
import androidx.compose.animation.fadeOut
8+
import androidx.compose.animation.shrinkVertically
49
import androidx.compose.foundation.background
10+
import androidx.compose.foundation.layout.Box
511
import androidx.compose.foundation.layout.Column
12+
import androidx.compose.foundation.layout.WindowInsets
13+
import androidx.compose.foundation.layout.exclude
614
import androidx.compose.foundation.layout.fillMaxSize
715
import androidx.compose.foundation.layout.fillMaxWidth
16+
import androidx.compose.foundation.layout.ime
17+
import androidx.compose.foundation.layout.imePadding
18+
import androidx.compose.foundation.layout.padding
19+
import androidx.compose.foundation.layout.width
20+
import androidx.compose.foundation.layout.windowInsetsPadding
821
import androidx.compose.foundation.pager.HorizontalPager
922
import androidx.compose.foundation.pager.PagerState
1023
import androidx.compose.foundation.pager.rememberPagerState
@@ -16,17 +29,19 @@ import androidx.compose.runtime.remember
1629
import androidx.compose.runtime.rememberCoroutineScope
1730
import androidx.compose.runtime.setValue
1831
import androidx.compose.runtime.snapshotFlow
32+
import androidx.compose.ui.Alignment
1933
import androidx.compose.ui.Modifier
2034
import androidx.compose.ui.draw.clipToBounds
35+
import androidx.compose.ui.graphics.Color
2136
import androidx.compose.ui.layout.onSizeChanged
22-
import androidx.compose.foundation.layout.width
23-
import androidx.compose.ui.Alignment
2437
import androidx.compose.ui.platform.LocalContext
2538
import androidx.compose.ui.platform.LocalDensity
39+
import androidx.compose.ui.unit.Dp
2640
import androidx.compose.ui.unit.IntOffset
2741
import androidx.compose.ui.unit.dp
2842
import androidx.lifecycle.compose.collectAsStateWithLifecycle
2943
import com.neki.android.core.designsystem.DevicePreview
44+
import com.neki.android.core.designsystem.modifier.noRippleClickableSingle
3045
import com.neki.android.core.designsystem.topbar.BackTitleOptionTopBar
3146
import com.neki.android.core.designsystem.ui.theme.NekiTheme
3247
import com.neki.android.core.model.Photo
@@ -37,10 +52,12 @@ import com.neki.android.core.ui.compose.collectWithLifecycle
3752
import com.neki.android.core.ui.toast.NekiToast
3853
import com.neki.android.feature.archive.api.PhotoDetailResult
3954
import com.neki.android.feature.archive.impl.component.DeletePhotoDialog
55+
import com.neki.android.feature.archive.impl.photo_detail.component.MemoTextField
4056
import com.neki.android.feature.archive.impl.photo_detail.component.PhotoDetailActionBar
4157
import com.neki.android.feature.archive.impl.photo_detail.component.PhotoDetailImageItem
4258
import com.neki.android.feature.archive.impl.util.ImageDownloader
4359
import kotlinx.collections.immutable.persistentListOf
60+
import kotlinx.coroutines.flow.filter
4461
import kotlinx.coroutines.launch
4562
import timber.log.Timber
4663

@@ -66,6 +83,14 @@ internal fun PhotoDetailRoute(
6683
}
6784
}
6885

86+
LaunchedEffect(pagerState) {
87+
snapshotFlow { pagerState.isScrollInProgress }
88+
.filter { it }
89+
.collect {
90+
viewModel.store.onIntent(PhotoDetailIntent.PageScrollStarted)
91+
}
92+
}
93+
6994
viewModel.store.sideEffects.collectWithLifecycle { sideEffect ->
7095
when (sideEffect) {
7196
PhotoDetailSideEffect.NavigateBack -> navigateBack()
@@ -111,8 +136,6 @@ internal fun PhotoDetailScreen(
111136
onIntent: (PhotoDetailIntent) -> Unit = {},
112137
pagerState: PagerState = rememberPagerState { uiState.photos.size.coerceAtLeast(1) },
113138
) {
114-
val isMemoActive = uiState.currentMemoMode == MemoMode.Expanded ||
115-
uiState.currentMemoMode == MemoMode.Editing
116139
val density = LocalDensity.current
117140
var actionBarHeightDp by remember { mutableStateOf(0.dp) }
118141

@@ -127,46 +150,24 @@ internal fun PhotoDetailScreen(
127150
onClickIcon = { onIntent(PhotoDetailIntent.ClickOptionIcon) },
128151
)
129152

130-
HorizontalPager(
153+
PhotoDetailPagerArea(
131154
modifier = Modifier
132155
.fillMaxWidth()
133156
.weight(1f)
134157
.clipToBounds(),
135-
state = pagerState,
136-
beyondViewportPageCount = 1,
137-
userScrollEnabled = !isMemoActive,
138-
) { page ->
139-
val index = if (uiState.photos.isEmpty()) 0 else page.coerceIn(0, uiState.photos.lastIndex)
140-
141-
val photo = uiState.photos.getOrNull(index)
142-
val pageMemoMode = uiState.memoModeOf(photo?.id ?: 0L)
143-
val pageMemo = if (index == uiState.currentIndex) uiState.memo
144-
else photo?.memo.orEmpty()
145-
146-
PhotoDetailImageItem(
147-
imageUrl = photo?.imageUrl,
148-
memo = pageMemo,
149-
memoMode = pageMemoMode,
150-
actionBarHeight = actionBarHeightDp,
151-
isScrollInProgress = pagerState.isScrollInProgress,
152-
isTapEnabled = pageMemoMode != MemoMode.Expanded && pageMemoMode != MemoMode.Editing,
153-
onClickLeft = { onIntent(PhotoDetailIntent.ClickLeftPhoto) },
154-
onClickRight = { onIntent(PhotoDetailIntent.ClickRightPhoto) },
155-
onClickMemoMore = { onIntent(PhotoDetailIntent.ClickMemoMore) },
156-
onClickMemoText = { onIntent(PhotoDetailIntent.ClickMemoText) },
157-
onClickMemoFold = { onIntent(PhotoDetailIntent.ClickMemoFold) },
158-
onClickMemoCancel = { onIntent(PhotoDetailIntent.ClickMemoCancel) },
159-
onClickMemoDone = { onIntent(PhotoDetailIntent.ClickMemoDone(it)) },
160-
onMemoTextChanged = { onIntent(PhotoDetailIntent.MemoTextChanged(it)) },
161-
)
162-
}
158+
uiState = uiState,
159+
actionBarHeightDp = actionBarHeightDp,
160+
pagerState = pagerState,
161+
onIntent = onIntent,
162+
)
163163

164-
if (uiState.currentMemoMode != MemoMode.Editing) {
164+
if (uiState.memoMode != MemoMode.Editing) {
165165
PhotoDetailActionBar(
166166
modifier = Modifier.onSizeChanged { size ->
167167
actionBarHeightDp = with(density) { size.height.toDp() }
168168
},
169169
isFavorite = uiState.photo.isFavorite,
170+
hasMemo = uiState.photo.memo.isNotEmpty(),
170171
onClickDownload = { onIntent(PhotoDetailIntent.ClickDownloadIcon) },
171172
onClickFavorite = { onIntent(PhotoDetailIntent.ClickFavoriteIcon) },
172173
onClickMemo = { onIntent(PhotoDetailIntent.ClickMemoIcon) },
@@ -204,6 +205,78 @@ internal fun PhotoDetailScreen(
204205
}
205206
}
206207

208+
@Composable
209+
private fun PhotoDetailPagerArea(
210+
modifier: Modifier,
211+
uiState: PhotoDetailState,
212+
actionBarHeightDp: Dp,
213+
pagerState: PagerState,
214+
onIntent: (PhotoDetailIntent) -> Unit,
215+
) {
216+
val isMemoActive = uiState.memoMode == MemoMode.Expanded ||
217+
uiState.memoMode == MemoMode.Editing
218+
219+
Box(modifier = modifier) {
220+
HorizontalPager(
221+
modifier = Modifier
222+
.fillMaxSize()
223+
.padding(bottom = if (uiState.memoMode == MemoMode.Editing) actionBarHeightDp else 0.dp),
224+
state = pagerState,
225+
beyondViewportPageCount = 1,
226+
userScrollEnabled = !isMemoActive,
227+
) { page ->
228+
val index = if (uiState.photos.isEmpty()) 0 else page.coerceIn(0, uiState.photos.lastIndex)
229+
val photo = uiState.photos.getOrNull(index)
230+
231+
PhotoDetailImageItem(
232+
imageUrl = photo?.imageUrl,
233+
isScrollInProgress = pagerState.isScrollInProgress,
234+
isTapEnabled = !isMemoActive,
235+
onClickLeft = { onIntent(PhotoDetailIntent.ClickLeftPhoto) },
236+
onClickRight = { onIntent(PhotoDetailIntent.ClickRightPhoto) },
237+
)
238+
}
239+
240+
AnimatedVisibility(
241+
visible = isMemoActive,
242+
enter = fadeIn(),
243+
exit = fadeOut(),
244+
) {
245+
Box(
246+
modifier = Modifier
247+
.fillMaxSize()
248+
.background(Color(0x80202227))
249+
.noRippleClickableSingle { onIntent(PhotoDetailIntent.ClickMemoFold) },
250+
)
251+
}
252+
253+
AnimatedVisibility(
254+
modifier = Modifier
255+
.align(Alignment.BottomCenter)
256+
.then(
257+
if (uiState.memoMode == MemoMode.Editing) Modifier.imePadding()
258+
else Modifier.windowInsetsPadding(
259+
WindowInsets.ime.exclude(WindowInsets(bottom = actionBarHeightDp)),
260+
),
261+
),
262+
visible = uiState.memoMode != MemoMode.Closed,
263+
enter = expandVertically(expandFrom = Alignment.Top),
264+
exit = shrinkVertically(shrinkTowards = Alignment.Top),
265+
) {
266+
MemoTextField(
267+
memo = uiState.memo,
268+
memoMode = uiState.memoMode,
269+
onClickMemoMore = { onIntent(PhotoDetailIntent.ClickMemoMore) },
270+
onClickMemoText = { onIntent(PhotoDetailIntent.ClickMemoText) },
271+
onClickMemoFold = { onIntent(PhotoDetailIntent.ClickMemoFold) },
272+
onClickMemoCancel = { onIntent(PhotoDetailIntent.ClickMemoCancel) },
273+
onClickMemoDone = { onIntent(PhotoDetailIntent.ClickMemoDone(it)) },
274+
onMemoTextChanged = { onIntent(PhotoDetailIntent.MemoTextChanged(it)) },
275+
)
276+
}
277+
}
278+
}
279+
207280
@DevicePreview
208281
@Composable
209282
private fun PhotoDetailScreenPreview() {

feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import dagger.assisted.Assisted
1313
import dagger.assisted.AssistedFactory
1414
import dagger.assisted.AssistedInject
1515
import dagger.hilt.android.lifecycle.HiltViewModel
16-
import kotlinx.collections.immutable.toImmutableMap
1716
import kotlinx.coroutines.CoroutineScope
1817
import kotlinx.coroutines.FlowPreview
1918
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -116,6 +115,8 @@ class PhotoDetailViewModel @AssistedInject constructor(
116115
preloadIfNeeded(reduce)
117116
}
118117

118+
PhotoDetailIntent.PageScrollStarted -> reduce { copy(memoMode = MemoMode.Closed) }
119+
119120
// ActionBar Intent
120121
PhotoDetailIntent.ClickDownloadIcon -> postSideEffect(PhotoDetailSideEffect.DownloadImage(state.photo.imageUrl))
121122
PhotoDetailIntent.ClickFavoriteIcon -> handleFavoriteToggle(state, reduce)
@@ -137,40 +138,36 @@ class PhotoDetailViewModel @AssistedInject constructor(
137138
// Memo Intent
138139
is PhotoDetailIntent.MemoTextChanged -> reduce { copy(memo = intent.text) }
139140
PhotoDetailIntent.ClickMemoIcon -> reduce {
140-
val photoId = photo.id
141-
val current = memoModeOf(photoId)
142-
val newMode = if (current == MemoMode.Closed) MemoMode.Preview else MemoMode.Closed
143-
copy(memoModes = (memoModes + (photoId to newMode)).toImmutableMap())
141+
copy(memoMode = if (memoMode == MemoMode.Closed) MemoMode.Preview else MemoMode.Closed)
144142
}
145143

146144
PhotoDetailIntent.ClickMemoMore -> reduce {
147-
copy(memoModes = (memoModes + (photo.id to MemoMode.Expanded)).toImmutableMap())
145+
copy(memoMode = MemoMode.Expanded)
148146
}
149147

150148
PhotoDetailIntent.ClickMemoText -> reduce {
151-
copy(memoModes = (memoModes + (photo.id to MemoMode.Editing)).toImmutableMap())
149+
copy(memoMode = MemoMode.Editing)
152150
}
153151

154152
PhotoDetailIntent.ClickMemoFold -> reduce {
155153
copy(
156154
memo = photo.memo,
157-
memoModes = (memoModes + (photo.id to MemoMode.Preview)).toImmutableMap(),
155+
memoMode = MemoMode.Preview,
158156
)
159157
}
160158

161159
PhotoDetailIntent.ClickMemoCancel -> reduce {
162160
copy(
163161
memo = photo.memo,
164-
memoModes = (memoModes + (photo.id to MemoMode.Preview)).toImmutableMap(),
162+
memoMode = MemoMode.Preview,
165163
)
166164
}
167165

168166
is PhotoDetailIntent.ClickMemoDone -> {
169-
val photoId = state.photo.id
170167
reduce {
171168
copy(
172169
memo = intent.memo,
173-
memoModes = (memoModes + (photoId to MemoMode.Preview)).toImmutableMap(),
170+
memoMode = MemoMode.Preview,
174171
)
175172
}
176173
saveMemo(state.copy(memo = intent.memo), reduce, postSideEffect)

feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailActionBar.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.neki.android.core.designsystem.ui.theme.NekiTheme
1919
@Composable
2020
internal fun PhotoDetailActionBar(
2121
isFavorite: Boolean,
22+
hasMemo: Boolean,
2223
modifier: Modifier = Modifier,
2324
onClickDownload: () -> Unit = {},
2425
onClickFavorite: () -> Unit = {},
@@ -61,7 +62,10 @@ internal fun PhotoDetailActionBar(
6162
) {
6263
Icon(
6364
modifier = Modifier.size(28.dp),
64-
imageVector = ImageVector.vectorResource(R.drawable.icon_memo),
65+
imageVector = ImageVector.vectorResource(
66+
if (hasMemo) R.drawable.icon_memo_filled
67+
else R.drawable.icon_memo_stroked,
68+
),
6569
contentDescription = null,
6670
tint = NekiTheme.colorScheme.gray700,
6771
)
@@ -88,6 +92,7 @@ private fun PhotoDetailActionBarClosedPreview() {
8892
NekiTheme {
8993
PhotoDetailActionBar(
9094
isFavorite = false,
95+
hasMemo = false,
9196
)
9297
}
9398
}
@@ -98,6 +103,7 @@ private fun PhotoDetailActionBarMemoActivePreview() {
98103
NekiTheme {
99104
PhotoDetailActionBar(
100105
isFavorite = true,
106+
hasMemo = true,
101107
)
102108
}
103109
}

0 commit comments

Comments
 (0)