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

[Android, Kotlin] Compose에서 동적 List 만들기 (7)

by immgga 2023. 10. 24.

이번 포스팅에서는 Planner v2의 일정 생성 화면을 구현하면서 알게 되었던 지식들을 풀어볼까 한다.

 

마지막으로 ui를 구현할 화면은 일정 생성 화면이다.

일정 생성 화면은 plan screen의 점선으로 된 추가하기 card처럼 아무것도 없는 화면에 그 card를 눌러서 일정을 생성하고 일정 card를 클릭해서 일정을 수정할 수 있게 구현해 보겠다.

 

간단하게 UI를 구상해 보았다.

킹림판

text 부분에 일정 생성이라는 타이틀을 넣고

처음에는 아무것도 없는 화면에 점선으로 된 + card를 누르면 일정이 생성되고 일정을 클릭하면 일정의 정보를 수정할 수 있도록 구현하겠다.

 

1. 일정 Card 구현

 

우선은 일정 추가 card부터 만들어 보자.

card의 border를 점선으로 설정하는 방법은 이전 포스팅에서 다루었으니 참고해도 될 것 같다.

https://rkdrkd-history.tistory.com/74

 

[Android, Kotlin] Compose Lazycolumn의 scroll state 관리하기 - (6)

이번에 Planner v2를 개발하면서 Plan(main) screen에서의 add schedule icon이 너무 사용자들에게 불편할 수도 있다는 느낌을 받았었다. 일정 header 부분에 + icon은 사용자들이 실제로 사용한다면 icon의 크기

rkdrkd-history.tistory.com

바로 코드로 확인해 보자.

이전에 사용한 방법과 거의 동일하기 때문에 간략한 설명만 하고 넘어가겠다.

@Composable
fun CreatePlanCard(
    onCardClick: () -> Unit
) {
    val stroke = Stroke(width = 2f, pathEffect = PathEffect.dashPathEffect(intervals = floatArrayOf(10f, 10f), phase = 10f))

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(100.dp)
            .padding(horizontal = 15.dp, vertical = 10.dp)
            .clip(RoundedCornerShape(8.dp))
            .drawBehind {
                drawRoundRect(
                    color = Color.LightGray,
                    style = stroke,
                    cornerRadius = CornerRadius(8.dp.toPx())
                )
            }
            .clickable { onCardClick() },
        contentAlignment = Alignment.Center
    ) {
        Icon(
            painter = painterResource(id = R.drawable.ic_create_schedule),
            contentDescription = "create schedule",
            tint = Color.LightGray
        )
    }
}

점선을 만들 수 있는 stroke 변수를 만들어서 Box Composable의 modifier에 넣어 주고, 안에는 + icon이 들어 있는 형식으로 구현하였다.

 

구현 결과

일정 추가 card preview

 

다음으로는 중요한 기능인 일정 정보 card, 일정 수정 card를 만들어 보겠다.

처음에는 일정 정보 card로 뜨다가 정보 card를 클릭하면 일정 수정 card로 바뀌게 구현하였다.

먼저 일정 정보 card의 코드부터 확인해 보자.

@Composable
fun PlanInfoCard(
    title: String,
    description: String,
    onCardClick: () -> Unit,
    onIconClick: () -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 15.dp, vertical = 10.dp)
            .shadow(elevation = 1.dp, shape = RoundedCornerShape(8.dp))
            .background(Color(0xFFFAFAFA))
            .clip(RoundedCornerShape(8.dp))
            .clickable { onCardClick() },
        verticalAlignment = Alignment.CenterVertically
    ) {
        Column(
            modifier = Modifier
                .weight(1f)
                .padding(15.dp)
        ) {
            Text(
                modifier = Modifier.padding(bottom = 10.dp),
                text = title.ifEmpty { "새 일정" },
                fontSize = 19.sp,
                fontWeight = FontWeight.SemiBold,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis
            )
            Text(
                text = description.ifEmpty { "비어 있음" },
                maxLines = 1,
                overflow = TextOverflow.Ellipsis
            )
        }

        IconButton(
            modifier = Modifier
                .clip(CircleShape)
                .padding(end = 15.dp)
                .size(40.dp),
            onClick = onIconClick
        ) {
            Icon(
                modifier = Modifier.padding(8.dp),
                painter = painterResource(id = R.drawable.ic_delete_plan),
                contentDescription = "delete plan",
                tint = Color.Red
            )
        }
    }
}

title과 description을 파라미터로 받아서 사용했다.

