본문으로 바로가기

[Compose] Button Selector in Android Jetpack

category 개발이야기/Android 2022. 2. 28. 17:05
반응형

Photo by unsplash

Compose는 기본적으로 click 효과로 ripple을 사용합니다. 이는 Material design의 기본 구성 요소로 기존에 버튼의 클릭 효과를 따로 구현할 필요 없이 기본적으로 적용되는 효과입니다.
따라서 button이나 text에 클릭을 구현 시 기존 방식인 selector라는 개념이 없습니다. 예전에는 view 요소에 아래와 같은 다양한 상태를 가지도록 하여 색상이나 아이콘을 다르게 표현했었습니다.

  • Pressed
  • Selected
  • enabled
  • ...

이를 xml로 만들면 아래와 같습니다.

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/btn_disabled" android:state_enabled="false" /> 
    <item android:drawable="@drawable/btn_pressed_selected" android:state_pressed="true"
                                                            android:state_selected="true" />
    <item android:drawable="@drawable/btn_selected" android:state_selected="true" /> 
    <item android:drawable="@drawable/btn_pressed" android:state_pressed="true" />
    <item android:drawable="@drawable/btn_play" />
</selector>

하지만 Compose에서는 xml을 사용하지 않으며, kotlin 코드를 직접 사용하기에 enabled나, selected에 따른 변경은 아래와 같이 직접 code로 처리하면 됩니다.

@Composable
fun ColorStateText(text: String,
                   isPressed: Boolean,
                   isSelected:Boolean,
                   isEnabled:Boolean) {
    Text(
        text = "test text",
        color = if (isPressed) Color.Red
                else if(isSelected) Color.Black 
                else if(isEnabled.not()) Color.Gray
                else Color.Blue
        )
}

다만 ripple을 대체하는 press에 대한 동작의 정의는 touch (tap)에 의해서 일어나므로 위와 같은 방식으로는 처리할 수 없기에, 이를 처리하기 위한 방법에 대해서 얘기합니다.

Press에 대한 Event 처리

Material design을 기본으로 디자인된 화면이라면 pressed에 대한 처리를 따로 신경 쓸 필요가 없습니다. 예전에는 ripple효과도 selector xml로 만들어 직접 click 되는 view마다 할당해 줬지만, 이제 그럴 필요가 없는 거죠. 하지만 완벽한 Material design으로 시작하고 Compose로 시작하는 새로운 앱이면 모를까, 기존 leagacy 코드가 존재하고, 기존 view 시스템을 사용하던 앱에 compose을 추가하는 경우 compose에서도 ripple이 아닌 press 효과를 따로 주도록 만들어줘야 합니다.
Press 동작에 대한 event는 interactionSource 객체를 통하여 State<Boolean>으로 얻어낼 수 있습니다. [1]

interactionSource.collectIsPressedAsState()

따라서 button에 press 효과를 통한 배경색을 변경하고자 한다면 아래와 같이 구성할 수 있습니다.

@Composable
fun ButtonSample() {
    val interactionSource by remember { MutableInteractionSource() }
    val isPressed by interactionSource.collectIsPressedAsState()
    val bgColor by if (isPressed) Color.Red else Color.Black
    
    Button(onClick = {},
    interactionSource = interactionSource,
        colors= ButtonDefaults.buttonColors(backgroundColor = bgColor)
    ) {
        Text("ButtonSample")
    }
}

만약 Text composable에 해당 효과를 주고 싶다면, interactionSource는 Modifier.clickable(interactionSource =...) 형태로 넣어줄 수 있습니다.
위 코드 snippet은 가장 간단하게 구현할 수 있는 형태의 press 효과입니다. 하지만 legacy code에서 사용하는 pressed 효과와 완전히 동일한 형태로 제작하기 위해서는 추가적인 작업이 필요합니다. 더불어 매번 모든 Button이나, clickable 한 Text에 위와 같은 코드를 넣기는 부담스럽습니다. 따라서 최종적으로는 press 효과를 지원하는 button composalbe을 제작하는 것을 목표로 합니다.

Button의 구성요소

