커스텀 단일 선택 버튼 리스트 - Jetpack Compose

Shawn Kang·2022년 12월 20일
0

Jetpack Compose

목록 보기
1/6
post-thumbnail

목적

커스텀 가능한 버튼 리스트를 만드는 게 목적이다. 여기서 말하는 버튼 리스트는:

  • 각 버튼에 인덱스가 부여되고
  • 특정 버튼 클릭 시, 그 버튼의 색상은 활성화되고 다른 버튼의 색상은 비활성화되고
  • 여러 버튼 중 단 하나의 버튼만 선택할 수 있는

그런 버튼들로 이루어진 리스트이다.

유사한 작동 방식을 가진 컴포넌트로 탭(Tab)이 있겠다. 여러 가지 선택지 중 딱 하나만 골라야 할 경우에 활용할 수 있는 그런 버튼 리스트이다.


구성 요소

버튼 리스트 구현에 필요한 구성 요소를 구현 순서에 따라 정렬했다. 아래를 참고하자:

  • data class ButtonItem
    버튼 구현에 필요한 데이터를 담고 있는 데이터 클래스이다. 인덱스도 여기서 관리한다.
  • @Composable Button
    버튼이다. 더 이상의 설명이 필요한가...?
  • @Composable ButtonPage
    버튼이 올라갈 컴포저블 위젯이다. LazyColumn을 활용할 예정이다.

구현

data class ButtonItem

버튼 구현에 필요한 데이터를 이 클래스에 정의해주는 것이 구현의 시작이다. 이 게시글에서는 최소한의 변수로 클래스를 정의했지만, 나중에 필요에 따라 더할 건 더하고 뺄 건 빼도 되겠다.

data class ButtonItem(
	val index: Int,
    val label: String,
    val icon: ImageVector
)

클래스 구성에 대한 설명이다:

  • index는 버튼의 인덱스이다. 특정 버튼의 인덱스가 4일 경우, 그 버튼을 클릭하면 state에 걸린 인덱스가 4로 바뀌면서 그 버튼이 활성화되는 동작 방식을 갖는다.
  • label은 버튼의 항목명이다.
  • icon은 버튼의 아이콘이다. 이 게시글에서는 Google에서 제공하는 자체 아이콘을 쓰기 때문에 ImageVector 형태로 선언했다. 만약 사용자가 직접 벡터 이미지를 불러와 아이콘으로 쓸 예정이라면, ImageVectorPainter로 바꾸어도 될 듯하다.

@Composable Button

data class ButtonItem를 정의했으니, 이제 버튼을 구현할 차례다. 버튼에는 아래와 같이 최소 3가지 변수가 필요하다:

fun Button(
	item: ButtonItem,
    isSelected: Boolean,
    onTap: () -> Unit
)

각 변수에 대한 설명이다:

  • item: 방금 전 선언한 ButtonItem 클래스이다. 버튼에 보여질 내용과 인덱스를 이 변수를 통해 전달한다.
  • isSelected: 버튼이 선택되었는지, 그렇지 않은지를 전달하는 변수이다.
  • onTap: 버튼을 클릭했을 때 동작할 함수를 전달하는 변수이다.

이상의 변수들을 바탕으로 버튼이 동작하는 로직을 설명해보자면 아래와 같다:

  • 버튼을 클릭하면 Compose에서 추적하는 state 값(아래에서 설명)이 변경된다.
  • 이에 따라 Recomposition이 진행된다.
  • 각 버튼은 본인의 isSelected에 전달된 값을 확인하고, true일 경우 버튼의 색상을 활성화한다. 그렇지 않을 경우 버튼의 색상을 비활성화한다.

다음은 버튼의 세부 구현이다:

fun Button(
    item: ButtonItem,
    isSelected: Boolean,
    onTap: () -> Unit
) {
    val backgroundColor = 
      if (isSelected) MaterialTheme.colorScheme.primary
      else MaterialTheme.colorScheme.surfaceVariant
    val contentColor =
      if (isSelected) MaterialTheme.colorScheme.onPrimary
      else MaterialTheme.colorScheme.onSurfaceVariant

	// ...이후 설명
}

버튼 구현 전에 버튼의 색상을 함수 내 변수로 선언해주어야 한다. 특히 지금 구현중인 버튼은 클릭 여부에 따라 색상이 바뀌므로 이를 반영해주어야 한다. 거기에 필요한 게 바로 위의 2개 변수이다.

backgroundColor는 버튼의 배경색이고, contentColor는 버튼의 텍스트, 아이콘 등 주요 UI 요소의 색상이다. 여기에 조건문을 걸어, 버튼의 선택 여부에 따라 색상이 달라지도록 코드를 짜면 된다.