또한 title과 description이 비어 있을 때, 기본 텍스트를 보이게 하기 위해 ifEmpty {}를 사용했다.

또한 삭제 icon인 x icon을 생성하였고, column을 이용해 title과 description을 수직 정렬되게 하였다.

 

구현 화면

일정 정보 card preview

preview 상에서는 title과 description을 empty string으로 설정해 놓았기에 ifEmpty에 작성한 Text가 보이는 모습이다.

 

두 번째로는 일정 수정 card를 만들어 보자.

일정 수정 card는 title과 description을 수정하고 저장 또는 취소를 할 수 있게 구현하였다.

그러면 바로 코드로 확인해 보자.

@Composable
fun ModifyPlanCard(
    title: String,
    description: String,
    onTitleChange: (String) -> Unit,
    onDescriptionChange: (String) -> Unit,
    onSaveButtonClick: () -> Unit,
    onCancelButtonClick: () -> Unit
) {
    var titleState by remember { mutableStateOf(title) }
    var descriptionState by remember { mutableStateOf(description) }

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 15.dp, vertical = 10.dp)
            .shadow(elevation = 1.dp, shape = RoundedCornerShape(8.dp))
            .background(Color(0xFFFAFAFA))
            .clip(RoundedCornerShape(8.dp)),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        ChangePlanInfoTextField(
            modifier = Modifier.padding(horizontal = 15.dp, vertical = 10.dp),
            value = title,
            hint = "제목을 입력하세요.",
            singleLine = true,
            maxLines = 1,
            textStyle = TextStyle(
                fontSize = 19.sp,
                fontWeight = FontWeight.Medium
            ),
            hintTextStyle = TextStyle(
                fontSize = 19.sp,
                fontWeight = FontWeight.Medium
            ),
            onValueChange = {
                titleState = it
                onTitleChange(titleState)
            }
        )

        ChangePlanInfoTextField(
            modifier = Modifier
                .padding(horizontal = 15.dp)
                .height(85.dp),
            value = description,
            hint = "상세 내용을 입력하세요.",
            singleLine = false,
            maxLines = 4,
            onValueChange = {
                descriptionState = it
                onDescriptionChange(descriptionState)
            }
        )

        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(vertical = 10.dp, horizontal = 15.dp)
        ) {
            Button(
                modifier = Modifier.width(100.dp),
                shape = RoundedCornerShape(8.dp),
                colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6EC4A7)),
                onClick = onCancelButtonClick
            ) { Text(text = "취소") }

            Spacer(modifier = Modifier.weight(1f))

            Button(
                modifier = Modifier.width(100.dp),
                shape = RoundedCornerShape(8.dp),
                colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFFDB86)),
                onClick = onSaveButtonClick
            ) { Text(text = "저장") }
        }
    }
}

ChangePlanInfoTextField는 필자가 직접 만든 Custom Textfield이다.

커스텀 Textfield를 만드는 방법은 BasicTextField를 사용하면 되는데, BasicTextfield에 대해 자세히 설명해 주는 블로그가 있어 참고용으로 남겨 놓겠다.

https://sungbin.land/jetpack-compose-%EB%82%98%EB%A7%8C%EC%9D%98-textfield-%EB%A7%8C%EB%93%A4%EA%B8%B0-1d117b37d2a7

 

Jetpack Compose 나만의 TextField 만들기

BasicTextField 활용

sungbin.land

Textfield 말고는 기본적인 Compose UI 구성이기에 설명은 생략하도록 하겠다.

궁금한 점이 있으면 댓글로 알려주시면 빠르게 답변드리겠습니다.

 

 

이제 위에서 만든 두 개의 card를 state를 이용해 서로 하나로 합쳐(?) 보겠다.

var isModifyPlanState by remember { mutableStateOf(false) }
var titleState by remember { mutableStateOf(planData.title) }
var descriptionState by remember { mutableStateOf(planData.description) }

if (isModifyPlanState) {
    ModifyPlanCard(
        title = planData.title,
        description = planData.description,
        onTitleChange = { titleState = it },
        onDescriptionChange = { descriptionState = it },
        onSaveButtonClick = {
            savePlanLogic(titleState, descriptionState)
            isModifyPlanState = false
        },
        onCancelButtonClick = { isModifyPlanState = false }
    )
} else {
    PlanInfoCard(
        title = planData.title,
        description = planData.description,
        onCardClick = { isModifyPlanState = true },
        onIconClick = deleteLogic
    )
}