Press 효과를 지원하는 버튼을 만들기 위해서는 실제 button의 구성 코드를 봐야 합니다. 실제 button composable function은 내부에서 surface로 버튼의 윤곽을 만들고, 넘겨받은 content composable을 surface의 content로 넘겨줍니다.

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    elevation: ButtonElevation? = ButtonDefaults.elevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) {
    val contentColor by colors.contentColor(enabled)
    Surface(
        ...
    ) {
            ...
                Row(
                   ...
                    content = content
                )
            }
        }
    }
}

여기서 press효과를 지원하는 버튼은 아래와 같은 추가적은 처리가 필요하다고 가정합니다.

  • 기본 Button과 같은 param을 전달받을 수 있어야 한다.
  • 원하는 pressed color를 기존 button composable에서 사용하는 ButtonsColors 형태로 전달받도록 한다.
  • evevation은 없어야 한다.
  • ripple 효과가 없어야 한다.

기본 Button과 동일한 형태의 param의 형태를 전달받도록 PressStateButton composable function을 만들어 보겠습니다. 위에서 열거한 항목에 대한 구현을 하나씩 설명하고, 맨 마지막에 최종 코드를 담도록 하겠습니다.

Press color의 설정

button은 기본적으로 colors라는 param으로 background color과 content color을 전달받습니다.

OptIn(ExperimentalMaterialApi::class)
@Composable
fun Button(
 ...
    colors: ButtonColors = ButtonDefaults.buttonColors(),
 ...
) {

먼저 ButtonColors는 interface로 아래와 같이 구성되어 있습니다.

@Stable
interface ButtonColors {
    /**
     * Represents the background color for this button, depending on [enabled].
     *
     * @param enabled whether the button is enabled
     */
    @Composable
    fun backgroundColor(enabled: Boolean): State<Color>

    /**
     * Represents the content color for this button, depending on [enabled].
     *
     * @param enabled whether the button is enabled
     */
    @Composable
    fun contentColor(enabled: Boolean): State<Color>
}

즉 두 가지 색상을 반환하게 되어 있으며, enable 여부를 받게 되어 있으므로 총 네 가지 색상을 받아 올 수 있습니다. 여기에 pressed background color와 pressed content color를 전달해 줄 수 있도록 관련된 abstract function을 추가한 새로운 PressStateButtonColors interface를 하나 추가합니다.

@Stable
interface PressStateButtonColors : ButtonColors {
    @Composable
    fun pressedBackgroundColor(enabled: Boolean): State<Color>

    @Composable
    fun pressedContentColor(enabled: Boolean): State<Color>
}

입력받는 colors param의 type이 PressStateButtonColors가 되면, 입력된 값이 없을 경우 theme에서 기본 색상을 읽어와 반환해 주는 기본값 생성 함수인 ButtonDefaults.buttonColors() 함수도 변경되어야 합니다. 우선 기존의 button 코드는 아래와 같습니다.

@Composable
fun textButtonColors(
    backgroundColor: Color = Color.Transparent,
    contentColor: Color = MaterialTheme.colors.primary,
    disabledContentColor: Color = MaterialTheme.colors.onSurface
        .copy(alpha = ContentAlpha.disabled)
): ButtonColors = DefaultButtonColors(
    backgroundColor = backgroundColor,
    contentColor = contentColor,
    disabledBackgroundColor = backgroundColor,
    disabledContentColor = disabledContentColor
)

@Immutable
private class DefaultButtonColors(
    private val backgroundColor: Color,
    private val contentColor: Color,
    private val disabledBackgroundColor: Color,
    private val disabledContentColor: Color
) : ButtonColors {
    @Composable
    override fun backgroundColor(enabled: Boolean): State<Color> {
        return rememberUpdatedState(if (enabled) backgroundColor else disabledBackgroundColor)
    }

    @Composable
    override fun contentColor(enabled: Boolean): State<Color> {
        return rememberUpdatedState(if (enabled) contentColor else disabledContentColor)
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || this::class != other::class) return false

        other as DefaultButtonColors

        if (backgroundColor != other.backgroundColor) return false
        if (contentColor != other.contentColor) return false
        if (disabledBackgroundColor != other.disabledBackgroundColor) return false
        if (disabledContentColor != other.disabledContentColor) return false

        return true
    }

    override fun hashCode(): Int {
        var result = backgroundColor.hashCode()
        result = 31 * result + contentColor.hashCode()
        result = 31 * result + disabledBackgroundColor.hashCode()
        result = 31 * result + disabledContentColor.hashCode()
        return result
    }
}

