[Compose] Pressed 효과가 잘 보이는 버튼을 만들어보자

Mint junghyun·2023년 2월 27일
0
post-thumbnail

주제

서론

평소에 토스 앱을 쓰면서 버튼 하나에도 정말 신경을 많이 쓴다는 생각이 들었다. 물론 어떤 화면에서 어떤 버튼을 쓰는 기준 같은건 정확히는 모르지만 누르는 순간 색상뿐만 아니라 크기도 변경되어 사용자에게 누르는 듯한 느낌을 제대로(?) 준다고 생각되었다.
(심지어 버튼이 아닌 레이아웃에서도 비슷한 효과가 난다.)
또한 버튼을 누를수 없는 disable 상태에서는 진동과 함께 좌우로 움직이는 애니메이션이 실행되어 사용자에게 더이상 진행할 수 없다 라는 사실을 인지 시킨다.

이렇게 생각하다보니 ComposeUI 를 통해서 구현할 수 있는지 궁금증이 생기기도 했고 요즘 Compose UI 를 공부중이라 위의 기능들을 담은 버튼을 만들어 두면 유용하게 써먹을 수 있을것 같아 구현해보기로 했다.

목표

  1. 버튼을 누를때 색이 변하게 만들어보자
  2. 버튼이 눌려있는 동안 버튼의 크기가 작아지고 떼면 다시 커지는 애니메이션을 만들어보자
  3. 아주 짧은시간 잠깐만 눌렀더라도 작아지고 다시 커지는 애니메이션이 완전히 실행되게 만들어보자
  4. disable 상태에서 진동 애니메이션을 만들어보자

구현

과정 1

우선 가장 쉽게 찾을수 있는 interactionSource 를 이용한 방법을 사용해보았다.

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun TestButton() {
    val interactionSource = remember { MutableInteractionSource() }
    val isPressed by interactionSource.collectIsPressedAsState()

    CompositionLocalProvider(
        LocalRippleTheme provides NoRippleTheme, // 버튼의 리플 이펙트 제거
    ) {
        Surface(
            onClick = {},
            color = if (isPressed) MLDTheme.color.itemAmber else MLDTheme.color.mainButton,
            interactionSource = interactionSource,
        ) {
            Row(
                Modifier
                    .height(52.dp)
                    .padding(horizontal = 20.dp),
                horizontalArrangement = Arrangement.Center,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(text = "TestButton", color = MLDTheme.color.background)
            }
        }
    }
}

isPressed 값을 State로 받아서 처리하기 때문에
값에 따른 분기처리만 해주면 되기 때문에 쉬운 구현이 가능하다.
버튼은 interactionSource 를 사용할 수 있도록 Surface 를 이용하여 구현해보았다.

실제로 동작하는 모습을 보면 약간 반응이 느리다는 느낌은 있지만 기본적인 구현은 된듯 했다.
일단 1번 목표는 달성한것으로 보인다.

이제 위의 방법으로 크기 변화도 만들어보자.

크기 변화 자체는 Modifier.scale() 을 활용하고
크기가 자연스럽게 변경되도록 하기 위해서 animateFloatAsState() 를 활용해봤다.

