[flexiglass] Pin bouncer UX polish.

This aligns the implementation closer to the go/android-u-bouncer-motion spec

- adds haptic feedback on pin touch
- updates colors to better match spec (but not all color tokens are available in code yet)
- update animation easing and durations to match spec
- add a hold time to short button presses.

Bug: 282730134
Test: Manual verification. Please see video capture attached to b/282730134.

Change-Id: Icdf24c17666fb85b0e7c7d28f1d46cb903cfe54c
This commit is contained in:
Mike Schneider
2023-05-17 08:01:41 +02:00
committed by Alejandro Nijamkin
parent 4e9af5b759
commit 2515b7efaf
2 changed files with 133 additions and 15 deletions

View File

@@ -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) }
}

View File

@@ -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