여기에 press color을 정보를 담기 위해 아래와 같이 수정한 pressStateButtonColors()DefaultPressStateButtonColors class를 생성합니다.

@Composable
fun ButtonDefaults.pressStateButtonColors(
   ...
    backgroundPressColor: Color = backgroundColor,
    contentPressColor: Color = contentColor

): PressStateButtonColors = DefaultPressStateButtonColors(
    ...
    backgroundPressColor = backgroundPressColor,
    contentPressColor = contentPressColor
)

@Immutable
private class DefaultPressStateButtonColors(
...
    private val backgroundPressColor: Color,
    private val contentPressColor: Color
) : PressStateButtonColors {
    @Composable
    override fun backgroundColor(enabled: Boolean): State<Color> {
   ...

    @Composable
    override fun contentColor(enabled: Boolean): State<Color> {
   ...

    @Composable
    override fun pressedBackgroundColor(enabled: Boolean): State<Color> {
        return rememberUpdatedState(if (enabled) backgroundPressColor else disabledBackgroundColor)
    }

    @Composable
    override fun pressedContentColor(enabled: Boolean): State<Color> {
        return rememberUpdatedState(if (enabled) contentPressColor else disabledContentColor)
    }

    override fun equals(other: Any?): Boolean {
        ...
        if (backgroundPressColor != other.backgroundPressColor) return false
        if (contentPressColor != other.contentPressColor) return false

        return true
    }

    override fun hashCode(): Int {
        ...
        result = 31 * result + backgroundPressColor.hashCode()
        result = 31 * result + contentPressColor.hashCode()
        return result
    }
}

press 색상을 반환하는 interface와 기본 색상을 저장할 수 있는 함수와 class를 생성했으니 Press color를 표시하는 PressStateButton composavle function은 아래와 같이 param을 정의할 수 있습니다.

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PressStateButton(
   ...
    colors: PressStateButtonColors = ButtonDefaults.pressStateButtonColors(),
   ...
) {

Elevation의 제거

Materail design에 포함된 기본 버튼은 evevation값이 지정되어 있습니다. 즉 기본적으로 버튼은 약간 허공에 뜬것처럼 보이고 클릭 시 더욱더 evelation값이 높아지면서 ripple과 함께 버튼이 눌리는 듯한 시각적인 효과를 주도록 되어 있습니다. 다만 legacy UX의 경우 elevation값의 조정 없이 가장 단순하게 color만을 변경하여 pressd 효과를 주는 경우가 많습니다. (적어도 제가 참여하고 있는 프로젝트는 그렇습니다.)
먼저 button 기본의 elevation 값은 아래와 같습니다

@Composable
fun Button(
    ...
    elevation: ButtonElevation? = ButtonDefaults.elevation(),
    ...
) {
...
object ButtonDefaults {    
    ...
    @Composable
    fun elevation(
        defaultElevation: Dp = 2.dp,
        pressedElevation: Dp = 8.dp,
        disabledElevation: Dp = 0.dp,
        hoveredElevation: Dp = 4.dp,
        focusedElevation: Dp = 4.dp,
    ): ButtonElevation {
    ...

다행히 기본 생성 함수에서 press 관련된 evelvation param 항목이 이미 선언되어 있기 때문에 colors처럼 추가로 함수를 만들 필요 없이 아래와 같이 변경할 수 있습니다.

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PressStateButton(
    ...
    elevation: ButtonElevation? = 
               ButtonDefaults.elevation(
                                        defaultElevation = 0.dp,
                                        pressedElevation = 0.dp
                                        ),
    ...
) {
...

Ripple 효과의 제거

마지막으로 ripple 효과만 제거하면, press시 단순 색상만 변경 가능한 PressStateButton을 완성할 수 있습니다. ripple을 disable 시킨다기보다는 ripple의 모든 효과를 투명하게 변경하여 시각적으로 표현되지 않도록 변경합니다. [2]

//NoRippleTheme.kt
object NoRippleTheme : RippleTheme {
    @Composable
    override fun defaultColor(): Color = Color.Transparent

    @Composable
    override fun rippleAlpha() = RippleAlpha(
        draggedAlpha = 0.0f,
        focusedAlpha = 0.0f,
        hoveredAlpha = 0.0f,
        pressedAlpha = 0.0f,
    )
}

// 사용 예시
@Composable
fun NoRipple(content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalRippleTheme provides NoRippleTheme) {
        content()
    }
}

RippleTheme을 상속받아 Ripple의 기본 색상과 alpha값을 조정합니다. 그리고 CompositionLocalProvider를 이용하여 Ripple의 효과가 제거되는 영역 지정하여 사용하는 형태로 코드를 구성합니다.

 

적용

위에서 만든 press 적용 버튼은 아래와 같이 사용이 가능합니다.

 PressStateButton(
 	colors = ButtonDefaults.pressStateButtonColors(
    	backgroundPressColor = Color.Yellow,
    	contentPressColor = Color.Black),
    onClick = { buttonClick(InappTest) }
    ) {
	Text("Press color is yellow")
}

 

PressStateButton.kt 전체 코드

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PressStateButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource = 
            remember { MutableInteractionSource() },
    elevation: ButtonElevation? = 
            ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: PressStateButtonColors = ButtonDefaults.pressStateButtonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) {
    val isPressed by interactionSource.collectIsPressedAsState()
    val backgroundColor by if (isPressed) 
                               colors.pressedBackgroundColor(enabled = enabled)
                           else
                               colors.backgroundColor(enabled = enabled)
    val contentColor by if (isPressed)
                               colors.pressedContentColor(enabled = enabled)
                           else
                                colors.contentColor(enabled = enabled)

    val interactionState by 
                        interactionSource.interactions.collectAsState(initial = null)

    CompositionLocalProvider(LocalRippleTheme provides NoRippleTheme) {
        Surface(
            modifier = modifier,
            shape = shape,
            color = backgroundColor,
            contentColor = contentColor.copy(alpha = 1f),
            border = border,
            elevation = elevation?.elevation(enabled, 
                                             interactionSource)?.value ?: 0.dp,
            onClick = onClick,
            enabled = enabled,
            role = Role.Button,
            interactionSource = interactionSource,
            indication = rememberRipple()
        ) {
            CompositionLocalProvider(LocalContentAlpha provides contentColor.alpha) {
                ProvideTextStyle(
                    value = MaterialTheme.typography.button
                ) {
                    Row(
                        Modifier
                            .defaultMinSize(
                                minWidth = ButtonDefaults.MinWidth,
                                minHeight = ButtonDefaults.MinHeight
                            )
                            .padding(contentPadding),
                        horizontalArrangement = Arrangement.Center,
                        verticalAlignment = Alignment.CenterVertically,
                        content = content
                    )
                }
            }
        }
    }
}

@Stable
interface PressStateButtonColors : ButtonColors {
    @Composable
    fun pressedBackgroundColor(enabled: Boolean): State<Color>

    @Composable
    fun pressedContentColor(enabled: Boolean): State<Color>
}

@Composable
fun ButtonDefaults.pressStateButtonColors(
    backgroundColor: Color = MaterialTheme.colors.primary,
    contentColor: Color = contentColorFor(backgroundColor),
    disabledBackgroundColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
        .compositeOver(MaterialTheme.colors.surface),
    disabledContentColor: Color = MaterialTheme.colors.onSurface
        .copy(alpha = ContentAlpha.disabled),
    backgroundPressColor: Color = backgroundColor,
    contentPressColor: Color = contentColor

): PressStateButtonColors = DefaultPressStateButtonColors(
    backgroundColor = backgroundColor,
    contentColor = contentColor,
    disabledBackgroundColor = disabledBackgroundColor,
    disabledContentColor = disabledContentColor,
    backgroundPressColor = backgroundPressColor,
    contentPressColor = contentPressColor
)

@Immutable
private class DefaultPressStateButtonColors(
    private val backgroundColor: Color,
    private val contentColor: Color,
    private val disabledBackgroundColor: Color,
    private val disabledContentColor: Color,
    private val backgroundPressColor: Color,
    private val contentPressColor: Color
) : PressStateButtonColors {
    @Composable
    override fun backgroundColor(enabled: Boolean): State<Color> {
        return rememberUpdatedState(if (enabled) backgroundColor 
                                    else disabledBackgroundColor)
    }

    @Composable
    override fun contentColor(enabled: Boolean): State<Color> {
        return rememberUpdatedState(if (enabled) contentColor
                                    else disabledContentColor)
    }

    @Composable
    override fun pressedBackgroundColor(enabled: Boolean): State<Color> {
        return rememberUpdatedState(if (enabled) backgroundPressColor 
                                    else disabledBackgroundColor)
    }

    @Composable
    override fun pressedContentColor(enabled: Boolean): State<Color> {
        return rememberUpdatedState(if (enabled) contentPressColor
                                    else disabledContentColor)
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || this::class != other::class) return false

        other as DefaultPressStateButtonColors

        if (backgroundColor != other.backgroundColor) return false
        if (contentColor != other.contentColor) return false
        if (disabledBackgroundColor != other.disabledBackgroundColor) return false
        if (disabledContentColor != other.disabledContentColor) return false
        if (backgroundPressColor != other.backgroundPressColor) return false
        if (contentPressColor != other.contentPressColor) return false

        return true
    }

    override fun hashCode(): Int {
        var result = backgroundColor.hashCode()
        result = 31 * result + contentColor.hashCode()
        result = 31 * result + disabledBackgroundColor.hashCode()
        result = 31 * result + disabledContentColor.hashCode()
        result = 31 * result + backgroundPressColor.hashCode()
        result = 31 * result + contentPressColor.hashCode()
        return result
    }
}

 

마치며

Press 효과를 갖는 버튼은 기존 legacy ux를 compose로 구현하기 위한 방법입니다. 완전히 compose로 개발을 시작하는 앱이 아니고서야 기존에 사용하던 형태를 compose로 구현할 수 있어야 하는 불편한 제약사항들이 추가됩니다. 이런 요소들이 많아지고 critical 할수록 기존 앱에 compose의 도입은 어려워질 수밖에 없습니다.
위 코드로 생성된 PressStateButton 역시 차음 개발 시 사용했던 compose version 1.0.5에서는 버그가 있었습니다. 버튼을 누르고 있으면 설정한 press color로 변경되지만, 버튼을 짧게 tab 하면서 onClick의 코드가 동작되는 경우 press color로 색상의 변화 없이 바로 onClick이 수행되었습니다. 즉 press가 되었다는 이벤트가 버튼 tap시 오는 게 아니라 한참 늦게 오는 문제가 있어 click시 focus를 강제로 맞춰주고, isFocused에 색상을 변경하도록 시도해 보고, 직접 Gesture의 onTap을 구현하는 등의 방법을 시도해 보았지만 이런 것들도 정상적으로 동작하지 못하는 이슈가 있었습니다.
다행히도 compose version이 1.1.1로 올라가면서 이 현상은 더 이상 발생하지 않으며, 위 코드는 원하는 대로 정상 동작됩니다. (release 문서에 따르면 1.1.0 다음이 1.2.0인데, intellij에서는 최신 버전이 1.1.1로 제시되는 이상한 점도 있습니다.)
아마 다른 분들이 이 글을 읽을 때쯤이면 compose 버전이 훨씬 높을 것이라고 희망해 봅니다. 아직까지도 compose에 버그가 존재하고, 이점으로 인하여 compose 적용이 망설여지는 것 역시 compose 도입을 위한 큰 장벽 중 하나라는 생각이 듭니다. 하지만 지속적인 관심을 가지고 활성화되어야만 빠르게 개선되고 발전되므로, 마냥 적용을 미루는 것보다는 한 발자국씩 나아가면 부딪쳐 보는 것도 나쁘지 않다는 생각이 듭니다.

NoRippleTheme.kt
0.00MB
PressStateButton.kt
0.01MB

References

[1] https://medium.com/geekculture/button-selector-in-jetpack-compose-android-fb77a37d03e8
[2] https://stackoverflow.com/questions/69783654/how-to-disable-ripple-effect-on-any-jetpack-compose-view

반응형