动画列表页

页面动画拆分
- 背景动画
- 标题动画
- 水波纹动画
- ToDo列表动画
背景动画 AnimateBackgroundView
使用 Canvas Path 绘制图形
rememberInfiniteTransition开始动画


scss
@Composable
fun AnimateBackgroundView(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition("AnimateBackgroundView")
val rotate by transition.animateFloat(
0f, 1f,
animationSpec =
infiniteRepeatable(
tween(16000, easing = LinearEasing),
RepeatMode.Restart
)
)
//
val periods = remember { intArrayOf(3, 4, 5) }
val colors = remember {
arrayOf(
Color.Green,
Color.Cyan,
Color.Magenta,
)
}
Box(modifier.fillMaxSize()) {
Canvas(modifier = Modifier.fillMaxSize()) {
val infos = arrayOf(
Triple(
size.width * 0.25f,
size.width * 0.2f,
size.height * 0.25f
),
Triple(
size.width * 0.18f,
size.width * 0.8f,
size.height * 0.29f
),
Triple(
size.width * 0.28f,
size.width * 0.72f,
size.height * 0.76f
)
)
infos.forEachIndexed { index, triple ->
val (radius, centerX, centerY) = triple
drawCustomPath(rotate, radius, centerX, centerY,
colors[index], periods[index])
}
}
}
}
private fun DrawScope.drawCustomPath(
rotate: Float,
radius: Float,
centerX: Float,
centerY: Float,
color: Color,
period: Int
) {
val path = Path().apply {
for (i in 0 until 360 step 5) {
//角度转化为弧度
val rad = i * PI.toFloat() / 180f
//正弦函数,period 周期
//完整的0-2π,出现period个周期, period越大,波形越瘦,
//0.2振幅,越大 波形越高
val wave = 0.2f * sin(rotate * PI.toFloat() * 2f + rad * period)
val adjustedRadius = radius * (1f + wave)
val x = centerX + adjustedRadius * cos(rad)
val y = centerY + adjustedRadius * sin(rad)
if (i == 0) {
moveTo(x, y)
} else {
lineTo(x, y)
}
}
close()
}
drawPath(path, color = color.copy(alpha = 0.4f))
}
标题动画 ToDoListTitle
标题从上往下移动