val scale = animateFloatAsState(targetValue = if (isPressed) 0.85f else 1f)
// ...
Surface(
    onClick = {},
    modifier = Modifier.scale(scale.value),
// ...

위 코드를 적용후 점검해보니 1번 목표와 2번 목표는 달성되었다.
다만 3번 목표는 충족할 수 없는 방법이었다.
(아주 짧게 누르는 경우 버튼이 작아지지 않는다.)
pressed 상태를 state 로 받고 있기 때문에 true false 로만 값이 반환되고 animateFloatState 는 현재의 값만 보기 때문에 완전히 줄어들었다가 커지도록 보장하지 않았다.

과정 2

과정 1의 문제를 해결하기 위해서 collectIsPressedAsState() 의 내부를 파악해보았다.

@Composable
fun InteractionSource.collectIsPressedAsState(): State<Boolean> {
    val isPressed = remember { mutableStateOf(false) }
    LaunchedEffect(this) {
        val pressInteractions = mutableListOf<PressInteraction.Press>()
        interactions.collect { interaction ->
            when (interaction) {
                is PressInteraction.Press -> pressInteractions.add(interaction)
                is PressInteraction.Release -> pressInteractions.remove(interaction.press)
                is PressInteraction.Cancel -> pressInteractions.remove(interaction.press)
            }
            isPressed.value = pressInteractions.isNotEmpty()
        }
    }
    return isPressed
}

단순히 생각해서 코루틴으로 Pressed, Release, Cancel 을 Boolean 값으로 변환해주고 있으니 직접 구현해서 각각의 이벤트가 들어왔을때 실행될 내용을 작성해주면 문제가 해결될것으로 보였다. (onTouchDown, onTouchUp)

실제로 버튼을 눌렀는지 여부(pressState)와 다시 떼는 여부(isPressCanceled) 두가지를 state로 관리해서 동작하도록 했더니 1, 2, 3 번 목표를 충족하면서 잘 동작하는 모습을 보여주었다.

Button

val buttonMinScaleValue = 0.95f
val pressState = remember { mutableStateOf(false) }
val isPressCanceled = remember { mutableStateOf(false) }
    
val buttonScale = animateFloatAsState(
    targetValue = if (pressState.value) buttonMinScaleValue else 1f,
    visibilityThreshold = 0.03f
) {
    if (isPressCanceled.value) {
        pressState.value = false
        isPressCanceled.value = false
    }
}

val onTouchDown = {
    if (!isDisable) {
        isPressCanceled.value = false
        pressState.value = true
    } else {
        if (disableAnimation) {
            isActiveDisableAnimationLeft.value = true
            context.vibe(Vibration.WARNING)
        }
    }
}
val onTouchUpOrCancel = {
    if (!isDisable) {
        if (buttonScale.value == buttonMinScaleValue) {
            pressState.value = false
        } else {
            isPressCanceled.value = true
        }
    }
}

MLDSurface(
    onClick = onClick,
    onTouchDown = onTouchDown,
    onTouchUpOrCancel = onTouchUpOrCancel,
    interactionSource = interactionSource
    isDisable = isDisable
    // ...
)
//...

CustomSurface

fun MLDSurface(
    modifier: Modifier = Modifier,
    onClick: () -> Unit = {},
    onTouchDown: () -> Unit = {},
    onTouchUpOrCancel: () -> Unit = {},
    isDisable: Boolean = false,
    // ...
) {
    LaunchedEffect(interactionSource) {
        interactions.collect { interaction ->
            when (interaction) {
                is PressInteraction.Press -> onTouchDown()
                is PressInteraction.Release -> onTouchUpOrCancel()
                is PressInteraction.Cancel -> onTouchUpOrCancel()
            }
        }
    }
    
    Surface(
        interactionSource = interactionSource
        enabled = !isDisable,
        // ...
}

그러나 이 방법도 문제점이 있었다. 1, 2, 3번 목표는 충족했지만 4번 목표를 구현할 수 없었다. 4번 목표를 위해서는 disable 상태에서 touchEvent 를 받아야만 했다. 하지만 Surface 는 disable 상태에서 interactionSource 에 이벤트가 전달 되지 않았다. 즉 disable 상태와 별개로 touchEvent 를 감지할 수 있는 방법이 필요해졌다.

과정 3

touchEvent 를 직접 제어 할 수 있는 방법을 찾다가 Modifier.pointerInteropFilter() 를 알게되었다. Compose 이전의 View 의 onTouchEvent() 메소드와 동일한 기능을 제공하는 메소드 였다.

테스트 결과 해당 메소드를 이용하면 Surface의 enable 과 별개로 터치 이벤트를 받을 수 있는 것을 확인했다. 다만 onClick 이벤트가 별개로 동작하기에 Surface 의 onClick은 무시하고 onTouchUp 이벤트가 들어올 때 직접 호출해주는 방식으로 변경하였다.

Surface(
    onClick = {},
    modifier = Modifier.then(modifier)
        .pointerInteropFilter {
            when (it.action) {
                MotionEvent.ACTION_DOWN -> {
                    onTouchDown()
                    true
                }
                MotionEvent.ACTION_MOVE -> {
                    true
                }
                MotionEvent.ACTION_CANCEL -> {
                    onTouchUpOrCancel()
                    true
                }
                MotionEvent.ACTION_UP -> {
                    onTouchUpOrCancel()
                    if (!isDisable) {
                        onClick()
                    }
                    true
                }
                else -> {
                    onTouchUpOrCancel()
                    false
                }
            }
        },
    enabled = !isDisable,
    // ...
) {
    content()
}

위의 방식은 Disable 상태에서도 이벤트가 잘 들어오고 원하는대로 커스텀이 가능하여
1, 2, 3, 4 번의 목표에 모두 만족하는 결과물을 얻을 수 있었다.
4번 목표는 animateFloatAsState()Modifier.offset() 을 이용하여 구현했다.

(밑에 버튼이 disable 상태이다. 자세히 보면 좌우로 흔들린다.)


다만 구현하면서 **ExperimentalComposeUiApi** 를 사용했기 때문에 아직은 안정적이라고 보긴 어려울것 같은데 조금 아쉬운 부분이다.

이렇게 해서 목표에 맞는 버튼을 구현해볼 수 있었다.
전체 코드는 완전히 공개하고 있으니 참고하길 바란다.

https://github.com/islandparadise14/MintLab
Button 구현 부분
커스텀 Surface 구현부분

profile
이상한 궁금증이 많은 안드로이드 개발자입니다. “굳이 이런거 까지 만들어야 하나…” 하는걸 만드는게 취미입니다.

0개의 댓글