diff options
| -rw-r--r-- | packages/SystemUI/compose/core/src/com/android/compose/animation/Easings.kt | 63 | ||||
| -rw-r--r-- | packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt | 85 |
2 files changed, 133 insertions, 15 deletions
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/Easings.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/Easings.kt new file mode 100644 index 000000000000..8fe1f48dcaee --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/Easings.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.compose.animation + +import androidx.compose.animation.core.Easing +import androidx.core.animation.Interpolator +import com.android.app.animation.InterpolatorsAndroidX + +/** + * Compose-compatible definition of Android motion eases, see + * https://carbon.googleplex.com/android-motion/pages/easing + */ +object Easings { + + /** The standard interpolator that should be used on every normal animation */ + val StandardEasing = fromInterpolator(InterpolatorsAndroidX.STANDARD) + + /** + * The standard accelerating interpolator that should be used on every regular movement of + * content that is disappearing e.g. when moving off screen. + */ + val StandardAccelerateEasing = fromInterpolator(InterpolatorsAndroidX.STANDARD_ACCELERATE) + + /** + * The standard decelerating interpolator that should be used on every regular movement of + * content that is appearing e.g. when coming from off screen. + */ + val StandardDecelerateEasing = fromInterpolator(InterpolatorsAndroidX.STANDARD_DECELERATE) + + /** The default emphasized interpolator. Used for hero / emphasized movement of content. */ + val EmphasizedEasing = fromInterpolator(InterpolatorsAndroidX.EMPHASIZED) + + /** + * The accelerated emphasized interpolator. Used for hero / emphasized movement of content that + * is disappearing e.g. when moving off screen. + */ + val EmphasizedAccelerateEasing = fromInterpolator(InterpolatorsAndroidX.EMPHASIZED_ACCELERATE) + + /** + * The decelerating emphasized interpolator. Used for hero / emphasized movement of content that + * is appearing e.g. when coming from off screen + */ + val EmphasizedDecelerateEasing = fromInterpolator(InterpolatorsAndroidX.EMPHASIZED_DECELERATE) + + /** The linear interpolator. */ + val LinearEasing = fromInterpolator(InterpolatorsAndroidX.LINEAR) + + private fun fromInterpolator(source: Interpolator) = Easing { x -> source.getInterpolation(x) } +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt index cbd7b8806a77..2ca78b1ad195 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt @@ -18,11 +18,15 @@ package com.android.systemui.bouncer.ui.composable +import android.view.HapticFeedbackConstants import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -48,6 +52,7 @@ import androidx.compose.runtime.collectAsState 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.Alignment import androidx.compose.ui.Modifier @@ -55,8 +60,10 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.android.compose.animation.Easings import com.android.compose.grid.VerticalGrid import com.android.systemui.R import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel @@ -65,6 +72,11 @@ import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.ui.compose.Icon import com.android.systemui.compose.modifiers.thenIf import kotlin.math.max +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable internal fun PinBouncer( @@ -128,7 +140,7 @@ internal fun PinBouncer( onClicked = { viewModel.onBackspaceButtonClicked() }, onLongPressed = { viewModel.onBackspaceButtonLongPressed() }, isEnabled = isInputEnabled, - isHighlighted = true, + isIconButton = true, ) { contentColor -> PinIcon( Icon.Resource( @@ -149,8 +161,8 @@ internal fun PinBouncer( PinButton( onClicked = { viewModel.onAuthenticateButtonClicked() }, - isHighlighted = true, isEnabled = isInputEnabled, + isIconButton = true, ) { contentColor -> PinIcon( Icon.Resource( @@ -196,39 +208,65 @@ private fun PinButton( isEnabled: Boolean, modifier: Modifier = Modifier, onLongPressed: (() -> Unit)? = null, - isHighlighted: Boolean = false, + isIconButton: Boolean = false, content: @Composable (contentColor: Color) -> Unit, ) { var isPressed: Boolean by remember { mutableStateOf(false) } + + val view = LocalView.current + LaunchedEffect(isPressed) { + if (isPressed) { + view.performHapticFeedback( + HapticFeedbackConstants.VIRTUAL_KEY, + HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING, + ) + } + } + + // Pin button animation specification is asymmetric: fast animation to the pressed state, and a + // slow animation upon release. Note that isPressed is guaranteed to be true for at least the + // press animation duration (see below in detectTapGestures). + val animEasing = if (isPressed) pinButtonPressedEasing else pinButtonReleasedEasing + val animDurationMillis = + (if (isPressed) pinButtonPressedDuration else pinButtonReleasedDuration).toInt( + DurationUnit.MILLISECONDS + ) + val cornerRadius: Dp by animateDpAsState( - if (isPressed) 24.dp else PinButtonSize / 2, + if (isPressed) 24.dp else pinButtonSize / 2, label = "PinButton round corners", + animationSpec = tween(animDurationMillis, easing = animEasing) ) + val colorAnimationSpec: AnimationSpec<Color> = tween(animDurationMillis, easing = animEasing) val containerColor: Color by animateColorAsState( when { - isPressed -> MaterialTheme.colorScheme.primaryContainer - isHighlighted -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.surface + isPressed -> MaterialTheme.colorScheme.primary + isIconButton -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant }, label = "Pin button container color", + animationSpec = colorAnimationSpec ) val contentColor: Color by animateColorAsState( when { - isPressed -> MaterialTheme.colorScheme.onPrimaryContainer - isHighlighted -> MaterialTheme.colorScheme.onSecondaryContainer - else -> MaterialTheme.colorScheme.onSurface + isPressed -> MaterialTheme.colorScheme.onPrimary + isIconButton -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant }, label = "Pin button container color", + animationSpec = colorAnimationSpec ) + val scope = rememberCoroutineScope() + Box( contentAlignment = Alignment.Center, modifier = modifier - .size(PinButtonSize) + .size(pinButtonSize) .drawBehind { drawRoundRect( color = containerColor, @@ -239,9 +277,15 @@ private fun PinButton( Modifier.pointerInput(Unit) { detectTapGestures( onPress = { - isPressed = true - tryAwaitRelease() - isPressed = false + scope.launch { + isPressed = true + val minDuration = async { + delay(pinButtonPressedDuration + pinButtonHoldTime) + } + tryAwaitRelease() + minDuration.await() + isPressed = false + } }, onTap = { onClicked() }, onLongPress = onLongPressed?.let { { onLongPressed() } }, @@ -253,4 +297,15 @@ private fun PinButton( } } -private val PinButtonSize = 84.dp +private fun showFailureAnimation() { + // TODO(b/282730134): implement. +} + +private val pinButtonSize = 84.dp + +// Pin button motion spec: http://shortn/_9TTIG6SoEa +private val pinButtonPressedDuration = 100.milliseconds +private val pinButtonPressedEasing = LinearEasing +private val pinButtonHoldTime = 33.milliseconds +private val pinButtonReleasedDuration = 420.milliseconds +private val pinButtonReleasedEasing = Easings.StandardEasing |