ini
@Composable
private fun ToDoListTitle() {
val d = LocalDensity.current
val titleY = remember {
with(d) {
50.dp.toPx()
}
}
//Animatable控制动画
val animate = remember { Animatable(-titleY) }
LaunchedEffect(Unit) {
delay(200)
animate.animateTo(
0f,
animationSpec = spring(dampingRatio = 0.28f, stiffness = 300f),
)
}
val date = remember { LocalDate.now() }
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp)) {
Text(
buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(date.monthValue.toString())
}
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.tertiary)) {
append(".")
}
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.tertiary)) {
append(date.dayOfMonth.toString())
}
},
color = Color.Gray.copy(alpha = 0.8f),
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.graphicsLayer {
translationY = animate.value
}
)
Spacer(Modifier.height(5.dp))
Text(
"Today",
fontSize = 28.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.graphicsLayer {
translationY = animate.value
})
}
}
列表动画ToDoList
AnimatedVisibility 控制动画出现消失
使用list存储当前item是否显示和隐藏
ini
@Composable
private fun ToDoList() {
val toDoViewModel = koinViewModel<ToDoViewModel>()
val lists by toDoViewModel.todoList.collectAsStateWithLifecycle()
val animatedIndices = toDoViewModel.animatedIndices
LaunchedEffect(lists) {
for ((index, model) in lists.withIndex()) {
if (!animatedIndices.contains(model.id)) {
delay(50 * index.toLong())
animatedIndices.add(model.id)
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn {
itemsIndexed(lists, key = { index, model -> model.id }) { index, model ->
val animate by remember(index) {
derivedStateOf { animatedIndices.contains(model.id) }
}
AnimatedVisibility(
visible = animate,
enter =
slideInVertically(
initialOffsetY = { -it },
animationSpec = tween(
durationMillis = 300,
delayMillis = 100,
easing = LinearEasing
)
) + fadeIn(tween(300, delayMillis = 100, easing = LinearEasing)),
exit = slideOutVertically(
targetOffsetY = { -it/2 },
animationSpec = tween(
durationMillis = 300,
easing = LinearEasing
)
) + fadeOut(tween(300, easing = LinearEasing))
) {
ListItemView(
model.title, model.content, model.color,
modifier = Modifier
.graphicsLayer {
// translationY = offsetY
})
}
}
}
Column(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(
modifier = Modifier
.clip(CircleShape)
.background(color = Color.Gray),
onClick = {
toDoViewModel.remove()
}) {
Icon(
imageVector = Icons.Default.Remove,
tint = Color.White,
contentDescription = "Remove"
)
}
IconButton(
modifier = Modifier
.clip(CircleShape)
.background(color = Color.Gray),
onClick = {
toDoViewModel.add()
}) {
Icon(
imageVector = Icons.Default.Add,
tint = Color.White,
contentDescription = "Add"
)
}
}
}
}
@Composable
private fun ListItemView(
title: String,
content: String,
color: Color = Color.Gray,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.padding(horizontal = 20.dp)
.padding(top = 10.dp)
.clip(RoundedCornerShape(10.dp))
.graphicsLayer {
// shadowElevation = 8.dp.toPx()
// shape = RoundedCornerShape(8.dp)
// clip = false
// ambientShadowColor = Color(0x80000000)
// spotShadowColor = Color(0x80000000)
}
.fillMaxWidth()
.background(color)
.padding(horizontal = 10.dp, vertical = 15.dp),
verticalArrangement = Arrangement.Center
) {
Column {
Text(title, fontSize = 14.sp)
Spacer(Modifier.height(5.dp))
Text(content, fontSize = 12.sp)
}
}
}
ToDoViewModel
kotlin
class ToDoViewModel : ViewModel() {
private val _todoList = MutableStateFlow(emptyList<ToDoModel>())
val todoList: StateFlow<List<ToDoModel>>
get() = _todoList
val animatedIndices = mutableStateSetOf<String>()
init {
_todoList.value = arrayListOf(
ToDoModel(uuid(), "洗衣服", "好多衣服1", Color.Gray.copy(alpha = 0.8f)),
ToDoModel(uuid(), "洗衣服", "好多衣服2", Color.Green.copy(alpha = 0.8f)),
ToDoModel(uuid(), "洗衣服", "好多衣服3", Color.Magenta.copy(alpha = 0.8f)),
ToDoModel(uuid(), "洗衣服", "好多衣服4", Color.Green),
)
}
//删除时,先使 item 消失,在 刷新lazyColumn
fun remove(index: Int = 0) {
viewModelScope.launch {
val list = _todoList.value.toMutableList()
if (list.isNotEmpty()) {
val id = list.removeAt(index).id
animatedIndices.remove(id)
delay(200)
_todoList.update {
list
}
}
}
}
fun add() {
_todoList.update {
it + ToDoModel(
uuid(),
"洗衣服",
"好多衣服1",
arrayListOf(Color.Green, Color.Magenta, Color.Gray).random()
)
}
}
}
fun uuid(): String {
return UUID.randomUUID().toString().take(8)
}
水波纹动画
scss
@Composable
private fun MountainsRiversView() {
var shanVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(800)
shanVisible = true
}
AnimatedVisibility(
visible = shanVisible,
enter = slideInVertically(
initialOffsetY = { -it - it / 2 }
) + slideInHorizontally(
initialOffsetX = { it + it / 2 }
) + fadeIn() + scaleIn(initialScale = 0.5f)
) {
EnhancedWaterRippleWidget()
}
}
EnhancedWaterRippleWidget
ini
@Composable
fun EnhancedWaterRippleWidget() {
SideEffect {
val runtime = Runtime.getRuntime()
val usedMem = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)
"Memory ,Used memory: ${usedMem}MB".loge()
}
val infiniteTransition = rememberInfiniteTransition()
val (phase1, phase2) = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 2f * PI.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(1600, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
) to infiniteTransition.animateFloat(
initialValue = PI.toFloat(),
targetValue = 3f * PI.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(1600, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
val (alpha, alphaFlash) = infiniteTransition.animateFloat(
initialValue = 0.2f,
targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
) to infiniteTransition.animateFloat(
initialValue = 0.1f,
targetValue = 0.6f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
)
)
val animatable = remember { Animatable(0f) }
LaunchedEffect(Unit) {
animatable.animateTo(
targetValue = 1f,
animationSpec = tween(800, delayMillis = 400, easing = FastOutSlowInEasing)
)
}
val cloudDx by infiniteTransition.animateValue(
initialValue = 108.dp,
targetValue = (-8).dp,
animationSpec = infiniteRepeatable(
animation = tween(8000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
typeConverter = Dp.VectorConverter,
label = "cloudDx"
)
SideEffect {
"重组--- Ripple".loge()
}
Box(
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(CircleShape)
.background(Color.Blue)
.clipToBounds()
.drawWithCache {
onDrawWithContent {
//绘制水波纹
waterDraw(phase1 = { phase1.value }, phase2 = { phase2.value })
//绘制云朵
cloudDraw(dx = { cloudDx })
//绘制闪电
drawFlash(alpha = { alphaFlash.value })
//绘制太阳
val x = { animatable.value * 35.dp.toPx() }
val y = { animatable.value * 20.dp.toPx() }
glowingSun(x = x, y = y, alpha = { alpha.value })
}
}
)
}
水波纹 waterDraw
scss
fun DrawScope.waterDraw(
path1: Path = Path(),
path2: Path = Path(),
phase1: () -> Float,
phase2: () -> Float
) {
val waveWidth = size.width
val baseHeight = size.height * 0.68f
val amplitude = size.height * 0.1f
// 主波纹(红色)
path1.apply {
reset()
moveTo(0f, baseHeight)
for (x in 0..waveWidth.toInt() step 5) {
val ratio = x.toFloat() / waveWidth
val y = amplitude * 0.4f * sin(phase1() + ratio * 2f * PI.toFloat())
lineTo(x.toFloat(), baseHeight - y)
}
lineTo(waveWidth, size.height)
lineTo(0f, size.height)
close()
}
// 次波纹(青色,半透明)
path2.apply {
reset()
moveTo(0f, baseHeight)
for (x in 0..waveWidth.toInt() step 5) {
val ratio = x.toFloat() / waveWidth
val y = amplitude * 0.4f * sin(phase2() + ratio * 2.2f * PI.toFloat()) // 不同频率
lineTo(x.toFloat(), baseHeight - y)
}
lineTo(waveWidth, size.height)
lineTo(0f, size.height)
close()
}
// 绘制双波纹(后绘制的在上层)
drawPath(
path = path1,
color = Color(0xFFE53935), // 红色
)
drawPath(
path = path2,
color = Color(0xFF4DD0E1), // 青色
)
// 添加波光粼粼效果(可选)
for (i in 0..3) {
val sparkleX = waveWidth * ((phase1() / (2f * PI) + i * 0.25f) % 1f)
if (sparkleX in 0f..waveWidth) {
val sparkleY = baseHeight - amplitude * 0.4f *
sin(phase1() + sparkleX / waveWidth * 4f * PI.toFloat())
drawCircle(
color = Color.White.copy(alpha = 0.8f),
radius = 2.dp.toPx(),
center = Offset(sparkleX.toFloat(), sparkleY.toFloat())
)
}
}
}
绘制太阳
ini
fun DrawScope.glowingSun(x: () -> Float, y: () -> Float, alpha: () -> Float) {
val center = Offset(x(), y())
val baseRadius = 10.dp.toPx()
// 在Canvas中添加光芒射线
with(drawContext.canvas) {
val spikeCount = 12
repeat(spikeCount) { i ->
val angle = i * (360f / spikeCount)
val spikeLength = baseRadius + 2.dp.toPx()
val endX = center.x + spikeLength * cos(angle * PI / 180).toFloat()
val endY = center.y + spikeLength * sin(angle * PI / 180).toFloat()
drawLine(
start = center,
end = Offset(endX, endY),
color = Color.Red,
strokeWidth = 1.dp.toPx(),
colorFilter = ColorFilter.tint(
color = Color.Yellow.copy(alpha = 0.6f),
blendMode = BlendMode.Screen
)
)
}
}
drawCircle(
color = Color.Red, // 橙黄色
radius = baseRadius,
center = center,
)
// 4. 核心太阳(纯色不透明)
drawCircle(
color = Color.Red, // 橙黄色
radius = baseRadius,
center = center,
colorFilter = ColorFilter.tint(
// color = Color.Yellow.copy(alpha = alpha()),
color = Color.Yellow.copy(alpha = 0.6f),
blendMode = BlendMode.SrcOver
)
)
// 5. 高光点(可选)
drawCircle(
color = Color.White,
radius = baseRadius * 0.2f,
center = Offset(
center.x + baseRadius * 0.3f,
center.y - baseRadius * 0.3f
),
colorFilter = ColorFilter.tint(
color = Color.White.copy(alpha = 0.6f),
blendMode = BlendMode.Plus
)
)
}
绘制云朵
scss
/**
* 绘制云朵
*/
fun DrawScope.cloudDraw(
path: Path = Path(),
path2: Path = Path(),
isRainLine: Boolean = true,
dx: () -> Dp
) {
// val center = Offset(78.dp.toPx(), 20.dp.toPx())
val center = Offset(dx().toPx(), 20.dp.toPx())
val radius = 4.dp.toPx()
path.apply {
reset()
moveTo(center.x, center.y)
addArc(
Rect(
offset = Offset(center.x - radius, center.y - radius),
size = Size(radius * 2f, radius * 2f)
),
-180f, 180f
)
moveTo(center.x + radius, center.y + radius)
addArc(
Rect(
offset = Offset(center.x, center.y),
size = Size(radius * 2f, radius * 2f)
), -90f, 180f
)
moveTo(center.x + radius, center.y + radius * 2)
lineTo(center.x - radius, center.y + 2 * radius)
moveTo(center.x - radius, center.y + radius)
addArc(
Rect(
offset = Offset(center.x - radius * 2, center.y),
size = Size(radius * 2f, radius * 2f)
), 90f, 180f
)
addRect(
Rect(
offset = Offset(center.x - radius, center.y),
size = Size(radius * 2f, radius * 2f)
)
)
//绘制底部线条
if (isRainLine) {
val lineCenter = Offset(center.x, center.y + 2 * radius)
val xLineSpace = radius / 2
val xSpace = 0.8f * radius * cos(Math.toRadians(60.0)).toFloat()
val ySpace = 0.8f * radius * sin(Math.toRadians(60.0)).toFloat()
path2.apply {
reset()
for (i in -1..1) {
moveTo(lineCenter.x + (i * xLineSpace), lineCenter.y)
lineTo(lineCenter.x + (i * xLineSpace) - xSpace, lineCenter.y + ySpace)
}
}
drawPath(
path2,
color = Color.White.copy(alpha = 0.4f),
style = Stroke(width = 2f)
)
}
}
drawPath(path, color = Color.White.copy(alpha = 0.4f))
}
绘制闪电
scss
/**
* 绘制闪电
*/
fun DrawScope.drawFlash(path: Path = Path(), alpha: () -> Float) {
// val x = (10..(size.width - 10f).toInt()).random().toFloat()
// val y = (10..(size.height / 2).toInt()).random().toFloat()
// val x = if(alpha()>0.32) 40.dp.toPx() else 70.dp.toPx()
// val y = if(alpha()>0.32) 40.dp.toPx() else 32.dp.toPx()
val x = 70.dp.toPx()
val y = 28.dp.toPx()
val center = Offset(x, y)
path.apply {
reset()
moveTo(center.x, center.y)
lineTo(center.x, center.y + 5.dp.toPx())
lineTo(center.x + 1.2.dp.toPx(), center.y + 5.dp.toPx())
lineTo(center.x + 1.2.dp.toPx(), center.y + 10.dp.toPx())
lineTo(center.x + 5.dp.toPx(), center.y + 4.dp.toPx())
lineTo(center.x + 5.dp.toPx(), center.y + 4.dp.toPx())
lineTo(center.x + 4.dp.toPx(), center.y + 4.dp.toPx())
lineTo(center.x + 5.dp.toPx(), center.y)
close()
}
drawPath(path, color = Color.White.copy(alpha = alpha()))
}