@Composable
fun Button(
    item: ButtonItem,
    isSelected: Boolean,
    onTap: () -> Unit
) {
    val backgroundColor =
        if (isSelected) MaterialTheme.colorScheme.primary
        else MaterialTheme.colorScheme.surfaceVariant
    val contentColor =
        if (isSelected) MaterialTheme.colorScheme.onPrimary
        else MaterialTheme.colorScheme.onSurfaceVariant

    Row(
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.Center,
        modifier = Modifier
            .size(150.dp, 50.dp)
            .clip(RoundedCornerShape(40.dp))
            .background(color = backgroundColor)
            .padding(10.dp)
            .clickable { onTap() }
    ) {
        Icon(
            imageVector = item.icon,
            contentDescription = null,
            tint = contentColor
        )
        Text(
            text = item.label,
            color = contentColor
        )
    }
}

버튼의 전체 소스 코드이다. 나는 Row를 활용했지만, 사실 Row에 해당하는 코드는 버튼의 겉모습에 해당하기 때문에 온전히 사용자의 몫이다. 따라서 버튼의 사용 목적과 계획 등에 따라 디자인하면 된다. Row 말고 Column이나 Box나 뭐든 괜찮다. 커스텀이 가능하다고 한 이유가 바로 이 부분 때문이다.

어쨌든 여기서 제일 중요한 건, 버튼의 배경 색상과 버튼 내용물의 색상에 위에서 선언한 backgroundColorcontentColor를 적용해야 한다는 점이다.

나의 경우는, 아이콘과 텍스트를 감싸는 RowModifier를 걸어 배경 색상을 backgroundColor로 지정했고, 아이콘과 텍스트에는 contentColor를 적용했다.


@Composable ButtonPage

@Composable Button을 구현했으니, 이 버튼들이 탑재될 페이지를 간단하게 작업해보자.

@Composable
fun ButtonPage() {
    val buttonItemList = listOf(
        ButtonItem(0, "홈", Icons.Rounded.Home),
        ButtonItem(1, "연락처", Icons.Rounded.Person),
        ButtonItem(2, "설정", Icons.Rounded.Settings)
    )
    var selectedIndex by rememberSaveable { mutableStateOf(0) }
    
    // ...이후 설명
}

ButtonPage의 본격적인 구현 전에, 아래의 변수 2개를 선언해주어야 한다:

  • buttonItemList
    구현할 버튼의 데이터를 정의해주어야 한다. 이 리스트를 가지고 버튼을 생성할 예정이다.
  • selectedIndex
    현재 선택되어 있는 버튼의 인덱스이다. Compose가 값이 바뀌는 경우를 추적할 수 있도록 rememberSaveablemutableStateOf를 활용해 선언했다. 위에서 말했던 'state 값'이 바로 이거다. 사용자가 인덱스 5인 버튼을 누르면 selectedIndex가 5로 바뀌는 식.

여담으로 런타임 동안 값이 바뀔 일이 없는 buttonItemListval로 선언했고, 버튼을 누름에 따라 값이 변경될 selectedIndexvar로 선언했다. 참고하도록 하자.

이제 본격적인 구현이다. LazyColumn을 활용한다. 물론 LazyRow도 사용 가능.

@Composable
fun ButtonPage() {
    val buttonItemList = listOf(// ...생략)
    var selectedIndex by rememberSaveable { mutableStateOf(0) }
    
    LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
        items(buttonItemList) { item ->
            Button(
                item = item,
                isSelected = selectedIndex == item.index,
                onTap = { selectedIndex = item.index }
            )
        }

    }
}

코드를 한 줄씩 확인해보자.

items(buttonItemList) { item ->
	// ...생략
}
  • LazyColumn의 가장 보편적인 활용 방법이다. 리스트의 내용물을 item이라는 이름으로 컨트롤하게 된다. 이 코드에서는 buttonItemListList<ButtonItem>이므로, item의 자료형은 ButtonItem이 된다.

verticalArrangement = Arrangement.spacedBy(10.dp)
  • 버튼 간 간격을 주기 위해서 부여한 값이다. 각 버튼은 10 dp 만큼의 간격을 두고 스크린에 표시된다.

Button(
	item = item,
    isSelected = selectedIndex == item.index,
    onTap = { selectedIndex = item.index }
)
  • item 항목에는 뭐 당연히... ButtonItem 항목이 들어간다.
  • isSelected에는 selectedIndex == item.index라는 평가식을 활용해, 현재의 인덱스와 각 버튼의 인덱스를 비교할 수 있도록 한다. 저 식 자체가 Boolean 결과값을 내므로 그냥 통으로 입력한다.
  • onTap에는 버튼을 눌렀을 때 현재 인덱스를 버튼의 인덱스로 변경해주는 동작이 반드시 들어가야 한다. 그 외에 별도로 필요한 동작이 있다면 추가로 달아주면 되겠다.


결과

대충 잘 바뀌는 것 같다. 굳이다.

profile
i meant to be

0개의 댓글