From 2258e2496a3bc63cf780638e0c3f710a1fa38522 Mon Sep 17 00:00:00 2001 From: soochan Date: Tue, 1 Jul 2025 19:37:44 +0900 Subject: [PATCH 1/6] =?UTF-8?q?Feat=20:=20Evnet=20=20SelectCategory=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20ViewModel=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카테고리 클릭 시, categoryId 저장 --- .../java/com/chan/category/ui/CategoryContract.kt | 1 + .../java/com/chan/category/ui/CategoryViewModel.kt | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt b/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt index 658cdd3f..e714ce27 100644 --- a/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt +++ b/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt @@ -8,6 +8,7 @@ import com.chan.category.ui.model.CategoryModel class CategoryContract { sealed class Event : ViewEvent { object CategoriesLoad : Event() + data class SelectCategory(val categoryId: Int): Event() } data class State( diff --git a/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt b/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt index 3378d742..b302e685 100644 --- a/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt +++ b/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt @@ -18,6 +18,7 @@ class CategoryViewModel @Inject constructor( override fun handleEvent(event: CategoryContract.Event) { when (event) { CategoryContract.Event.CategoriesLoad -> getCategories() + is CategoryContract.Event.SelectCategory -> updateSelectedCategoryId(event.categoryId) } } @@ -26,7 +27,18 @@ class CategoryViewModel @Inject constructor( setState { copy(isLoading = true, isError = false) } val categoryList = categoryRepository.getCategories().map { it.toPresentation() } - setState { copy(categoryList = categoryList, isLoading = false) } + val firstId = categoryList.firstOrNull()?.id + setState { + copy( + categoryList = categoryList, + selectedCategoryId = firstId, + isLoading = false + ) + } } } + + private fun updateSelectedCategoryId(categoryId: Int) { + setState { copy(selectedCategoryId = categoryId) } + } } \ No newline at end of file From 2eccc90f8511a02cb3ada45aba5c86b273c28603 Mon Sep 17 00:00:00 2001 From: soochan Date: Tue, 1 Jul 2025 19:39:14 +0900 Subject: [PATCH 2/6] =?UTF-8?q?Feat=20:=20=EA=B8=B0=EB=B3=B8=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=99=94=EB=A9=B4=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대분류 카테고리 clickable 기능 추가 --- .../category/ui/composables/CategoryScreen.kt | 78 +++++++++++++------ 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt b/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt index 074137cb..ee49f76f 100644 --- a/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt +++ b/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt @@ -1,15 +1,16 @@ package com.chan.category.ui.composables import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,42 +40,75 @@ fun CategoryScreen( Row( modifier = Modifier.fillMaxSize() ) { + //대분류 카테고리 LazyColumn( modifier = Modifier - .width(100.dp) + .fillMaxWidth(0.33f) .fillMaxHeight() - .background(color = Color(0xFFF5F5F5)) + .background(color = Color(0xFFF6F7F9)) ) { items(items = state.categoryList, key = { it.id }) { category -> - Text( - text = category.name, + val selected = category.id == state.selectedCategoryId + Box( modifier = Modifier .fillMaxWidth() + .background( + color = if (selected) Color.White else Color.Transparent, + ) + .clickable { + categoryViewModel.setEvent( + CategoryContract.Event.SelectCategory( + category.id + ) + ) + } .padding(vertical = 12.dp, horizontal = 8.dp) - ) + ) { + Text( + text = category.name, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 8.dp), + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal + ), + color = if (selected) Color.Black else Color.Gray + ) + } } } - - - + //대분류 카테고리에 따른 리스트 LazyColumn( modifier = Modifier .fillMaxHeight() - .weight(1f), - verticalArrangement = Arrangement.spacedBy(24.dp) + .fillMaxWidth() + .background(color = Color.White) + .padding(start = 20.dp) ) { state.categoryList.forEach { category -> - item(key = "header-${category.id}") { - Text( - text = category.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = Color.Black, - modifier = Modifier.padding(vertical = 8.dp) - ) - } category.subCategoryItems.forEach { subCategory -> - + item(key = subCategory.id) { + Text( + text = subCategory.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color.Black, + modifier = Modifier.padding(top = 25.dp) + ) + } + items( + items = subCategory.items, + key = { it.id } + ) { subItem -> + Text( + text = subItem.name, + style = MaterialTheme.typography.titleSmall, + color = Color.DarkGray, + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp) + ) + } } } } From 8005510e2f084527db8ca5d83dfcc41e1d197939 Mon Sep 17 00:00:00 2001 From: soochan Date: Tue, 1 Jul 2025 20:13:14 +0900 Subject: [PATCH 3/6] =?UTF-8?q?Feat:=20CategoryViewModel=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/chan/category/ui/CategoryContract.kt | 2 +- .../com/chan/category/ui/CategoryViewModel.kt | 21 ++++++++++++------- .../category/ui/composables/CategoryScreen.kt | 10 ++++++++- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt b/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt index e714ce27..4ce5be01 100644 --- a/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt +++ b/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt @@ -19,6 +19,6 @@ class CategoryContract { ) : ViewState sealed class Effect : ViewEffect { - //추후 네비게이션 추가 예정 + data class ShowError(val errorMessage: String) : Effect() } } \ No newline at end of file diff --git a/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt b/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt index b302e685..0423eb72 100644 --- a/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt +++ b/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt @@ -26,14 +26,19 @@ class CategoryViewModel @Inject constructor( viewModelScope.launch { setState { copy(isLoading = true, isError = false) } - val categoryList = categoryRepository.getCategories().map { it.toPresentation() } - val firstId = categoryList.firstOrNull()?.id - setState { - copy( - categoryList = categoryList, - selectedCategoryId = firstId, - isLoading = false - ) + try { + val categoryList = categoryRepository.getCategories().map { it.toPresentation() } + val firstId = categoryList.firstOrNull()?.id + setState { + copy( + categoryList = categoryList, + selectedCategoryId = firstId, + isLoading = false + ) + } + } catch(e: Exception) { + setState { copy(isLoading = false, isError = true) } + setEffect { CategoryContract.Effect.ShowError(e.message.toString()) } } } } diff --git a/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt b/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt index ee49f76f..19c26a23 100644 --- a/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt +++ b/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt @@ -1,5 +1,6 @@ package com.chan.category.ui.composables +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -10,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -32,10 +32,18 @@ fun CategoryScreen( ) { val state by categoryViewModel.viewState.collectAsState() + val effects = categoryViewModel.effect LaunchedEffect(Unit) { categoryViewModel.setEvent(CategoryContract.Event.CategoriesLoad) } + LaunchedEffect(effects) { + effects.collect { effect -> + when(effect) { + is CategoryContract.Effect.ShowError -> Log.d("CategoryScreen", " Error : ${effect.errorMessage}") + } + } + } Row( modifier = Modifier.fillMaxSize() From 775e2693684a56365f8ece0a133e19064b933bcc Mon Sep 17 00:00:00 2001 From: soochan Date: Tue, 1 Jul 2025 20:36:54 +0900 Subject: [PATCH 4/6] =?UTF-8?q?build=20:=20kapt=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + feature/category/build.gradle.kts | 2 +- gradle/libs.versions.toml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 3ed43670..3c45f039 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.jetbrains.kotlin.jvm) apply false + alias(libs.plugins.kotlin.kapt) apply false } diff --git a/feature/category/build.gradle.kts b/feature/category/build.gradle.kts index 8ac342fd..13431570 100644 --- a/feature/category/build.gradle.kts +++ b/feature/category/build.gradle.kts @@ -2,7 +2,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.hilt) - kotlin("kapt") + alias(libs.plugins.kotlin.kapt) } android { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a64f6e0f..45889217 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,6 +70,7 @@ google-gson = { group = "com.google.code.gson", name = "gson", version.ref = "gs android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hiltVersion" } jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" } From a824cf871af615fb785b844cc3755420ce8973f4 Mon Sep 17 00:00:00 2001 From: soochan Date: Wed, 2 Jul 2025 14:44:49 +0900 Subject: [PATCH 5/6] =?UTF-8?q?Feature:=20State=20headerPositions=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit first : subCategoryItems.id index second : headerCategory id --- .../com/chan/category/ui/CategoryContract.kt | 4 ++- .../com/chan/category/ui/CategoryViewModel.kt | 31 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt b/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt index 4ce5be01..9a8de644 100644 --- a/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt +++ b/feature/category/src/main/java/com/chan/category/ui/CategoryContract.kt @@ -9,11 +9,13 @@ class CategoryContract { sealed class Event : ViewEvent { object CategoriesLoad : Event() data class SelectCategory(val categoryId: Int): Event() + data class CategoryScrolledIndex (val firstVisibleItemIndex: Int): Event() } data class State( val categoryList: List = emptyList(), - var selectedCategoryId: Int? = null, + val selectedCategoryId: Int? = null, + val headerPositions : List> = emptyList(), val isLoading: Boolean = false, val isError: Boolean = false ) : ViewState diff --git a/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt b/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt index 0423eb72..1a2b352b 100644 --- a/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt +++ b/feature/category/src/main/java/com/chan/category/ui/CategoryViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope import com.chan.android.BaseViewModel import com.chan.category.domian.CategoryRepository import com.chan.category.ui.mapper.toPresentation +import com.chan.category.ui.model.CategoryModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -17,8 +18,17 @@ class CategoryViewModel @Inject constructor( override fun handleEvent(event: CategoryContract.Event) { when (event) { - CategoryContract.Event.CategoriesLoad -> getCategories() + is CategoryContract.Event.CategoriesLoad -> getCategories() is CategoryContract.Event.SelectCategory -> updateSelectedCategoryId(event.categoryId) + is CategoryContract.Event.CategoryScrolledIndex -> { + val newId = viewState.value.headerPositions + .filter { it.first <= event.firstVisibleItemIndex } + .maxByOrNull { it.first } + ?.second + ?: viewState.value.selectedCategoryId + + setState { copy(selectedCategoryId = newId) } + } } } @@ -29,14 +39,17 @@ class CategoryViewModel @Inject constructor( try { val categoryList = categoryRepository.getCategories().map { it.toPresentation() } val firstId = categoryList.firstOrNull()?.id + + val mappings = categoryHeaderMapping(categoryList) setState { copy( categoryList = categoryList, selectedCategoryId = firstId, + headerPositions = mappings, isLoading = false ) } - } catch(e: Exception) { + } catch (e: Exception) { setState { copy(isLoading = false, isError = true) } setEffect { CategoryContract.Effect.ShowError(e.message.toString()) } } @@ -46,4 +59,18 @@ class CategoryViewModel @Inject constructor( private fun updateSelectedCategoryId(categoryId: Int) { setState { copy(selectedCategoryId = categoryId) } } + + private fun categoryHeaderMapping(categories: List): List> { + val list = mutableListOf>() + var index = 0 + categories.forEach { category -> + category.subCategoryItems.forEach { subCategory -> + //리스트 subCategoryItem.id + list += index to category.id + //헤더에 속한 CategoryId + index += 1 + subCategory.items.size + } + } + return list + } } \ No newline at end of file From 9f7133e2b6ca2c7cb6f75064fcab428a5d2c3784 Mon Sep 17 00:00:00 2001 From: soochan Date: Wed, 2 Jul 2025 14:46:26 +0900 Subject: [PATCH 6/6] =?UTF-8?q?Feature:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B5=9C=EC=83=81=EB=8B=A8=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EC=9C=84=EC=B9=98=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20UI=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../category/ui/composables/CategoryScreen.kt | 62 +++++++++++++++++-- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt b/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt index 19c26a23..fef69a6c 100644 --- a/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt +++ b/feature/category/src/main/java/com/chan/category/ui/composables/CategoryScreen.kt @@ -11,12 +11,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight @@ -24,6 +27,11 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.chan.category.ui.CategoryContract import com.chan.category.ui.CategoryViewModel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import kotlin.math.abs @Composable @@ -33,22 +41,56 @@ fun CategoryScreen( val state by categoryViewModel.viewState.collectAsState() val effects = categoryViewModel.effect + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() LaunchedEffect(Unit) { categoryViewModel.setEvent(CategoryContract.Event.CategoriesLoad) } LaunchedEffect(effects) { effects.collect { effect -> - when(effect) { - is CategoryContract.Effect.ShowError -> Log.d("CategoryScreen", " Error : ${effect.errorMessage}") + when (effect) { + is CategoryContract.Effect.ShowError -> Log.d( + "CategoryScreen", + " Error : ${effect.errorMessage}" + ) } } } + LaunchedEffect(listState) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo } + .map { visibleItems -> + //중앙에 가까운 Items + val start = listState.layoutInfo.viewportStartOffset + val end = listState.layoutInfo.viewportEndOffset + val centerY = (start + end) / 2 + + visibleItems.minByOrNull { info -> + val itemCenter = info.offset + info.size / 2 + abs(itemCenter - centerY) + }?.index + } + .mapNotNull { itemIndex -> + // 헤더의 CategoryIndex 구함 + itemIndex?.let { + state.headerPositions + .filter { it.first <= itemIndex } + .maxByOrNull { it.first } + ?.second + } + } + .distinctUntilChanged() + .collect { newCatId -> + categoryViewModel.setEvent( + CategoryContract.Event.SelectCategory(newCatId) + ) + } + } + Row( modifier = Modifier.fillMaxSize() ) { - //대분류 카테고리 LazyColumn( modifier = Modifier .fillMaxWidth(0.33f) @@ -64,6 +106,16 @@ fun CategoryScreen( color = if (selected) Color.White else Color.Transparent, ) .clickable { + val targetIndex = state.headerPositions + .first { it.second == category.id } + .first + + scope.launch { + listState.scrollToItem( + index = targetIndex, + scrollOffset = 0 + ) + } categoryViewModel.setEvent( CategoryContract.Event.SelectCategory( category.id @@ -85,8 +137,8 @@ fun CategoryScreen( } } } - //대분류 카테고리에 따른 리스트 LazyColumn( + state = listState, modifier = Modifier .fillMaxHeight() .fillMaxWidth() @@ -95,7 +147,7 @@ fun CategoryScreen( ) { state.categoryList.forEach { category -> category.subCategoryItems.forEach { subCategory -> - item(key = subCategory.id) { + item(key = "header-${subCategory.id}") { Text( text = subCategory.name, style = MaterialTheme.typography.titleMedium,