Android

BounceClick을 Compose에서 수식과 함께 구현해보자 with Animation

chattymin 2024. 7. 19. 17:16
728x90

최근 프로젝트를 하나 하며 디자이너의 요구사항을 하나 받았다.

Button을 클릭했을 때 Bunce Click을 구현해달라 하더라.

 

이 무슨...

 

사용자가 클릭을 했을 때 scale이 작아지고, 위에 검은색 filter가 생겨야 한다. 그리고 손을 뗀다면 다시 원래대로 돌아와야 한다.

 

 

이 기능을 구현하기 위해 제일 첫 단계로 Modifier의 확장함수를 만들기 시작했다.

@Composable
fun Modifier.bounceClick(
): Modifier = composed {

}

 

특정 버튼에 해당 기능을 넣어둔다면 다른 버튼에 넣어달라 할 때 마다 버튼부터 새로 만들어야 할 것이다. 그렇게 된다면 자연스럽게 너무 많은 시간이 들게되고, 비효율적이게 된다고 생각한다. 

 

그래서 Modifier의 확장함수를 만들고 이를 적용해 모든 버튼, 모든 컴포넌트를 clickable하고 bounce할 수 있도록 하고자 했다.

 

 

뭐가 필요할까?

제일 사용자의 눈에 띄는 것은 크기가 작아지는 이펙트일 것이다.

그렇기 때문에 scale을 줄이는 것을 제 1순위로 생각했다. 그리고 해당 값이 즉각적으로 변한다면 scale이 끊어지며 변하는 모습을 보일 것이고, 이는 사용자에게 좋아보이지 않는다. 그렇기 때문에 animation을 넣어야 할 것이다.

또한 사용자가 눌렸을 때 scale이 작아져야 하고 손을 떼면 scale이 커져야 한다. 그러므로 사용자의 입력을 감지하는 기능이 필요하다.

@Composable
fun Modifier.bounceClick(
    scaleDown: Float = 0.96f,
    onClick: () -> Unit,
): Modifier = composed {
    val interactionSource = remember { MutableInteractionSource() }
    val isPressed by interactionSource.collectIsPressedAsState()

    val scale by animateFloatAsState(
        if (isPressed) scaleDown else 1f, label = ""
    )

    this
        .graphicsLayer {
            scaleX = scale
            scaleY = scale
        }
        .clickable(
            interactionSource = interactionSource,
            indication = null,
            onClick = onClick
        )
}

 

interactionSource를 이용해 사용자의 입력을 감지한다.

scaleDown을 이용해 작아졌으면 하는 크기를 받아온다.

animateFloatAsState를 이용해 부드럽게 scale이 작아질 수 있도록 했다.

 

 

이렇게 동작시키면 아래와 같이 동작하게 된다

 

머릿속에 생각한 대로 클릭하면 천천히 scale이 작아지고 손을 떼면 천천히 scale이 커진다.

1차적으로 이정도 기능이 구현되었다.

 

 

 

2차 구현으로 위에 검은색 filter가 생기는 것을 해야한다.

이를 위해 color값을 설정해줘야 하고 기존 값 위에 필터를 씌우는 과정을 넣어야 한다.

@Composable
fun Modifier.bounceClick(
    scaleDown: Float = 0.96f,
    blackAlpha: Float = 0.08f,
    radius: Float = 10f,
    onClick: () -> Unit,
): Modifier = composed {
    val interactionSource = remember { MutableInteractionSource() }
    val isPressed by interactionSource.collectIsPressedAsState()

    val scale by animateFloatAsState(
        if (isPressed) scaleDown else 1f, label = ""
    )

    val color by animateColorAsState(
        if (isPressed) Color.Black.copy(alpha = blackAlpha) else Color.Transparent, label = ""
    )

    this
        .graphicsLayer {
            scaleX = scale
            scaleY = scale
        }
        .clickable(
            interactionSource = interactionSource,
            indication = null,
            onClick = onClick
        )
        .drawWithContent {
            drawContent()
            if (isPressed) {
                drawRoundRect(
                    cornerRadius = CornerRadius(radius),
                    color = color,
                    size = size,
                    blendMode = BlendMode.SrcAtop
                )
            }
        }
}

 

 

blackAlpha값을 매개변수로 받아와서 검은색 필터의 색상 정도를 조정한다.

animateColorAsState를 통해 갑자기 검은 색이 되는 것이 아닌, 점진적으로 검은색이 되도록 설정하였다.

 

 

