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/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..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 @@ -8,16 +8,19 @@ 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 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 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 3378d742..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,7 +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) } + } } } @@ -25,8 +36,41 @@ class CategoryViewModel @Inject constructor( viewModelScope.launch { setState { copy(isLoading = true, isError = false) } - val categoryList = categoryRepository.getCategories().map { it.toPresentation() } - setState { copy(categoryList = categoryList, isLoading = false) } + 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) { + setState { copy(isLoading = false, isError = true) } + setEffect { CategoryContract.Effect.ShowError(e.message.toString()) } + } + } + } + + 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 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..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 @@ -1,21 +1,25 @@ package com.chan.category.ui.composables +import android.util.Log 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 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 @@ -23,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 @@ -31,50 +40,135 @@ 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}" + ) + } + } + } + + 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 - .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 { + 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 + ) + ) + } .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( + state = listState, 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 = "header-${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) + ) + } } } } 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" }