본문 바로가기
⛏️ | 개발 기록/⏰ | Schedule Planner

[Android, Kotlin] 카테고리 기능 구현 (17)

by immgga 2024. 5. 11.

 

이번 포스팅에서는 마지막 기능인 카테고리 기능을 구현한 것에 대해 포스팅을 하려고 한다.

꽤나 오래 걸렸는데, 내가 천천히 개발한 것도 있고 카테고리를 만들면서 다른 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 

 

GitHub - godaddy/compose-color-picker: Jetpack Compose Android Color Picker 🎨

Jetpack Compose Android Color Picker 🎨. Contribute to godaddy/compose-color-picker development by creating an account on GitHub.

github.com

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% 정도는 다시 손봤던 것 같다.
  • 기능 개발은 이제 마무리되어서 오류 테스트 후 출시해 볼 생각이다.
728x90