
PC 버전에서 인스타그램에 게시물을 올리면 나오는 애니메이션이다.
만들어야 하는 애니메이션은
1. 배경 원 - 완료가 될 때까지 무한히 회전해야함
2. 체크 표시 - 배경 원이 멈춘 후에 그려지며 창을 닫을 때까지 무한히 반복
이렇게 크게 2가지가 있다.
우선 배경 링을 그려보자

그라데이션 색상은 인스타 공식 홈페이지에서 가져왔다. 총 5개의 색상으로 이루어져 있고 사선으로 그라데이션이 이루어져 있기에
val colorList = listOf(
Color(0xFFFFD600),
Color(0xFFFF7A00),
Color(0xFFFF0069),
Color(0xFFD300C5),
Color(0xFF7638FA)
)
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.align(Alignment.Center)
.background(
brush = Brush.linearGradient(colorList),
shape = RingShape(ringWidth = 12.dp)
)
)
다음과 같이 linearGradient로 설정해줬다.
그라데이션 옵션은 여기에서 확인할 수 있다.
체크 마크는 Path를 이용해서 그렸다.

체크 마크의 비율을 확인해보니 왼쪽 체크마크는 높이의 절반에서 시작하고 꺽이는 지점은 가로길이의 1/3 지점 정도인 것 같다.
전체 높이는 width의 2/3 크기로 가로세로 비율이 1.5:1이 되도록 하였다.
val height = (size.width * 2 / 3).toInt()
val width = size.width
val centerY = size.height / 2
val curvePoint = width / 3
val startY = centerY - height / 2
val endY = centerY + height / 2
val path = Path().apply {
moveTo(0f, centerY.toFloat())
lineTo(curvePoint.toFloat(), endY.toFloat())
lineTo(size.width.toFloat(), startY.toFloat())
}
Animatable API를 이용해서 회전시켰으며 isComplete가 될 때까지는 계속 반복해서 돌아야 하기 때문에 비동기적으로 계속 돌리도록 하였다.
val rotationAnimation = remember { Animatable(0f) }
val rotation by remember { derivedStateOf { rotationAnimation.value % 360f } }
val scope = rememberCoroutineScope()
val rotateDuration = 500
LaunchedEffect(isComplete) {
scope.launch {
while (true) {
rotationAnimation.animateTo(
targetValue = rotationAnimation.value + 360f,
animationSpec = tween(
durationMillis = rotateDuration,
easing = LinearEasing
)
)
}
}
}
rotation을 적용해줄 때 조심해야하는 게
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp)
.graphicsLayer {
rotationZ = rotation
}
) {
//ring
//check mark
}
돌리려는 컴포저블의 밖의 컴포저블에 적용을 해줘야 내부 컴포저블들이 회전하게 된다.
단순히 isComplete가 되었다고 바로 stop을 해버리면

