[flexiglass] Pin bouncer UX polish. am: 2515b7efaf
Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/23284679 Change-Id: I0854f083bb0101f428813dfd7895f74e7847208d Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
@@ -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
|
package com.android.systemui.bouncer.ui.composable
|
||||||
|
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.animateContentSize
|
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.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
@@ -48,6 +52,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.geometry.CornerRadius
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
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 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.compose.grid.VerticalGrid
|
||||||
import com.android.systemui.R
|
import com.android.systemui.R
|
||||||
import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
|
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.common.ui.compose.Icon
|
||||||
import com.android.systemui.compose.modifiers.thenIf
|
import com.android.systemui.compose.modifiers.thenIf
|
||||||
import kotlin.math.max
|
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
|
@Composable
|
||||||
internal fun PinBouncer(
|
internal fun PinBouncer(
|
||||||
@@ -128,7 +140,7 @@ internal fun PinBouncer(
|
|||||||
onClicked = { viewModel.onBackspaceButtonClicked() },
|
onClicked = { viewModel.onBackspaceButtonClicked() },
|
||||||
onLongPressed = { viewModel.onBackspaceButtonLongPressed() },
|
onLongPressed = { viewModel.onBackspaceButtonLongPressed() },
|
||||||
isEnabled = isInputEnabled,
|
isEnabled = isInputEnabled,
|
||||||
isHighlighted = true,
|
isIconButton = true,
|
||||||
) { contentColor ->
|
) { contentColor ->
|
||||||
PinIcon(
|
PinIcon(
|
||||||
Icon.Resource(
|
Icon.Resource(
|
||||||
@@ -149,8 +161,8 @@ internal fun PinBouncer(
|
|||||||
|
|
||||||
PinButton(
|
PinButton(
|
||||||
onClicked = { viewModel.onAuthenticateButtonClicked() },
|
onClicked = { viewModel.onAuthenticateButtonClicked() },
|
||||||
isHighlighted = true,
|
|
||||||
isEnabled = isInputEnabled,
|
isEnabled = isInputEnabled,
|
||||||
|
isIconButton = true,
|
||||||
) { contentColor ->
|
) { contentColor ->
|
||||||
PinIcon(
|
PinIcon(
|
||||||
Icon.Resource(
|
Icon.Resource(
|
||||||
@@ -196,39 +208,65 @@ private fun PinButton(
|
|||||||
isEnabled: Boolean,
|
isEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onLongPressed: (() -> Unit)? = null,
|
onLongPressed: (() -> Unit)? = null,
|
||||||
isHighlighted: Boolean = false,
|
isIconButton: Boolean = false,
|
||||||
content: @Composable (contentColor: Color) -> Unit,
|
content: @Composable (contentColor: Color) -> Unit,
|
||||||
) {
|
) {
|
||||||
var isPressed: Boolean by remember { mutableStateOf(false) }
|
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
|
val cornerRadius: Dp by
|
||||||
animateDpAsState(
|
animateDpAsState(
|
||||||
if (isPressed) 24.dp else PinButtonSize / 2,
|
if (isPressed) 24.dp else pinButtonSize / 2,
|
||||||
label = "PinButton round corners",
|
label = "PinButton round corners",
|
||||||
|
animationSpec = tween(animDurationMillis, easing = animEasing)
|
||||||
)
|
)
|
||||||
|
val colorAnimationSpec: AnimationSpec<Color> = tween(animDurationMillis, easing = animEasing)
|
||||||
val containerColor: Color by
|
val containerColor: Color by
|
||||||
animateColorAsState(
|
animateColorAsState(
|
||||||
when {
|
when {
|
||||||
isPressed -> MaterialTheme.colorScheme.primaryContainer
|
isPressed -> MaterialTheme.colorScheme.primary
|
||||||
isHighlighted -> MaterialTheme.colorScheme.secondaryContainer
|
isIconButton -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
else -> MaterialTheme.colorScheme.surface
|
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
},
|
},
|
||||||
label = "Pin button container color",
|
label = "Pin button container color",
|
||||||
|
animationSpec = colorAnimationSpec
|
||||||
)
|
)
|
||||||
val contentColor: Color by
|
val contentColor: Color by
|
||||||
animateColorAsState(
|
animateColorAsState(
|
||||||
when {
|
when {
|
||||||
isPressed -> MaterialTheme.colorScheme.onPrimaryContainer
|
isPressed -> MaterialTheme.colorScheme.onPrimary
|
||||||
isHighlighted -> MaterialTheme.colorScheme.onSecondaryContainer
|
isIconButton -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
},
|
},
|
||||||
label = "Pin button container color",
|
label = "Pin button container color",
|
||||||
|
animationSpec = colorAnimationSpec
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.size(PinButtonSize)
|
.size(pinButtonSize)
|
||||||
.drawBehind {
|
.drawBehind {
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = containerColor,
|
color = containerColor,
|
||||||
@@ -239,9 +277,15 @@ private fun PinButton(
|
|||||||
Modifier.pointerInput(Unit) {
|
Modifier.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onPress = {
|
onPress = {
|
||||||
isPressed = true
|
scope.launch {
|
||||||
tryAwaitRelease()
|
isPressed = true
|
||||||
isPressed = false
|
val minDuration = async {
|
||||||
|
delay(pinButtonPressedDuration + pinButtonHoldTime)
|
||||||
|
}
|
||||||
|
tryAwaitRelease()
|
||||||
|
minDuration.await()
|
||||||
|
isPressed = false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onTap = { onClicked() },
|
onTap = { onClicked() },
|
||||||
onLongPress = onLongPressed?.let { { onLongPressed() } },
|
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