개요
초기 앱 개발 시 이 프로젝트엔 인터랙션이라고는 찾아볼 수 없었다. 클릭 이벤트에 있어서, 나는 당연히 `noRippleClickable`(리플 효과 제거) 이 제일 괜찮은 줄 알았다. 그래서 그렇게 해왔고, 그렇게 하려고 했다.
그러나 나의 생각을 바꾼 건 토스 앱이었다. 그냥 무심코 썼던 토스를 유심히 관찰하니 훌륭한 인터랙션들을 찾아볼 수 있었다. 그리고 관련해서 찾아보다가 토스 팀의 고민과 해결이 담긴 좋은 글도 읽어보았다.
물론 처음부터 훌륭한 인터랙션을 적용할 수는 없어서 (실력 이슈 및 시간 이슈ㅠㅠ), 하고자 하는 인터랙션과 그 이유를 들어 일단 팀원분들께 양해를 구했다.
정말 감사하게도 의견을 반영해 주셨고, 오히려 먼저 생각하고 계셨다고 다양한 의견을 적극적으로 주셨다. (정말 감사합니다🙇🙇)
터치 인터랙션 도입
필자가 처음에 적용한 인터랙션은 다음과 같다.
클릭 효과로 `noRippleClickable`을 사용하고 있으니 파라미터로 `interactionSource`를 넘겨주고 원하는 효과인 scale, color 등을 사용하는 쪽으로 코드를 구성했다.
완전 과거의 나 : '인터랙션 주고 싶은 곳에서 호출해서 써야지 ㅎㅎ'
@Composable
fun useInteraction(
scaleDownFactor: Float = 0.95f,
backgroundColor: Color = White,
pressedColor: Color = Grey50
): Triple<MutableInteractionSource, Float, Color> {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (isPressed) scaleDownFactor else 1f,
label = "scale"
)
val color by animateColorAsState(
targetValue = if (isPressed) pressedColor else backgroundColor,
label = "color"
)
return Triple(interactionSource, scale, color)
}
// 사용
val (interactionSource, scale, color) = useInteraction()
Modifier
.scale(scale)
.background(color)
.noRippleClickable(interactionSource) { /* click */ }
이 메서드를 사용하면 할수록 뭔가 점점.. 반복되는 코드를 호출하는 게 불필요하다는 느낌이 들었다..
생각해 보니.. 안드로이드의 ripple 효과 자체가 인터랙션을 기반으로 하는 사실이라는 것을 정말 간과하고 있었다.
터치 인터랙션 개선
공식 문서에서 인터랙션에 대해 아주 친절하게 설명하고 있었다.
compose `theme` 에서 적용되고 있었던 `CompositionLocalProvider`를 활용하여 일괄적으로 인터랙션을 적용할 수 있다.
주로 터치 상호작용 시 발생하는 시각적 피드백을 구현하기 위해 `IndicationNodeFactory`를 사용한다. 그래서 필자는 `IndicationNodeFactory`를 상속받은 새 `object`를 작성하였다.
필자가 적용하고 싶었던 인터랙션은 클릭 시 클릭한 아이템의
1. 색상이 살짝 변하기
2. 크기가 살짝 변하기
였다.
그래서 일단 공식 문서를 살짝 참조하여 다음과 같이 적어주었다.
private class NofficeIndicationNode(
private val interactionSource: InteractionSource,
private val dispatcher: CoroutineContext
) : Modifier.Node(), DrawModifierNode {
val animatedScalePercent = Animatable(1f)
val alpha = Animatable(0f)
private suspend fun animateToPressed() {
animatedScalePercent.animateTo(0.95f, tween())
alpha.animateTo(0.3f, tween())
}
private suspend fun animateToResting() {
animatedScalePercent.animateTo(1f, tween())
alpha.animateTo(0f, tween())
}
override fun onAttach() {
coroutineScope.launch {
interactionSource.interactions.collectLatest { interaction ->
when (interaction) {
is PressInteraction.Press -> animateToPressed()
is PressInteraction.Release -> animateToResting()
is PressInteraction.Cancel -> animateToResting()
}
}
}
}
override fun ContentDrawScope.draw() {
drawRoundRect(color = Grey300, alpha = alpha.value, cornerRadius = CornerRadius(x = 32f, y = 32f))
scale(
scale = animatedScalePercent.value,
) {
this@draw.drawContent()
}
}
}
근데.! 필자가 원하는 대로 나오지 않았다. 1) 클릭을 하면 2) 크기가 작아진 후 , 3) 색이 변하는 것이었다.
이유는 `animateTo()` 메서드가 `suspend`함수이기 때문에 동기적으로 실행이 되기 때문이었다.
그래서 해당 코드를 다음과 같이 바꾸어 주었다.
`launch` 를 활용해 비동기로 실행하였다.
private class NofficeIndicationNode(
private val interactionSource: InteractionSource,
private val dispatcher: CoroutineContext
) : Modifier.Node(), DrawModifierNode {
val animatedScalePercent = Animatable(1f)
val alpha = Animatable(0f)
private suspend fun animateToPressed() = withContext(dispatcher) {
launch { animatedScalePercent.animateTo(0.95f, tween()) }
launch { alpha.animateTo(0.3f, tween()) }
}
private suspend fun animateToResting() = withContext(dispatcher) {
launch { animatedScalePercent.animateTo(1f, tween()) }
launch { alpha.animateTo(0f, tween()) }
}
override fun onAttach() {
coroutineScope.launch {
interactionSource.interactions.collectLatest { interaction ->
when (interaction) {
is PressInteraction.Press -> animateToPressed()
is PressInteraction.Release -> animateToResting()
is PressInteraction.Cancel -> animateToResting()
}
}
}
}
override fun ContentDrawScope.draw() {
drawRoundRect(color = Grey300, alpha = alpha.value, cornerRadius = CornerRadius(x = 32f, y = 32f))
scale(
scale = animatedScalePercent.value,
) {
this@draw.drawContent()
}
}
}
좋아쒀!
이렇게 만든 `NodeFactory`를 다음과 같이 설정해 주었다.
디스패처를 `Dispatchers.Main.immediate`를 사용한 이유는 메인 스레드에서 이미 실행 중인 경우, 코루틴을 즉시 실행하기 때문이다.
이러한 특징이 불필요한 지연을 피하기 때문에 바로 반응이 와야 하는 터치 인터랙션 특성을 고려하여 설정해 주었다.
object NofficeIndicationNodeFactory : IndicationNodeFactory {
override fun create(interactionSource: InteractionSource): DelegatableNode {
return NofficeIndicationNode(interactionSource, Dispatchers.Main.immediate)
}
override fun hashCode(): Int = -1
override fun equals(other: Any?) = other === this
}
그리고 당장은 아니지만 나중에 추가적인 인터랙션이 생길 수도 있다고 하여, 다음과 같이 만들어주었다.
@Composable
fun NofficeLocalProvider(
type: ProviderType = ProviderType.BASIC,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
*type.values
) {
content()
}
}
enum class ProviderType(vararg val values: ProvidedValue<*>) {
@OptIn(ExperimentalFoundationApi::class)
BASIC(
LocalOverscrollConfiguration provides null,
LocalRippleTheme provides NoRippleTheme,
LocalIndication provides NofficeIndicationNodeFactory,
)
}
그럼 나는 원하는 곳에서 이렇게 쓰면 설정한 인터랙션을 반영할 수 있다~! (필자는 `theme`에서 일괄적용했다.)
MaterialTheme(
content = {
NofficeLocalProvider {
content()
}
}
)
그럼 `noRippleClickable`을 사용하지 않고 그냥 `clickable`을 사용하면 내가 원하는 인터랙션을 적용할 수 있다.
해당 코드를 적용한 프로젝트는 여기서 보실 수 있습니다.
https://github.com/Team-Notitime/NOFFICE-ANDROID
더 좋은 방법이나 해결책이 있다면 공유 부탁드립니다 ㅎㅎ
참고자료
https://toss.tech/article/interaction
'안드로이드' 카테고리의 다른 글
[Android] 코루틴을 활용해 카카오 로그인 콜백 지옥에서 탈출하기 (5) | 2024.10.02 |
---|---|
[Android] 전략 패턴(Strategy Pattern)을 적용하여 소셜 로그인 리팩토링 하기 (0) | 2024.08.12 |
[Android] 네트워크 예외 처리 (에러 핸들링)- Retrofit CallAdapter (0) | 2024.06.16 |
[Android] MVI 패턴을 알아볼까? (1) | 2024.06.07 |
[Android] 메인 화면 성능 개선하기 대작전 (1) | 2024.04.19 |