[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:
committed by
Alejandro Nijamkin
parent
4e9af5b759
commit
2515b7efaf
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user