@Composable
fun WaveEffect(
modifier: Modifier = Modifier,
waterHeight: Dp,
numberOfWaves: Int = 3,
) = BoxWithConstraints(modifier = modifier) {
val density = LocalDensity.current
val height = with(density) {
maxHeight.toPx()
}
val width = with(density) {
maxWidth.toPx()
}
var previousWaterHeight by remember { mutableStateOf(waterHeight) }
val durationMillis = height / 100
val heightDifference by remember {
derivedStateOf {
with(density) {
abs(previousWaterHeight.toPx() - waterHeight.toPx())
}
}
}
val animatedWaterHeight by animateDpAsState(
targetValue = waterHeight,
animationSpec = tween(
durationMillis = (durationMillis * heightDifference).toInt(),
easing = LinearEasing
), label = ""
)
val waterHeightPx = with(density) {
animatedWaterHeight.toPx()
}
val interval = (width / 3).toInt()
val waterLevel = with(density) {
20.dp.toPx()
}
val progressAnimate = rememberInfiniteTransition(label = "")
val progresses = List(numberOfWaves) { waveIndex ->
progressAnimate.animateFloat(
initialValue = -waveIndex.toFloat(),
targetValue = width - waveIndex.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 3000,
easing = LinearEasing
),
repeatMode = RepeatMode.Restart
),
label = ""
).value + (width / numberOfWaves) * waveIndex
}
val offsets =
List(numberOfWaves) { wave ->
List(interval) { index ->
val xPosition = index * interval.toFloat()
val relativeX = (xPosition - progresses[wave]) / width * 2 * Math.PI
val yValue = sin(relativeX + Math.PI / 2) * waterLevel
mutableStateOf(
Offset(
interval * index.toFloat(),
height - waterHeightPx + yValue.toFloat()
)
)
}
}
LaunchedEffect(animatedWaterHeight) {
previousWaterHeight = waterHeight
}
Canvas(
modifier = modifier
.fillMaxSize()
.background(Color.White)
) {
val paths = List(numberOfWaves) { wave ->
Path().apply {
moveTo(offsets[wave][0].value.x, offsets[wave][0].value.y)
for (i in 1 until offsets[wave].size - 1) {
val x = offsets[wave][i].value.x
val y = offsets[wave][i].value.y
val prevX = offsets[wave][i - 1].value.x
val prevY = offsets[wave][i - 1].value.y
val nextX = offsets[wave][i + 1].value.x
val nextY = offsets[wave][i + 1].value.y
cubicTo(
(prevX + x) / 2,
(prevY + y) / 2,
x,
y,
(x + nextX) / 2,
(y + nextY) / 2
)
}
lineTo(width, height)
lineTo(0f, height)
}
}
for ((index, path) in paths.withIndex()) {
drawPath(path = path, color = image1Colors[index])
}
}
}
물결 효과를 구현한 코드이다. 컴포저블 하나에 다 넣어서 코드가 길기도 하고 이것저것 다 넣어놓은 느낌이 들어 객체지향적으로 코드의 책임을 분리해보도록 하겠다

먼저 도메인 모델을 그려보았다.
도메인 모델은 소프트웨어가 적용하고자 하는 대상(문제 영역)에 대해 추상화한 모델로 사람이 그림을 그리는 영역에 대해서 추상화해보았다.

