实战案例1:登录页面的动画设计

登录页面,几乎是每个App都有的标配。但说实话,很多登录页做得太「硬」了——点一下按钮直接跳转,输入框切换毫无过渡。用户感受不到任何「手感」。

我个人习惯,在做一个新项目时,会先把登录页的动画打磨好。为什么呢?因为这是用户接触App的第一道门。门推起来顺不顺,直接影响第一印象。

今天我们就用Compose动画,把一个平平无奇的登录页面,做出「丝滑感」。我会从输入框、按钮、转场三个维度来拆解。

一、整体动画设计思路

先看下我们要实现的效果:

  • 页面加载时,Logo从上方淡入并下移
  • 两个输入框依次从左侧滑入
  • 登录按钮在输入框填完后「呼吸」起来
  • 点击登录,整个页面做转场动画进入主页

说白了,就是让每个UI元素「活」过来。不是一下子全出现,而是有节奏地登场。

核心知识点:AnimatedVisibility 控制入场顺序,用 animateFloatAsState 控制按钮状态,用 AnimatedContent 做页面转场。

下面这张图,是我梳理的动画流程结构:

登录页面动画流程 阶段1:页面入场 Logo淡入 + 下移 阶段2:输入框 依次从左侧滑入 阶段3:按钮 呼吸动画提示 阶段4:转场 点击后页面切换 使用 AnimatedVisibility + animateFloatAsState + AnimatedContent 0ms 300ms 600ms 点击时

二、输入框的入场动画

先看输入框。我见过很多实现,直接把两个输入框同时丢出来。嗯,这样其实有点「糙」。用户眼睛会不知道该看哪里。

更好的做法是:让它们依次出现。第一个输入框滑入后,第二个再跟上。就像舞台上的演员,一个一个登场。

// 用 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)
    )
}
小技巧: 我习惯把 slideInHorizontallyfadeIn 组合使用。光滑入会显得有点「愣」,加上淡入就柔和多了。你试试看,效果完全不一样。

三、按钮的呼吸动画

按钮这块,我想重点说说。很多登录页的按钮是「死」的——你填不填信息,它都一个样。但好的交互应该给用户反馈。

我的做法是:当两个输入框都填了内容,按钮开始「呼吸」——就是轻微地放大缩小,像是在说「点我呀」。这个效果用 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" }
        )
    }
}

这里有个细节:slideOutHorizontallyslideInHorizontally 的方向要相反。登录页向左滑出,主页就从右侧滑入。这样看起来才自然,像是「推开了一扇门」。

五、完整代码整合

把上面三块拼起来,就是一个完整的登录页面了。我建议你把它们放在一个 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不会卡顿。
总结一下: 登录页的动画设计,核心就是「节奏感」。输入框依次入场、按钮呼吸提示、页面平滑转场——每一步都让用户觉得「舒服」。你想想看,如果所有App的登录页都这么丝滑,用户还会觉得切换账号是件麻烦事吗?

好了,这个案例就讲到这里。代码可以直接拿去用,但建议你根据自己的设计风格调整参数。动画这东西,多调几次才能找到最顺眼的感觉。