isModifyPlanState를 이용해 card들의 보이는 여부를 설정해 주었다.

간단하지 않은가?

 

그러면 이제 생성한 card를 LazyColumn에 적용해 보겠다.

 

2. 일정 Card 적용

이제야 블로그 글의 제목인 동적 리스트를 구현해 볼 차례이다.

일정 카드를 적용하기 위해 LazyColumn을 생성해 주겠다.

LazyColumn(
    modifier = Modifier
        .fillMaxWidth()
        .weight(1f)
) {
    itemsIndexed(planListState) { index, item ->
        PlanCard(
            planData = item,
            savePlanLogic = { title, description ->
                createPlanViewModel.modifyPlan(
                    title = title,
                    description = description,
                    position = index
                )
            },
            deleteLogic = { createPlanViewModel.removePlan(index) }
        )
    }

    item {
        CreatePlanCard {
            createPlanViewModel.addPlan(
                PlanData(
                    title = "",
                    description = ""
                )
            )
        }
    }
}

위의 코드를 보면, 총 2개의 item 덩어리로 구성되어 있는 것을 볼 수 있을 것이다.

하나는 일정 카드이고(위), 나머지는 일정 생성 카드이다(아래).

 

필자는 일정의 정보를 저장하기 위해서 ViewModel을 만들어 일정의 정보들을 생성, 삭제, 수정하는 로직을 작성해 주었다. 위의 코드에 곳곳에 viewModel을 사용한 함수가 눈에 보일 것이다.

private val _planList = mutableStateListOf<PlanData>()
val planList: SnapshotStateList<PlanData> = _planList

fun addPlan(planData: PlanData) {
    _planList.add(planData)
}

fun modifyPlan(title: String, description: String, position: Int) {
    _planList[position] = PlanData(title, description)
}

fun removePlan(position: Int) {
    _planList.removeAt(position)
}

viewModel에서 간단하게 list를 생성, 삭제, 수정 로직을 만들어 주었다.

그럼 다시 위의 LazyColumn 코드를 보면 planlist 정보를 viewmodel에서 불러왔다.

val planList = createPlanViewModel.planList
val planListState = remember { planList }

그리고 각각의 함수 파라미터에 생성, 수정, 삭제 함수 부분에 viewModel의 함수들을 사용해 주었다.

위와 같이 구현하면 planListState가 변함에 따라 LazyColumn의 items에도 변화가 있을 것이다.

 

마지막으로 저장, 취소 버튼을 만들어 보자.

저장, 취소 버튼은 특정 조건을 만족해야 나타나도록 만들었다.

먼저 조건을 저장할 변수를 만들어 주었다.

var buttonsVisibility by remember { mutableStateOf(false) }

LaunchedEffect(planListState.toList()) {
    buttonsVisibility = planListState.isNotEmpty() && planListState[0].title.isNotEmpty()
}

그리고 LaunchedEffect를 이용해 planList가 변함에 따라 buttonVisibility의 state를 변경하는 로직을 추가해 주었다.

LaunchedEffect()는 함수 param인 key가 변하면 호출된다.

AnimatedVisibility(
    visible = buttonsVisibility,
    enter = slideInVertically(initialOffsetY = { it }),
    exit = slideOutVertically(targetOffsetY = { it })
) {
    // button ui
}

button들이 나타나는 것은 AnimatedVisibility Composable을 이용해서 enter, exit 애니메이션을 설정해 주었다.

그럼 코드는 이쯤에서 마무리를 짓고 실행 화면을 봐보자.

 

3. 실행 화면

이제 실행 화면을 보고 마무리 짓도록 하자.

위의 영상과 같이 리스트가 수시로 변하는 모습을 볼 수 있다.

 

정리

  • 동적 리스트를 만들기 위해 많이 검색해 보았는데 영어로 된 블로그들 밖에 보이지 않았었다 ㅠ
  • 동적 리스트를 구현할 때, 데이터를 삭제할 때, 맨 끝의 데이터만 삭제되는 오류가 있었는데, title과 description을 remember state로 설정해 놓아서 새로운 PlanData를 받아오지 못하는 문제가 있었었다.(PlanData의 데이터로 교체해서 해결했다.)
  • Compose로 저런 복잡한 리스트도 구현할 수 있다는 것에 놀랐다. XML으로는 상상도 못 했었는데 :0
  • 궁금하신 점은 댓글로 알려주시면 빠르게 답변드리겠습니다 XD
728x90

댓글