实战案例1:登录页面的动画设计
登录页面,几乎是每个App都有的标配。但说实话,很多登录页做得太「硬」了——点一下按钮直接跳转,输入框切换毫无过渡。用户感受不到任何「手感」。
我个人习惯,在做一个新项目时,会先把登录页的动画打磨好。为什么呢?因为这是用户接触App的第一道门。门推起来顺不顺,直接影响第一印象。
今天我们就用Compose动画,把一个平平无奇的登录页面,做出「丝滑感」。我会从输入框、按钮、转场三个维度来拆解。
一、整体动画设计思路
先看下我们要实现的效果:
- 页面加载时,Logo从上方淡入并下移
- 两个输入框依次从左侧滑入
- 登录按钮在输入框填完后「呼吸」起来
- 点击登录,整个页面做转场动画进入主页
说白了,就是让每个UI元素「活」过来。不是一下子全出现,而是有节奏地登场。
AnimatedVisibility 控制入场顺序,用 animateFloatAsState 控制按钮状态,用 AnimatedContent 做页面转场。
下面这张图,是我梳理的动画流程结构:
二、输入框的入场动画
先看输入框。我见过很多实现,直接把两个输入框同时丢出来。嗯,这样其实有点「糙」。用户眼睛会不知道该看哪里。
更好的做法是:让它们依次出现。第一个输入框滑入后,第二个再跟上。就像舞台上的演员,一个一个登场。
// 用 AnimatedVisibility 控制入场顺序
var showUsername by remember { mutableStateOf(false) }
var showPassword by remember { mutableStateOf(false) }
// 启动入场动画
LaunchedEffect(Unit) {
showUsername = true
delay(300) // 第一个输入框出现后等300ms
showPassword = true
}
// 用户名输入框
AnimatedVisibility(
visible = showUsername,
enter = slideInHorizontally(
initialOffsetX = { -it }, // 从左侧滑入
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(300))
) {
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)
)
}
// 密码输入框
AnimatedVisibility(
visible = showPassword,
enter = slideInHorizontally(
initialOffsetX = { -it },
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(300))
) {
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)
)
}
slideInHorizontally 和 fadeIn 组合使用。光滑入会显得有点「愣」,加上淡入就柔和多了。你试试看,效果完全不一样。
三、按钮的呼吸动画
按钮这块,我想重点说说。很多登录页的按钮是「死」的——你填不填信息,它都一个样。但好的交互应该给用户反馈。
我的做法是:当两个输入框都填了内容,按钮开始「呼吸」——就是轻微地放大缩小,像是在说「点我呀」。这个效果用 animateFloatAsState 就能搞定。
// 按钮呼吸动画
val isFormFilled = username.isNotBlank() && password.isNotBlank()
// 呼吸动画:在 1.0 和 1.05 之间来回缩放
val breathScale by animateFloatAsState(
targetValue = if (isFormFilled) {
// 如果表单已填,开始呼吸
if (isBreathing) 1.05f else 1.0f
} else {
1.0f // 没填就保持原样
},
animationSpec = tween(600, easing = EaseInOutCubic)
)
// 启动呼吸循环
LaunchedEffect(isFormFilled) {
if (isFormFilled) {
while (true) {
isBreathing = true
delay(600)
isBreathing = false
delay(600)
}
}
}
Button(
onClick = { /* 登录逻辑 */ },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.graphicsLayer {
scaleX = breathScale
scaleY = breathScale
},
enabled = isFormFilled
) {
Text("登录")
}
LaunchedEffect 配合 while(true) 实现。但别忘了在 isFormFilled 为 false 时退出循环,否则会浪费性能。我曾经见过一个项目,退出登录页后呼吸动画还在跑,CPU占用直接飙上去。
四、页面转场动画
最后是转场。点击登录按钮后,整个页面切换到主页。这里我用的是 AnimatedContent,它比 AnimatedVisibility 更适合做页面级别的切换。
// 用 AnimatedContent 做页面转场
var currentPage by remember { mutableStateOf("login") }
AnimatedContent(
targetState = currentPage,
transitionSpec = {
// 登录页滑出,主页滑入
if (targetState == "home") {
slideOutHorizontally(
targetOffsetX = { -it },
animationSpec = tween(400)
) togetherWith slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(400)
)
} else {
// 返回时反向滑动
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(400)
) togetherWith slideInHorizontally(
initialOffsetX = { -it },
animationSpec = tween(400)
)
}
}
) { page ->
when (page) {
"login" -> LoginScreen(
onLoginSuccess = { currentPage = "home" }
)
"home" -> HomeScreen(
onLogout = { currentPage = "login" }
)
}
}
这里有个细节:slideOutHorizontally 和 slideInHorizontally 的方向要相反。登录页向左滑出,主页就从右侧滑入。这样看起来才自然,像是「推开了一扇门」。
五、完整代码整合
把上面三块拼起来,就是一个完整的登录页面了。我建议你把它们放在一个 LoginScreen 的 Composable 里,用 Column 布局。
@Composable
fun LoginScreen(onLoginSuccess: () -> Unit) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showUsername by remember { mutableStateOf(false) }
var showPassword by remember { mutableStateOf(false) }
var isBreathing by remember { mutableStateOf(false) }
// 入场动画
LaunchedEffect(Unit) {
showUsername = true
delay(300)
showPassword = true
}
// 呼吸动画
val isFormFilled = username.isNotBlank() && password.isNotBlank()
val breathScale by animateFloatAsState(
targetValue = if (isFormFilled && isBreathing) 1.05f else 1.0f,
animationSpec = tween(600)
)
LaunchedEffect(isFormFilled) {
if (isFormFilled) {
while (true) {
isBreathing = true
delay(600)
isBreathing = false
delay(600)
}
}
}
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Logo(略,可自行添加淡入动画)
Spacer(modifier = Modifier.height(48.dp))
// 用户名输入框
AnimatedVisibility(
visible = showUsername,
enter = slideInHorizontally { -it } + fadeIn()
) {
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(16.dp))
// 密码输入框
AnimatedVisibility(
visible = showPassword,
enter = slideInHorizontally { -it } + fadeIn()
) {
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(32.dp))
// 登录按钮
Button(
onClick = onLoginSuccess,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = breathScale; scaleY = breathScale },
enabled = isFormFilled
) {
Text("登录")
}
}
}
六、避坑指南
做这个案例时,我踩过几个坑,分享给你:
- 动画时序别太紧凑:我曾经把两个输入框的入场间隔设成100ms,结果看起来就像同时出现。300ms是个比较舒服的间隔。
- 呼吸动画别太夸张:缩放比例1.05倍就够了。有次我设成1.2倍,按钮看起来像在「跳动」,用户反馈说有点晕。
- 转场动画要双向:登录到主页是一个方向,返回时应该是反向。否则用户会觉得「怎么回去的感觉不一样」。
- 别忘了性能:动画过程中避免做耗时操作。我习惯把网络请求放在动画结束后再发起,这样UI不会卡顿。
好了,这个案例就讲到这里。代码可以直接拿去用,但建议你根据自己的设计风格调整参数。动画这东西,多调几次才能找到最顺眼的感觉。