도메인 모델을 토대로 클래스 다이어그램을 만들어보았다.
Person이 해당 그림을 그리라는 호출을 받으면 해당 그림에 대한 정보를 Picture 클래스로부터 받아오고 이를 Canvas에 전달하면서 화면에 그리라는 요청을 한다.
Canvas를 전달받은 Picture에게 draw를 호출하여 현재 사이즈 캔버스에 그림을 그리라고 요청하고 Picture는 자신이 알고 있는 그림 그리는 방법으로 실제 화면에 그림을 그린다.
@Composable
fun MyCanvas(
modifier: Modifier = Modifier,
backgroundColor: Color = Color.White,
shape: Shape = RectangleShape,
content: (DrawScope) -> Unit,
) {
Canvas(
modifier = modifier
.fillMaxSize()
.background(backgroundColor)
.clip(shape)
) {
content(this)
}
}
기존에 이미 Canvas 컴포저블이 있어서 MyCanvas로 새로 만들어 주었다.
사실 backgroundColor, Shape 등등을 다 Modifier에 넣을 수 있기에 기존 Canvas를 사용해도 된다.
class WavePicture(
val color: Color = Color.Blue,
val animation: WaveAnimation,
private val offset: Offset = Offset.Zero,
private val waveAmplitude: Float = 0f,
private val numberOfPeaks: Int,
) : Picture() {
override fun draw(drawScope: DrawScope) {
val size = drawScope.size
val interval = (size.width / (numberOfPeaks * 2 + 1)).toInt()
val offsets = List(interval) { index ->
val xPosition = index * interval.toFloat()
val progressX = animation.getWaveProgress(offset.x)
val currentWaterHeight = animation.getWaterHeight()
val relativeX = (xPosition - progressX) / size.width * 2 * Math.PI
val yValue = sin(relativeX + Math.PI / 2) * waveAmplitude
mutableStateOf(
Offset(
interval * index.toFloat(),
size.height - currentWaterHeight + yValue.toFloat()
)
)
}
drawScope.drawPath(
path = Path().apply {
moveTo(offsets[0].value.x, offsets[0].value.y)
for (i in 1 until offsets.size - 1) {
val x = offsets[i].value.x
val y = offsets[i].value.y
val prevX = offsets[i - 1].value.x
val prevY = offsets[i - 1].value.y
val nextX = offsets[i + 1].value.x
val nextY = offsets[i + 1].value.y
cubicTo(
(prevX + x) / 2,
(prevY + y) / 2,
x,
y,
(x + nextX) / 2,
(y + nextY) / 2
)
}
lineTo(size.width, size.height)
lineTo(0f, size.height)
},
color = color
)
}
}
color, offet, animation 등등 필요한 부가 정보들을 받아서 drawScope에다가 그림을 그려준다.
abstract class Picture {
open fun draw(drawScope: DrawScope) {}
}
Picture는 간단하게 draw만 가지고 있다.
@Composable
fun WaveEffect(
modifier: Modifier = Modifier,
waterHeight: Dp,
waveAmplitude: Float,
numberOfWaves: Int = 3,
numberOfPeaks: Int = 1,
waveProgressDuration: Int,
) {
var width by remember {
mutableFloatStateOf(0f)
}
var height by remember {
mutableFloatStateOf(0f)
}
val animation = remember {
WaveAnimation(
waveProgressDuration = waveProgressDuration
)
}
animation.Start(waterHeight = waterHeight, width = width, numOfWaves = numberOfWaves)
val waves = List(numberOfWaves) { index ->
WavePicture(
color = image1Colors[index],
animation = animation,
waveAmplitude = waveAmplitude,
offset = Offset(-index.toFloat(), 0f),
numberOfPeaks = numberOfPeaks
)
}
MyCanvas(
modifier = modifier,
backgroundColor = Color.White
) { drawScope ->
width = drawScope.size.width
height = drawScope.size.height
for (wave in waves) {
wave.draw(drawScope)
}
}
}
설정해줘야 하는 값들을 받아서 Picture를 생성해주고
이를 MyCanvas에 전달해서 그림을 그려준다.
class WaveAnimation(
private val waveProgressDuration: Int,
) {
private var previousWaterHeight by mutableStateOf(0.dp)
private var currentWaterHeight by mutableStateOf(previousWaterHeight)
private var currentWaveProgresses by mutableStateOf(listOf<Float>())
@Composable
fun Start(
waterHeight: Dp,
width: Float,
numOfWaves: Int,
) {
UpdateWaterHeight(waterHeight)
UpdateWaveProgress(width, numOfWaves)
}
@Composable
fun UpdateWaterHeight(waterHeight: Dp) {
val animatedHeight by animateDpAsState(
targetValue = waterHeight,
animationSpec = tween(
durationMillis = waveProgressDuration,
easing = LinearEasing
),
label = "waterHeight"
)
LaunchedEffect(animatedHeight) {
previousWaterHeight = waterHeight
}
currentWaterHeight = animatedHeight
}
@Composable
fun UpdateWaveProgress(
width: Float,
numOfWaves: Int,
) {
val progressAnimate = rememberInfiniteTransition(label = "waveProgress")
currentWaveProgresses = List(numOfWaves) { waveIndex ->
progressAnimate.animateFloat(
initialValue = -waveIndex.toFloat(),
targetValue = width - waveIndex.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = waveProgressDuration,
easing = LinearEasing
),
repeatMode = RepeatMode.Restart
),
label = "waveProgress"
).value + (width / numOfWaves) * waveIndex
}
}
fun getWaveProgress(waveOffset: Float): Float {
val waveNum = -waveOffset.toInt()
return currentWaveProgresses.getOrElse(waveNum) { waveOffset }
}
fun getWaterHeight(): Float {
return currentWaterHeight.value
}
}
사실 애니메이션 분리하는 게 제일 힘들었다.
우선 Composable 안에서 동작해야 하기에 함수들은 @Composable로 구성이 되어있고 어떤 어떤 애니메이션을 시작해야하는 지는 WaveEffect가 몰라야 하기에 Start 컴포저블로 감쌌다.
애니메이션이 진행되면서 생성되는 값들을 변수로 가지고 있다가 Picture에서 draw할 때 getXX로 가져와서 사용하도록 하였다.
WaveEffect(
modifier = Modifier
.width(500.dp)
.aspectRatio(1f)
.onGloballyPositioned {
size = it.size
},
waveAmplitude = with(LocalDensity.current) {
20.dp.toPx()
},
waterHeight = targetHeight,
waveProgressDuration = 2000,
numberOfPeaks = 1
)