Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 2515b7ef authored by Mike Schneider's avatar Mike Schneider Committed by Alejandro Nijamkin
Browse files

[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
parent 4e9af5b7
Loading
Loading
Loading
Loading
+63 −0
Original line number Diff line number Diff line
/*
 * 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) }
}
+70 −15
Original line number Diff line number Diff line
@@ -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 = {
                                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