
이렇게 인스타그램에서 라이브방송을 할 때 나오는 하트 애니메이션을 만들어볼 것이다.
분석을 해보자면
1. 작은 하트들이 먼저 빠르게 지나감
2. 큰 하트들은 줄을 지어서 지나가고 벽에 부딪히는 것처럼 좌우로 움직임
3. 일정 시간이 지나면 반복
var bubbleState by remember {
mutableStateOf(
List(30) { index ->
LiveHeart.create(
index,
screenSize = Size(
widthPx,
heightPx
),
floatWidth = widthPx / 3f,
density = density,
radius = with(density) {
25.dp.toPx()
},
incrementationRatio = 0.4f,
)
}
)
}
fun create(
index: Int,
screenSize: Size,
density: Density,
floatWidth: Float,
radius: Float,
incrementationRatio: Float = 0.4f,
startOffset: Offset = Offset.Zero,
): LiveHeart {
val x = screenSize.width / 2f + startOffset.x
val y = screenSize.height + startOffset.y + 15.dp.dpToPx(density) * index
return LiveHeart(
offset = Offset(x, y),
radius = radius,
width = floatWidth,
angle = Random.nextFloat() * (PI / 8) + PI * 3 / 2 - (PI * 1 / 16),
backgroundAlpha = (Random.nextFloat() + 0.2f).coerceAtMost(1f),
index = index,
incrementationRatio = incrementationRatio,
startOffset = startOffset
)
}
하트와 하트 사이는 y값이 15.dp씩 차이가 나도록 하여 나란히 올라가도록 하였다.
var firstBubbleState by remember {
mutableStateOf(
List(50) { index ->
LiveHeart.create(
index,
screenSize = Size(
widthPx,
heightPx
),
floatWidth = widthPx,
density = density,
radius = with(density) {
15.dp.toPx()
},
incrementationRatio = 1.5f,
)
}.also {
activeFirstHeartMotion = true
}
)
}
작은 하트도 동일하게 생성해주는데 incrementationRatio를 크게 설정해서 큰 하트보다 더 빨리 움직이도록 하였다.
LaunchedEffect(activeFirstHeartMotion) {
while (isActive) {
awaitFrame()
firstBubbleState = firstBubbleState.map { bubble ->
bubble.update(
Size(
widthPx,
heightPx
),
density
)
bubble
}
delay(16L)
}
}
LaunchedEffect(activeFirstHeartMotion) {
if (activeFirstHeartMotion) {
delay(300)
activeHeartMotion = true
}
}
LaunchedEffect(activeHeartMotion) {
if (!activeHeartMotion) return@LaunchedEffect
while (isActive) {
awaitFrame()
bubbleState = bubbleState.map { bubble ->
bubble.update(
Size(
widthPx,
heightPx
),
density
)
bubble
}
delay(16L)
}
}
fun update(
screenSize: Size,
density: Density,
) {
if (offset.y >= screenSize.height + startOffset.y && initlaized) {
return
}
val newX = offset.x + increment * cos(angle).toFloat()
val newY = offset.y + increment * sin(angle).toFloat()
offset = if (newX < 0 || newX > screenSize.width || newY < 0) {
initializeOffset(screenSize, density)
} else if (newX !in range) {
angle = (3 * PI / 2 - angle) + (3 * PI / 2)
Offset(
x = newX + cos(angle).toFloat(),
y = newY
)
} else {
Offset(newX, newY)
}
if (offset.y < screenSize.height / 2) {
alpha -= 0.05f
}
if (offset.y <= screenSize.height + startOffset.y) {
initlaized = true
}
}
16ms마다 위치값을 없데이트해주고 initalized를 통해 처음 초기화되어 화면 밖으로 나간 경우 외에 다시 화면 밖으로 나간 경우에는 다시 반복하지 않도록 하였다.
for (bubble in bubbleState) {
val offsetX = with(density) {
bubble.offset.x.toDp()
}
val offsetY = with(density) {
bubble.offset.y.toDp()
}
val radiusDp = with(density) {
bubble.radius.toDp()
}
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = null,
modifier = Modifier
.size(radiusDp * 2)
.offset(
x = offsetX,
y = offsetY
)
.alpha(bubble.alpha),
tint = Color.White.copy(alpha = bubble.backgroundAlpha)
)
}
@Composable
fun InstagramLiveHeart(
modifier: Modifier = Modifier,
) = BoxWithConstraints(modifier) {
val density = LocalDensity.current
val widthPx = with(density) {
maxWidth.toPx()
}
val heightPx = with(density) {
maxHeight.toPx()
}
var activeFirstHeartMotion by remember {
mutableStateOf(false)
}
var activeHeartMotion by remember {
mutableStateOf(false)
}
var firstBubbleState by remember {
mutableStateOf(
List(50) { index ->
LiveHeart.create(
index,
screenSize = Size(
widthPx,
heightPx
),
floatWidth = widthPx,
density = density,
radius = with(density) {
15.dp.toPx()
},
incrementationRatio = 1.5f,
)
}.also {
activeFirstHeartMotion = true
}
)
}
var bubbleState by remember {
mutableStateOf(
List(30) { index ->
LiveHeart.create(
index,
screenSize = Size(
widthPx,
heightPx
),
floatWidth = widthPx / 3f,
density = density,
radius = with(density) {
25.dp.toPx()
},
incrementationRatio = 0.4f,
)
}
)
}
LaunchedEffect(activeFirstHeartMotion) {
while (isActive) {
awaitFrame()
firstBubbleState = firstBubbleState.map { bubble ->
bubble.update(
Size(
widthPx,
heightPx
),
density
)
bubble
}
delay(16L)
}
}
LaunchedEffect(activeFirstHeartMotion) {
if (activeFirstHeartMotion) {
delay(300)
activeHeartMotion = true
}
}
LaunchedEffect(activeHeartMotion) {
if(!activeHeartMotion) return@LaunchedEffect
while (isActive) {
awaitFrame()
bubbleState = bubbleState.map { bubble ->
bubble.update(
Size(
widthPx,
heightPx
),
density
)
bubble
}
delay(16L)
}
}
Box(
modifier = Modifier
.fillMaxSize()
) {
for (bubble in firstBubbleState) {
val offsetX = with(density) {
bubble.offset.x.toDp()
}
val offsetY = with(density) {
bubble.offset.y.toDp()
}
val radiusDp = with(density) {
bubble.radius.toDp()
}
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = null,
modifier = Modifier
.size(radiusDp * 2)
.offset(
x = offsetX,
y = offsetY
)
.alpha(
bubble.alpha
),
tint = Color.White.copy(alpha = bubble.backgroundAlpha)
)
}
for (bubble in bubbleState) {
val offsetX = with(density) {
bubble.offset.x.toDp()
}
val offsetY = with(density) {
bubble.offset.y.toDp()
}
val radiusDp = with(density) {
bubble.radius.toDp()
}
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = null,
modifier = Modifier
.size(radiusDp * 2)
.offset(
x = offsetX,
y = offsetY
)
.alpha(
bubble.alpha
),
tint = Color.White.copy(alpha = bubble.backgroundAlpha)
)
}
}
}
class LiveHeart(
offset: Offset,
val radius: Float,
val width: Float,
angle: Double,
val backgroundAlpha: Float,
val index: Int,
incrementationRatio: Float = 0.1f,
private val startOffset: Offset = Offset.Zero,
) {
var offset by mutableStateOf(offset)
private var angle by mutableDoubleStateOf(angle)
var alpha by mutableFloatStateOf(1f)
private var range = (offset.x - width / 2)..(offset.x + width / 2)
private val increment = radius * incrementationRatio
private var initlaized = false
fun update(
screenSize: Size,
density: Density,
) {
if (offset.y >= screenSize.height + startOffset.y && initlaized) {
return
}
val newX = offset.x + increment * cos(angle).toFloat()
val newY = offset.y + increment * sin(angle).toFloat()
offset = if (newX < 0 || newX > screenSize.width || newY < 0) {
initializeOffset(screenSize, density)
} else if (newX !in range) {
angle = (3 * PI / 2 - angle) + (3 * PI / 2)
Offset(
x = newX + cos(angle).toFloat(),
y = newY
)
} else {
Offset(newX, newY)
}
if (offset.y < screenSize.height / 2) {
alpha -= 0.05f
}
if (offset.y <= screenSize.height + startOffset.y) {
initlaized = true
}
}
private fun initializeOffset(
screenSize: Size,
density: Density,
): Offset {
val x = startOffset.x + screenSize.width / 2f
val y = startOffset.y + screenSize.height + 20.dp.dpToPx(density) * index
alpha = 0f
return Offset(x, y)
}
companion object {
fun create(
index: Int,
screenSize: Size,
density: Density,
floatWidth: Float,
radius: Float,
incrementationRatio: Float = 0.4f,
startOffset: Offset = Offset.Zero,
): LiveHeart {
val x = screenSize.width / 2f + startOffset.x
val y = screenSize.height + startOffset.y + 15.dp.dpToPx(density) * index
return LiveHeart(
offset = Offset(x, y),
radius = radius,
width = floatWidth,
angle = Random.nextFloat() * (PI / 8) + PI * 3 / 2 - (PI * 1 / 16),
backgroundAlpha = (Random.nextFloat() + 0.2f).coerceAtMost(1f),
index = index,
incrementationRatio = incrementationRatio,
startOffset
)
}
}
}