이번 포스팅에서는 마지막 기능인 카테고리 기능을 구현한 것에 대해 포스팅을 하려고 한다.
꽤나 오래 걸렸는데, 내가 천천히 개발한 것도 있고 카테고리를 만들면서 다른 UI적 요소와 기능들이 추가되어야 했기 때문에 오래 걸렸던 것 같다.
바로 내가 어떻게 카테고리 기능을 구현했는지 간단하게 알아보도록 하자.
간단히 알아볼 예정이라 코드에 대한 설명이 미흡할 수 있다..!
1. 카테고리 화면 생성
우선은 먼저 카테고리 화면을 생성해 주었다.
카테고리 화면에서는 자신이 생성한 모든 카테고리를 볼 수 있고, 카테고리를 수정하거나 삭제할 수 있도록 만들어 주었다.
@Composable
fun CategoryScreen(categoryViewModel: CategoryViewModel = viewModel()) {
. . .
}
category 화면으로 사용할 composable을 만들어 주고,
@Composable
fun PlannerV2NavHost(
navHostController: NavHostController,
startDestination: String
) {
NavHost(navController = navHostController, startDestination = startDestination) {
loginScreen(
navigateToPlan = { navHostController.navigateToPlan() }
)
planScreen(
navigateToCreatePlan = { navHostController.navigateToCreatePlan(it) }
)
createPlanScreen(
navigateToPlan = { navHostController.navigateToPlan() }
)
statisticsScreen()
categoryScreen()
}
}
fun NavGraphBuilder.categoryScreen() {
composable(route = categoryRoute) {
CategoryScreen()
}
}
카테고리 Screen을 Navigation에 연결해 주었다.
2. 카테고리 기능 구현
카테고리 화면에는 카테고리 정보를 담을 Item이 필요하다. 그것을 먼저 만들어 주었다.
소스 코드.
Box(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.width(10.dp)
.fillMaxHeight()
.clip(CircleShape)
.background(Color(android.graphics.Color.parseColor(categoryData.categoryColorHex)))
)
Column(
modifier = Modifier
.padding(horizontal = 5.dp)
.weight(1f)
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = categoryData.categoryTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
modifier = Modifier.fillMaxWidth(),
text = "생성일: ${formatLocalDateTime(categoryData.createdTime?.unixTimestampToLocalDateTime())}",
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Icon(
modifier = Modifier
.padding(end = 5.dp)
.clip(CircleShape)
.clickable { categoryModifyState.value = true },
imageVector = Icons.Default.Create,
contentDescription = "category modify",
tint = Color(0xFFFFDB86)
)
Icon(
modifier = Modifier
.clip(CircleShape)
.clickable { categoryDeleteState.value = true },
imageVector = Icons.Default.Delete,
tint = Color.Red,
contentDescription = "category delete"
)
}
}
완성한 카테고리 Item
그다음으로는 카테고리를 생성하기 위한 Dialog를 만들어 주었다.
Dialog에는 카테고리 이름과 색상을 설정할 수 있도록 해주었다.
Dialog 소스 코드
@Composable
fun CreateCategoryDialog(
onDismissRequest: () -> Unit,
onSaveClick: (CategoryData) -> Unit,
onCancelClick: () -> Unit,
) {
val titleState = remember { mutableStateOf("") }
val colorState = remember { mutableStateOf("#FFFFFFFF") }
val saveVisibility = remember { mutableStateOf(false) }
LaunchedEffect(titleState.value) {
saveVisibility.value = titleState.value.isNotBlank()
}
Dialog(onDismissRequest = onDismissRequest) {
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(elevation = 1.dp, shape = RoundedCornerShape(8.dp))
.background(Color(0xFFFAFAFA))
.clip(RoundedCornerShape(8.dp))
.padding(10.dp),
) {
Text(
text = "새 카테고리",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
Row(verticalAlignment = Alignment.CenterVertically) {
PlannerV2TextField(
modifier = Modifier
.weight(1f)
.padding(vertical = 10.dp)
.height(45.dp),
value = titleState.value,
hint = "카테고리 이름을 입력하세요.",
singleLine = true,
maxLines = 1
) { titleState.value = it }
Box(
modifier = Modifier
.padding(start = 10.dp)
.size(30.dp)
.background(Color(android.graphics.Color.parseColor(colorState.value))),
)
}
Divider()
ColorPicker(
modifier = Modifier
.height(150.dp)
.padding(vertical = 10.dp)
) {
colorState.value = it.toHexCode()
}
Row(modifier = Modifier.fillMaxWidth()) {
Button(
modifier = Modifier.width(100.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6EC4A7)),
onClick = onCancelClick
) { Text(text = "취소") }
Spacer(modifier = Modifier.weight(1f))
AnimatedVisibility(
visible = saveVisibility.value,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it + 10 }) // dialog에 기본적으로 설정된 padding 값(10) 만큼 추가 값을 부여함.
) {
Button(
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFFDB86)),
onClick = {
onSaveClick(
CategoryData(
id = UUID.randomUUID().toString(),
categoryTitle = titleState.value,
categoryColorHex = colorState.value,
createdTime = System.currentTimeMillis()
)
)
}
) { Text(text = "생성하기") }
}
}
}
}
}
완성한 Dialog UI
생성하기 Button은 TextField의 text가 입력되어 있는 경우에만 활성화되도록 만들어 주었다.
ColorPicker는 외부 라이브러리를 사용한 것이다.
https://github.com/godaddy/compose-color-picker
ColorPicker 코드도 첨부해 주겠다.
@Composable
fun ColorPicker(
modifier: Modifier = Modifier,
initialColor: Color = Color.White,
onColorChange: (color: Color) -> Unit
) {
ClassicColorPicker(
modifier = modifier,
color = HsvColor.from(initialColor),
onColorChanged = { hsvColor: HsvColor ->
onColorChange(hsvColor.toColor())
}
)
}
카테고리 화면에 대한 설명은 간단히 끝냈으니, 이제는 일정 쪽에 카테고리를 적용시켜야 할 차례이다.
3. 다른 UI에 카테고리 설정 기능 추가
우선 일정을 생성할 때, 카테고리를 정하고 생성할 수 있도록 만들어 주었다.
dropdown 소스 코드
@Composable
fun CategoryDropdown(
modifier: Modifier = Modifier,
dropdownItems: List<CategoryData>,
title: String,
checkBoxStateList: List<Boolean>,
onDropdownCheckBoxClick: (Boolean, CategoryData) -> Unit
) {
val dropdownExpendedState = remember { mutableStateOf(false) }
Column {
Row(
modifier = modifier
.clickable { dropdownExpendedState.value = true },
verticalAlignment = Alignment.CenterVertically
) {
// dropdown menu title
Text(
modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
modifier = Modifier.padding(10.dp),
imageVector = Icons.Default.ArrowDropDown,
contentDescription = "dropdown open"
)
}
val dropdownScrollState = rememberScrollState()
DropdownMenu(
modifier = Modifier.background(Color.White),
expanded = dropdownExpendedState.value,
onDismissRequest = { dropdownExpendedState.value = false }
) {
Box(
modifier = Modifier
.height(150.dp)
.verticalScroll(dropdownScrollState)
) {
Column {
dropdownItems.forEachIndexed { index, it ->
DropdownMenuItem(
text = {
Text(
modifier = Modifier
.padding(start = 7.dp)
.fillMaxWidth(),
text = it.categoryTitle,
color = Color(parseColor(it.categoryColorHex))
)
},
onClick = { dropdownExpendedState.value = false },
leadingIcon = {
Checkbox(
checked = checkBoxStateList[index],
onCheckedChange = { checked ->
onDropdownCheckBoxClick(checked, it)
}
)
},
trailingIcon = { /* 일부러 비워놔서 checkBox와 맞지 않는 공백을 맞춤. */ }
)
}
}
}
}
}
}
생성한 카테고리가 많을 경우, dropdown이 끝도 없이 늘어날 것이기에 height를 고정시키고 verticalScroll()을 달아서 scroll 할 수 있도록 만들어 주었다.
dropdown 결과
그리고 생성된 일정 정보를 보여주는 Item에는 어떤 카테고리를 설정했는지도 보일 수 있도록 LazyRow를 만들어 주었다.
설정한 카테고리 소스 코드
LazyRow(
modifier = Modifier.padding(top = 5.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
items(categoryData.sortedBy { it["title"].toString() }) {map ->
CategoryBadge(title = map["title"].toString(), colorHex = map["color"].toString())
}
}
@Composable
fun CategoryBadge(title: String, colorHex: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(Color(android.graphics.Color.parseColor(colorHex)))
)
Text(
modifier = Modifier.padding(start = 5.dp),
text = title,
fontSize = 9.sp,
fontWeight = FontWeight.Thin
)
}
}
설정된 카테고리 Badge 목록.
또한 일정의 Item 메뉴에 카테고리 설정 메뉴를 추가해서 설정한 카테고리를 변경할 수 있도록 만들어 주었다.
카테고리 변경은 bottomSheet로 만들어 주었다.
bottomSheet 소스 코드
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SetCategoryBottomSheet(
categoryList: List<CategoryData> = listOf(),
selectedCategories: Collection<Map<String, Any>>,
onDismissRequest: () -> Unit,
onSaveClick: (Map<String, Map<String, Any>>) -> Unit
) {
val bottomSheetState = rememberModalBottomSheetState()
val updateCategoryMap = mutableMapOf<String, Map<String, Any>>() // 카테고리를 업데이트할 변수.
val scope = rememberCoroutineScope()
ModalBottomSheet(
sheetState = bottomSheetState,
onDismissRequest = onDismissRequest,
dragHandle = null,
shape = RectangleShape
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFFAFAFA))
) {
Text(
modifier = Modifier.padding(10.dp),
text = "카테고리 변경"
)
Divider()
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
) {
items(categoryList) {
val checkedState = remember { mutableStateOf(selectedCategories.map { map -> map["title"] }.contains(it.categoryTitle)) }
if (checkedState.value) updateCategoryMap[it.id] = mapOf(
"title" to it.categoryTitle,
"color" to it.categoryColorHex,
)
CategoryBottomSheetItem(
categoryData = it,
checked = checkedState.value,
onCheckedChange = { checked ->
checkedState.value = checked
if (checked) {
updateCategoryMap[it.id] = mapOf(
"title" to it.categoryTitle,
"color" to it.categoryColorHex,
)
} else updateCategoryMap.remove(it.id)
}
)
}
}
Divider(modifier = Modifier.padding(top = 10.dp))
Button(
modifier = Modifier.padding(10.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6EC4A7)),
onClick = {
onSaveClick(updateCategoryMap)
scope.launch {
bottomSheetState.hide()
}
}
) { Text(text = "완료") }
}
}
}
@Composable
fun CategoryBottomSheetItem(
categoryData: CategoryData,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
Text(
modifier = Modifier.weight(1f),
text = categoryData.categoryTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Box(
modifier = Modifier
.padding(end = 15.dp)
.size(20.dp)
.background(Color(android.graphics.Color.parseColor(categoryData.categoryColorHex)))
)
}
}
여기도 bottomSheet가 끝도 없이 늘어나는 것을 막기 위해 LazyColumn()을 사용해 주었다.
생성한 bottomSheet UI
간단 정리
- 카테고리를 만들면서 기능들의 70% 정도는 다시 손봤던 것 같다.
- 기능 개발은 이제 마무리되어서 오류 테스트 후 출시해 볼 생각이다.
'⛏️ | 개발 기록 > ⏰ | Schedule Planner' 카테고리의 다른 글
[Android, Kotlin] 일정 item에 menu 추가해서 일정 수정하기 기능 추가하기 (16) (0) | 2024.04.19 |
---|---|
[Android, Kotlin] SwipeToDismiss을 이용한 밀어서 삭제하는 기능 구현하기 (15) (0) | 2024.04.16 |
[Android, Kotlin] 통계 화면 구성하기 (14) (0) | 2024.04.14 |
[Android, Kotlin] firebase 데이터베이스 변경하기 (13) (0) | 2024.04.10 |
[Android, Kotlin] Compose로 동적 checkBox 리스트 만들기 (12) (2) | 2023.12.04 |