이렇게 했을 때 생각대로라면 scale이 작아지며 위에 검은색 필터가 생길 것이라 예상했다.

하지만 결과물은 아래와 같았다.

 

끝 모서리 부분을 자세히 보면 불필요한 회색 여백이 보인다.

너무나 마음에 안든다. 오히려 디자인을 해치는 느낌.

 

 

뭐가 문제일까?

버튼의 scale은 작아지지만, 검은색 필터의 scale은 줄어들지 않는다. 

그렇기 때문에 같은 radius의 값이더라도 크기가 달라진다.

 

그렇기 때문에 크기 차이가 발생한다고 판단했다.

 

해결방법으로 필터에도 scale을 주는 방법을 적용해보았다.

...
    this
        .graphicsLayer {
            scaleX = scale
            scaleY = scale
        }
        .clickable(
            interactionSource = interactionSource,
            indication = null,
            onClick = onClick
        )
        .drawWithContent {
            drawContent()
            if (isPressed) {
                scale(scale) {
                    drawRoundRect(
                        cornerRadius = CornerRadius(radius),
                        color = color,
                        size = size,
                        blendMode = BlendMode.SrcAtop
                    )
                }
            }
        }
...

 

필터의 scale이 줄어들고 같은 radius를 가진다면 fit하게 필터가 될 것이라 생각했다.

 

 

하지만... 오히려 버튼보다 크기가 작아졌다.

 

 

어떻게 해결하지?

그래서 계산을 해보기로 했다.

 

radius가 10이고, scaleDown이 96f였다.

그랬을 때 fit한 필터의 radius를 직접 구해보니 24를 넣으니 깔끔하게 나왔다.

 

그래서 역순으로 계산을 시작했다.

 

radius가 10일때 24로 변경되었으니 radius가 1일때 2.4만큼 줄어든다.

이때 scaleDown이 96으로 4만큼 scaleDown되었기 때문에 1 radius, 1 scaleDown당 0.4만큼 줄어든다고 계산했다.

 

물론 정확한 계산이 아니라 역순으로 계산을 한것이기 때문에 정확하지는 않다.

하지만 현재 내가 개발한 앱의 모든 button에 적용되기 때문에 이렇게 적용했다.

 

아래는 결과 코드다.

@Composable
fun Modifier.bounceClick(
    scaleDown: Float = 0.96f,
    blackAlpha: Float = 0.08f,
    radius: Float = 10f,
    onClick: () -> Unit,
): Modifier = composed {
    val interactionSource = remember { MutableInteractionSource() }
    val isPressed by interactionSource.collectIsPressedAsState()

    val cornerRadius = (1 - scaleDown) * 100 * 0.6f * radius

    val scale by animateFloatAsState(
        if (isPressed) scaleDown else 1f, label = ""
    )

    val color by animateColorAsState(
        if (isPressed) Color.Black.copy(alpha = blackAlpha) else Color.Transparent, label = ""
    )

    this
        .graphicsLayer {
            scaleX = scale
            scaleY = scale
        }
        .clickable(
            interactionSource = interactionSource,
            indication = null,
            onClick = onClick
        )
        .drawWithContent {
            drawContent()
            if (isPressed) {
                drawRoundRect(
                    cornerRadius = CornerRadius(cornerRadius),
                    color = color,
                    size = size,
                    blendMode = BlendMode.SrcAtop
                )
            }
        }
}

 

 

 

이 값을 구하기 위해서 디자이너와 둘이 붙어서 한참을 씨름했다.

100퍼센트 완벽한 코드는 아니지만, 일단은 잘 돌아가고 내 환경에서는 문제가 없는 코드를 만들 수 있었다.

추후에 해당 값을 계산하는 수식을 만들고, 적용해서 깔끔하게 만들고 싶다.

 

 

 

 

이것저것...

애니메이션과 같은 인터렉션 요소들이 사용자에게 가장 가까이 다가갈 수 있고, 극단적으로 사용 경험을 끌어올릴 수 있다고 생각한다.

그래서 이번에 꼭 구현하고 싶었고, 마음에 들게 구현된 것 같다.

이 외에도 이번에 다양한 애니메이션에 도전했고, 생각보다 이쁘게 잘 나왔다.

 

 

물론 마음에 안드는 부분도 많다. 하지만 예전과 비교하여 많이 성장했고, 이런 것들을 보기 시작했다는 것이 뿌듯하다.

앞으로도 더 많은 애니메이션으로 사용자의 경험을 개션시키는 개발자가 되고자 방향을 정하려 한다.

728x90