优化应用锁交互动画

This commit is contained in:
2026-06-17 10:57:23 +08:00
parent be613749c4
commit f33247f732
5 changed files with 79 additions and 21 deletions
@@ -7,6 +7,11 @@ import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -84,7 +89,23 @@ class MainActivity : FragmentActivity() {
dynamicColor = dynamicColor,
seedColor = seedColor
) {
if (isLocked) {
val animDuration = 300
AnimatedContent(
targetState = isLocked,
transitionSpec = {
if (targetState) {
// Lock screen fades in (app fades out)
(fadeIn(animationSpec = tween(animDuration)))
.togetherWith(fadeOut(animationSpec = tween(animDuration)))
} else {
// App fades in (lock screen fades out)
(fadeIn(animationSpec = tween(animDuration)))
.togetherWith(fadeOut(animationSpec = tween(animDuration)))
}
},
label = "lock_transition"
) { locked ->
if (locked) {
LockScreen(
onUnlock = { appLockManager.unlock() },
modifier = Modifier.fillMaxSize()
@@ -96,3 +117,4 @@ class MainActivity : FragmentActivity() {
}
}
}
}
@@ -4,16 +4,19 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cn.airnan.a2fair.R
import cn.airnan.a2fair.A2FairApplication
import cn.airnan.a2fair.core.security.PIN_LENGTH
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
@@ -37,6 +40,7 @@ fun LockScreen(
var enteredPin by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isSuccess by remember { mutableStateOf(false) }
val incorrectPinMsg = stringResource(R.string.incorrect_pin)
val pinHintMsg = stringResource(R.string.pin_digits_hint)
var remainingLockOutTime by remember { mutableStateOf(0L) }
@@ -78,11 +82,13 @@ fun LockScreen(
fun submitPin(candidate: String) {
if (pinCode != null && candidate == pinCode) {
isSuccess = true
scope.launch {
settingsRepo.setFailedAttempts(0)
settingsRepo.setLockoutEndTime(0L)
}
delay(450L)
onUnlock()
}
} else {
val newAttempts = failedAttempts + 1
errorMessage = incorrectPinMsg
@@ -106,10 +112,10 @@ fun LockScreen(
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Lock,
imageVector = if (isSuccess) Icons.Default.LockOpen else Icons.Default.Lock,
contentDescription = stringResource(R.string.lock_icon),
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
tint = if (isSuccess) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(32.dp))
@@ -122,6 +128,7 @@ fun LockScreen(
PinIndicatorRow(
filledCount = enteredPin.length,
isError = errorMessage != null || remainingLockOutTime > 0L,
isSuccess = isSuccess,
modifier = Modifier.fillMaxWidth(0.8f)
)
Spacer(modifier = Modifier.height(12.dp))
@@ -146,7 +153,7 @@ fun LockScreen(
resetLabel = stringResource(R.string.clear_pin_input),
deleteLabel = stringResource(R.string.delete_digit),
onDigit = { digit ->
if (remainingLockOutTime > 0L || enteredPin.length >= PIN_LENGTH) {
if (remainingLockOutTime > 0L || isSuccess || enteredPin.length >= PIN_LENGTH) {
return@PinNumberPad
}
val updatedPin = enteredPin + digit
@@ -2,6 +2,7 @@ package cn.airnan.a2fair.ui.settings.security
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -23,21 +24,24 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import cn.airnan.a2fair.core.security.PIN_LENGTH
import kotlinx.coroutines.delay
@Composable
fun PinIndicatorRow(
filledCount: Int,
modifier: Modifier = Modifier,
isError: Boolean = false
isError: Boolean = false,
isSuccess: Boolean = false
) {
val activeColor = if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
val activeColor = when {
isSuccess -> Color(0xFF4CAF50)
isError -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
val inactiveColor = if (isError) {
MaterialTheme.colorScheme.error.copy(alpha = 0.24f)
@@ -60,13 +64,22 @@ fun PinIndicatorRow(
}
}
// Success sweep: each dot scales up in sequence
LaunchedEffect(isSuccess) {
if (isSuccess) {
delay(index * 50L)
scale.snapTo(1.4f)
scale.animateTo(1f, tween(120))
}
}
Box(
modifier = Modifier
.padding(horizontal = 6.dp)
.size(14.dp)
.scale(scale.value)
.clip(CircleShape)
.background(if (isFilled) activeColor else inactiveColor)
.background(if (isFilled || isSuccess) activeColor else inactiveColor)
)
}
}
@@ -13,6 +13,7 @@ import androidx.compose.ui.unit.dp
import cn.airnan.a2fair.R
import cn.airnan.a2fair.core.security.PIN_LENGTH
import cn.airnan.a2fair.data.repository.SettingsRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -26,6 +27,7 @@ fun PinSetupScreen(
var pin by remember { mutableStateOf("") }
var confirmPin by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isSuccess by remember { mutableStateOf(false) }
val pinTooShortMsg = stringResource(R.string.pin_too_short)
val pinsDoNotMatchMsg = stringResource(R.string.pins_do_not_match)
@@ -71,7 +73,8 @@ fun PinSetupScreen(
PinIndicatorRow(
filledCount = currentPin.length,
isError = errorMessage != null
isError = errorMessage != null,
isSuccess = isSuccess
)
Spacer(modifier = Modifier.height(12.dp))
@@ -94,7 +97,7 @@ fun PinSetupScreen(
},
deleteLabel = stringResource(R.string.delete_digit),
onDigit = { digit ->
if (currentPin.length >= PIN_LENGTH) return@PinNumberPad
if (isSuccess || currentPin.length >= PIN_LENGTH) return@PinNumberPad
if (step == 1) {
pin += digit
} else {
@@ -143,8 +146,10 @@ fun PinSetupScreen(
if (confirmPin.length != PIN_LENGTH) {
errorMessage = pinTooShortMsg
} else if (pin == confirmPin) {
isSuccess = true
scope.launch {
settingsRepository.enablePin(pin)
delay(450L)
onNavigateBack()
}
} else {
@@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -21,6 +22,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import cn.airnan.a2fair.R
import cn.airnan.a2fair.core.security.PIN_LENGTH
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun SecurityVerificationDialog(
@@ -31,11 +34,17 @@ fun SecurityVerificationDialog(
) {
var enteredPin by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isSuccess by remember { mutableStateOf(false) }
val incorrectPinMessage = stringResource(R.string.incorrect_pin)
val scope = rememberCoroutineScope()
fun submitPin(candidate: String) {
if (!expectedPin.isNullOrEmpty() && candidate == expectedPin) {
isSuccess = true
scope.launch {
delay(450L)
onVerified()
}
} else {
enteredPin = ""
errorMessage = incorrectPinMessage
@@ -61,6 +70,7 @@ fun SecurityVerificationDialog(
PinIndicatorRow(
filledCount = enteredPin.length,
isError = errorMessage != null,
isSuccess = isSuccess,
modifier = Modifier.fillMaxWidth()
)
@@ -82,7 +92,7 @@ fun SecurityVerificationDialog(
resetLabel = stringResource(R.string.clear_pin_input),
deleteLabel = stringResource(R.string.delete_digit),
onDigit = { digit ->
if (enteredPin.length >= PIN_LENGTH) return@PinNumberPad
if (isSuccess || enteredPin.length >= PIN_LENGTH) return@PinNumberPad
val updatedPin = enteredPin + digit
enteredPin = updatedPin
errorMessage = null
@@ -105,6 +115,7 @@ fun SecurityVerificationDialog(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
isEnabled = !isSuccess,
isDeleteEnabled = enteredPin.isNotEmpty(),
isResetEnabled = enteredPin.isNotEmpty()
)