이렇게 체크마크도 같이 돌아간 채로 그려지게 된다.
그래서 isComplete가 되었어도 남아있는 각도만큼 추가로 회전하게 해주었다.
LaunchedEffect(isComplete) {
if (isComplete) {
while (rotationAnimation.isRunning) {
val remainAngle = rotationAnimation.value + (360f - rotation)
rotationAnimation.animateTo(
targetValue = remainAngle,
animationSpec = tween(
durationMillis = ((360f - rotation) * rotateDuration / 360f).toInt(),
easing = LinearEasing
)
)
}
} else {
scope.launch {
while (true) {
rotationAnimation.animateTo(
targetValue = rotationAnimation.value + 360f,
animationSpec = tween(
durationMillis = rotateDuration,
easing = LinearEasing
)
)
}
}
}
}
360도를 rotateDuration 시간동안 회전했으니까 남아있는 각도만큼으로 시간을 조절해줘야 자연스럽게 회전이 이어진다.
이제 체크 마크를 그려보자
animationDuration 동안 순차적으로 자른? PathSegment를 반복해서 그려주는 방식으로 그린다.
val pathMeasure = PathMeasure().apply {
setPath(path, forceClosed = false)
}
val pathSegment = Path()
pathMeasure.getSegment(
startDistance = 0f,
stopDistance = pathMeasure.length * progress,
path = pathSegment,
startWithMoveTo = true
)
path segment는 PathMeasure를 통해서 얻을 수 있고 pathSegement에다가 progress길이만큼 자른 path를 저장한다.
그리고 drawPath에다가 pathSegment를 전달해주면 된다.
drawPath(
path = pathSegment,
brush = Brush.linearGradient(colorList),
style = Stroke(
width = 12.dp.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
성공적으로 완료된 후에도 사용자가 창을 닫지 않으면 체크 마크가 무한히 계속 반복해서 그려진다. infiniteRepeatable를 이용하여 계속 그려주도록 하였다.
val infiniteTransition = rememberInfiniteTransition(label = "")
val pathLength by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
easing = LinearEasing
),
repeatMode = RepeatMode.Restart
),
label = ""
)
체크 마크를 그릴 때 배경 원의 회전이 멈추고 난 후, 체크 마크를 다시 그릴 때 약간의 딜레이가 있는 걸 볼 수 있다.
val infiniteTransition = rememberInfiniteTransition(label = "")
val pathLength by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 2700
0f at 0 with LinearEasing
0.3f at 300 with LinearEasing
0.3f at 400
1f at 700 with LinearEasing
1f at 2600
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(2000)
),
label = ""
)
처음 시작 딜레이는 initialStartOffset로 설정해주었고 keyframes를 이용해서 0.7초동안 그려주고 2초동안은 딜레이가 있도록 설정하였다.
기존 애니메이션을 잘 보면 중간에 꺽이는 부분에서 잠깐 멈춰서 체크하는 것처럼 보이게 해주었길래 중간에 0.1초 잠깐 딜레이를 넣어주었다.
val colorList = listOf(
Color(0xFFFFD600),
Color(0xFFFF7A00),
Color(0xFFFF0069),
Color(0xFFD300C5),
Color(0xFF7638FA)
)
@Composable
fun InstagramProgress(
isComplete: Boolean,
) {
val rotationAnimation = remember { Animatable(0f) }
val rotation by remember { derivedStateOf { rotationAnimation.value % 360f } }
val scope = rememberCoroutineScope()
val rotateDuration = 500
LaunchedEffect(isComplete) {
if (isComplete) {
while (rotationAnimation.isRunning) {
val remainAngle = rotationAnimation.value + (360f - rotation)
rotationAnimation.animateTo(
targetValue = remainAngle,
animationSpec = tween(
durationMillis = (360f - rotation).toInt() * (rotateDuration / 360f).toInt(),
easing = LinearEasing
)
)
}
} else {
scope.launch {
while (true) {
rotationAnimation.animateTo(
targetValue = rotationAnimation.value + 360f,
animationSpec = tween(
durationMillis = rotateDuration,
easing = LinearEasing
)
)
}
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp)
.graphicsLayer {
rotationZ = rotation
}
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.align(Alignment.Center)
.background(
brush = Brush.linearGradient(colorList),
shape = RingShape(
ringWidth = 12.dp
)
)
)
if (isComplete && rotationAnimation.isRunning.not()) {
CheckMarkAnimation(
modifier = Modifier
.fillMaxSize()
.padding(40.dp)
)
}
}
}
@Composable
private fun CheckMarkAnimation(modifier: Modifier) {
val infiniteTransition = rememberInfiniteTransition(label = "pathLength")
val pathLength by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 2700
0f at 0 with LinearEasing
0.3f at 300 with LinearEasing
0.3f at 400
1f at 700 with LinearEasing
1f at 2600
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(2000)
), label = "pathLength"
)
Surface(
modifier = modifier.fillMaxSize(),
color = Color.Transparent
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCheckMark(size, pathLength)
}
}
}
}
private fun DrawScope.drawCheckMark(
size: Size, progress: Float,
) {
val height = (size.width * 2 / 3).toInt()
val width = size.width
val centerY = size.height / 2
val curvePoint = width / 3
val startY = centerY - height / 2
val endY = centerY + height / 2
val path = Path().apply {
moveTo(0f, centerY)
lineTo(curvePoint, endY)
lineTo(size.width, startY)
}
val pathMeasure = PathMeasure().apply {
setPath(path, forceClosed = false)
}
val pathSegment = Path()
pathMeasure.getSegment(
startDistance = 0f,
stopDistance = pathMeasure.length * progress,
pathSegment,
startWithMoveTo = true
)
drawPath(
path = pathSegment,
brush = Brush.linearGradient(colorList),
style = Stroke(
width = 12.dp.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
}