지난 번에는 유튜브 구독설정 후 나타나는 알림설정 애니메이션을 만들었었다.
오늘은 여기에 사용자가 당겼다가 놓으면 시계추처럼 흔들리도록 인터렉션을 넣어볼 것이다.
Box(
modifier = modifier
.fillMaxSize()
.onGloballyPositioned {
size = it.size
}
.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
isHanging = true
pendulum.startSwinging()
},
onDragStart = { position ->
isHanging = false
startPosition = position
},
onDrag = { change, _ ->
endPosition = change.position
val angle = calculateAngleDegree(startPosition, endPosition, size)
animation.updateInitialAngleDegree(angle)
}
)
},
contentAlignment = Alignment.Center,
) {
pendulum.Draw(this)
}
detectDragGestures을 이용해서 드래그를 감지하고
onDragStart와 onDrag를 통해 어느 방향으로 어느 정도 드래그를 했는지를 계산하고 onDragEnd가 되면 hanging 애니메이션이 시작되도록 하였다.
private fun calculateAngleDegree(startPosition: Offset, endPosition: Offset, size: IntSize): Float {
val centerX = size.width / 2f
val centerY = size.height / 2f
val startDelta = atan2(
startPosition.y - centerY,
startPosition.x - centerX
)
val endDelta = atan2(
endPosition.y - centerY,
endPosition.x - centerX
)
var angleInRadians = endDelta - startDelta
if (angleInRadians > Math.PI.toFloat()) {
angleInRadians -= 2 * Math.PI.toFloat()
} else if (angleInRadians < -Math.PI.toFloat()) {
angleInRadians += 2 * Math.PI.toFloat()
}
var angleInDegrees = angleInRadians * (180 / Math.PI).toFloat()
angleInDegrees = angleInDegrees.coerceIn(-90f, 90f)
return angleInDegrees
}
(centerX, centerY)를 기준으로 startPosition, endPosition을 통해서 어느 각도만큼 기울었는지를 계산하고 -90도 ~ 90도 범위 내로 제한하였다.
@Composable
override fun Draw(boxScope: BoxScope) {
val iconSize = with(LocalDensity.current) {
minOf(size.width, size.height).toDp()
}
val radius = with(LocalDensity.current) {
iconSize.toPx() / 2
}
Box(
modifier = Modifier
.size(iconSize)
) {
Icon(
imageVector = Icons.Outlined.Notifications,
contentDescription = null,
modifier = Modifier
.size(iconSize)
.graphicsLayer {
val centerY = size.height / 2
val angleDegree = animation.getCurrentRotationDegree()
val angleRadians = angleDegree.toRadian()
this.rotationZ = angleDegree
this.translationX = -radius * sin(angleRadians)
this.translationY = -centerY + radius * cos(angleRadians)
}
)
}
}
rotationZ는 중심좌표를 기준으로 회전하기 때문에 상단 중앙을 기준으로 돌아가도록 하고 싶으면 translationX, Y를 통해서 이동을 해주어야 한다.
이렇게 구현하면

이런 결과가 나온다!
이제 사용자가 Drag를 끝내면 시계추처럼 흔들리는 애니메이션을 적용해보자.
여기에 원리를 작성해두었으니 애니메이션 식이 궁금한 사람은 여기가서 보고 이 글에서는 변경된 식만 작성하였다.
class PendulumAnimation(
val initialAngleDegree: Float = 20f,
private val dampingFactor: Float = 0.6f,
) {
private var startAngleDegree by mutableFloatStateOf(initialAngleDegree)
private var currentAngleDegree by mutableFloatStateOf(startAngleDegree)
private var rotationDegree by mutableFloatStateOf(currentAngleDegree)
@Composable
fun Start(trigger: Boolean) {
StartHanging(isHanging = trigger)
}
fun updateInitialAngleDegree(updateAngleDegree: Float) {
this.startAngleDegree = updateAngleDegree
this.currentAngleDegree = updateAngleDegree
this.rotationDegree = updateAngleDegree
}
@Composable
fun StartHanging(isHanging: Boolean) {
LaunchedEffect(isHanging) {
if (isHanging) {
while (abs(currentAngleDegree) >= 1f) {
animate(
initialValue = currentAngleDegree,
targetValue = 0f,
animationSpec = keyframes {
durationMillis = 300
0f at 0 with LinearEasing
currentAngleDegree at 75 with LinearEasing
0f at 150 with LinearEasing
-currentAngleDegree at 225 with LinearEasing
0f at 300 with LinearEasing
},
) { value, _ ->
rotationDegree = value
}
currentAngleDegree *= dampingFactor
}
} else {
currentAngleDegree = startAngleDegree
}
}
}
fun getCurrentRotationDegree() = rotationDegree
}