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

Commit 637fb749 authored by Ale Nijamkin's avatar Ale Nijamkin Committed by Android (Google) Code Review
Browse files

Merge changes from topic "bouncer-scene-throttling-b280877228" into udc-dev

* changes:
  [flexiglass] Failure animations for bouncer UIs.
  [flexiglass] Pattern bouncer motion UX polish.
  [flexiglass] Pin bouncer UX polish.
  [flexiglass] Fixes extraneous haptic feedback in pattern bouncer.
  [flexiglass] Bouncer throttling - composables.
  thenIf modifier.
parents 2ba4c12b 1b00a654
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) }
}
+4 −0
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@

package com.android.systemui.scene.ui.composable

import android.content.Context
import com.android.systemui.bouncer.ui.composable.BouncerScene
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.dagger.SysUISingleton
@@ -28,6 +29,7 @@ import com.android.systemui.scene.shared.model.Scene
import com.android.systemui.scene.shared.model.SceneContainerNames
import com.android.systemui.shade.ui.composable.ShadeScene
import com.android.systemui.shade.ui.viewmodel.ShadeSceneViewModel
import com.android.systemui.statusbar.phone.SystemUIDialog
import dagger.Module
import dagger.Provides
import javax.inject.Named
@@ -57,6 +59,7 @@ object SceneModule {
    @SysUISingleton
    @Named(SceneContainerNames.SYSTEM_UI_DEFAULT)
    fun bouncerScene(
        @Application context: Context,
        viewModelFactory: BouncerViewModel.Factory,
    ): BouncerScene {
        return BouncerScene(
@@ -64,6 +67,7 @@ object SceneModule {
                viewModelFactory.create(
                    containerName = SceneContainerNames.SYSTEM_UI_DEFAULT,
                ),
            dialogFactory = { SystemUIDialog(context) },
        )
    }

+42 −4
Original line number Diff line number Diff line
@@ -14,9 +14,16 @@
 * limitations under the License.
 */

@file:OptIn(ExperimentalMaterial3Api::class)

package com.android.systemui.bouncer.ui.composable

import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -26,15 +33,20 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.android.systemui.R
import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
@@ -51,6 +63,7 @@ import kotlinx.coroutines.flow.asStateFlow
/** The bouncer scene displays authentication challenges like PIN, password, or pattern. */
class BouncerScene(
    private val viewModel: BouncerViewModel,
    private val dialogFactory: () -> AlertDialog,
) : ComposableScene {
    override val key = SceneKey.Bouncer

@@ -68,16 +81,19 @@ class BouncerScene(
    override fun Content(
        containerName: String,
        modifier: Modifier,
    ) = BouncerScene(viewModel, modifier)
    ) = BouncerScene(viewModel, dialogFactory, modifier)
}

@Composable
private fun BouncerScene(
    viewModel: BouncerViewModel,
    dialogFactory: () -> AlertDialog,
    modifier: Modifier = Modifier,
) {
    val message: String by viewModel.message.collectAsState()
    val message: BouncerViewModel.MessageViewModel by viewModel.message.collectAsState()
    val authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethod.collectAsState()
    val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState()
    var dialog: Dialog? by remember { mutableStateOf(null) }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
@@ -88,9 +104,10 @@ private fun BouncerScene(
        Crossfade(
            targetState = message,
            label = "Bouncer message",
        ) {
            animationSpec = if (message.isUpdateAnimated) tween() else snap(),
        ) { message ->
            Text(
                text = it,
                text = message.text,
                color = MaterialTheme.colorScheme.onSurface,
                style = MaterialTheme.typography.bodyLarge,
            )
@@ -132,5 +149,26 @@ private fun BouncerScene(
                style = MaterialTheme.typography.bodyMedium,
            )
        }

        if (dialogMessage != null) {
            if (dialog == null) {
                dialog =
                    dialogFactory().apply {
                        setMessage(dialogMessage)
                        setButton(
                            DialogInterface.BUTTON_NEUTRAL,
                            context.getString(R.string.ok),
                        ) { _, _ ->
                            viewModel.onThrottlingDialogDismissed()
                        }
                        setCancelable(false)
                        setCanceledOnTouchOutside(false)
                        show()
                    }
            }
        } else {
            dialog?.dismiss()
            dialog = null
        }
    }
}
+10 −0
Original line number Diff line number Diff line
@@ -53,6 +53,8 @@ internal fun PasswordBouncer(
) {
    val focusRequester = remember { FocusRequester() }
    val password: String by viewModel.password.collectAsState()
    val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
    val animateFailure: Boolean by viewModel.animateFailure.collectAsState()

    LaunchedEffect(Unit) {
        // When the UI comes up, request focus on the TextField to bring up the software keyboard.
@@ -61,6 +63,13 @@ internal fun PasswordBouncer(
        viewModel.onShown()
    }

    LaunchedEffect(animateFailure) {
        if (animateFailure) {
            // We don't currently have a failure animation for password, just consume it:
            viewModel.onFailureAnimationShown()
        }
    }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = modifier,
@@ -71,6 +80,7 @@ internal fun PasswordBouncer(
        TextField(
            value = password,
            onValueChange = viewModel::onPasswordInputChanged,
            enabled = isInputEnabled,
            visualTransformation = PasswordVisualTransformation(),
            singleLine = true,
            textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
+151 −36
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ package com.android.systemui.bouncer.ui.composable

import android.view.HapticFeedbackConstants
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
@@ -41,12 +42,15 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.integerResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.android.compose.animation.Easings
import com.android.internal.R
import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel
import com.android.systemui.compose.modifiers.thenIf
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sqrt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

/**
@@ -66,9 +70,9 @@ internal fun PatternBouncer(
    val rowCount = viewModel.rowCount

    val dotColor = MaterialTheme.colorScheme.secondary
    val dotRadius = with(LocalDensity.current) { 8.dp.toPx() }
    val dotRadius = with(LocalDensity.current) { (DOT_DIAMETER_DP / 2).dp.toPx() }
    val lineColor = MaterialTheme.colorScheme.primary
    val lineStrokeWidth = dotRadius * 2 + with(LocalDensity.current) { 4.dp.toPx() }
    val lineStrokeWidth = with(LocalDensity.current) { LINE_STROKE_WIDTH_DP.dp.toPx() }

    var containerSize: IntSize by remember { mutableStateOf(IntSize(0, 0)) }
    val horizontalSpacing = containerSize.width / colCount
@@ -82,6 +86,9 @@ internal fun PatternBouncer(
    val currentDot: PatternDotViewModel? by viewModel.currentDot.collectAsState()
    // The dots selected so far, if the user is currently dragging.
    val selectedDots: List<PatternDotViewModel> by viewModel.selectedDots.collectAsState()
    val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
    val isAnimationEnabled: Boolean by viewModel.isPatternVisible.collectAsState()
    val animateFailure: Boolean by viewModel.animateFailure.collectAsState()

    // Map of animatables for the scale of each dot, keyed by dot.
    val dotScalingAnimatables = remember(dots) { dots.associateWith { Animatable(1f) } }
@@ -96,19 +103,46 @@ internal fun PatternBouncer(
    val view = LocalView.current

    // When the current dot is changed, we need to update our animations.
    LaunchedEffect(currentDot) {
    LaunchedEffect(currentDot, isAnimationEnabled) {
        // Perform haptic feedback, but only if the current dot is not null, so we don't perform it
        // when the UI first shows up or when the user lifts their pointer/finger.
        if (currentDot != null) {
            view.performHapticFeedback(
                HapticFeedbackConstants.VIRTUAL_KEY,
                HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING,
            )
        }

        if (!isAnimationEnabled) {
            return@LaunchedEffect
        }

        // Make sure that the current dot is scaled up while the other dots are scaled back down.
        // Make sure that the current dot is scaled up while the other dots are scaled back
        // down.
        dotScalingAnimatables.entries.forEach { (dot, animatable) ->
            val isSelected = dot == currentDot
            launch {
                animatable.animateTo(if (isSelected) 2f else 1f)
            // Launch using the longer-lived scope because we want these animations to proceed to
            // completion even if the LaunchedEffect is canceled because its key objects have
            // changed.
            scope.launch {
                if (isSelected) {
                    animatable.animateTo(1f)
                    animatable.animateTo(
                        targetValue = (SELECTED_DOT_DIAMETER_DP / DOT_DIAMETER_DP.toFloat()),
                        animationSpec =
                            tween(
                                durationMillis = SELECTED_DOT_REACTION_ANIMATION_DURATION_MS,
                                easing = Easings.StandardAccelerateEasing,
                            ),
                    )
                } else {
                    animatable.animateTo(
                        targetValue = 1f,
                        animationSpec =
                            tween(
                                durationMillis = SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS,
                                easing = Easings.StandardDecelerateEasing,
                            ),
                    )
                }
            }
        }
@@ -116,14 +150,18 @@ internal fun PatternBouncer(
        selectedDots.forEach { dot ->
            lineFadeOutAnimatables[dot]?.let { line ->
                if (!line.isRunning) {
                    // Launch using the longer-lived scope because we want these animations to
                    // proceed to completion even if the LaunchedEffect is canceled because its key
                    // objects have changed.
                    scope.launch {
                        if (dot == currentDot) {
                            // Reset the fade-out animation for the current dot. When the current
                            // dot is switched, this entire code block runs again for the newly
                            // selected dot.
                            // Reset the fade-out animation for the current dot. When the
                            // current dot is switched, this entire code block runs again for
                            // the newly selected dot.
                            line.snapTo(1f)
                        } else {
                            // For all non-current dots, make sure that the lines are fading out.
                            // For all non-current dots, make sure that the lines are fading
                            // out.
                            line.animateTo(
                                targetValue = 0f,
                                animationSpec =
@@ -139,6 +177,17 @@ internal fun PatternBouncer(
        }
    }

    // Show the failure animation if the user entered the wrong input.
    LaunchedEffect(animateFailure) {
        if (animateFailure) {
            showFailureAnimation(
                dots = dots,
                scalingAnimatables = dotScalingAnimatables,
            )
            viewModel.onFailureAnimationShown()
        }
    }

    // This is the position of the input pointer.
    var inputPosition: Offset? by remember { mutableStateOf(null) }

@@ -148,7 +197,8 @@ internal fun PatternBouncer(
            // when it leaves the bounds of the dot grid.
            .clipToBounds()
            .onSizeChanged { containerSize = it }
            .pointerInput(Unit) {
            .thenIf(isInputEnabled) {
                Modifier.pointerInput(Unit) {
                    detectDragGestures(
                        onDragStart = { start ->
                            inputPosition = start
@@ -156,9 +206,14 @@ internal fun PatternBouncer(
                        },
                        onDragEnd = {
                            inputPosition = null
                            if (isAnimationEnabled) {
                                lineFadeOutAnimatables.values.forEach { animatable ->
                                    // Launch using the longer-lived scope because we want these
                                    // animations to proceed to completion even if the surrounding
                                    // scope is canceled.
                                    scope.launch { animatable.animateTo(1f) }
                                }
                            }
                            viewModel.onDragEnd()
                        },
                    ) { change, _ ->
@@ -171,6 +226,7 @@ internal fun PatternBouncer(
                        )
                    }
                }
            }
    ) {
        // Draw lines between dots.
        selectedDots.forEachIndexed { index, dot ->
@@ -247,3 +303,62 @@ private fun lineAlpha(gridSpacing: Float, lineLength: Float = gridSpacing): Floa
    // farther the user input pointer goes from the line, the more opaque the line gets.
    return ((lineLength / gridSpacing - 0.3f) * 4f).coerceIn(0f, 1f)
}

private suspend fun showFailureAnimation(
    dots: List<PatternDotViewModel>,
    scalingAnimatables: Map<PatternDotViewModel, Animatable<Float, AnimationVector1D>>,
) {
    val dotsByRow =
        buildList<MutableList<PatternDotViewModel>> {
            dots.forEach { dot ->
                val rowIndex = dot.y
                while (size <= rowIndex) {
                    add(mutableListOf())
                }
                get(rowIndex).add(dot)
            }
        }

    coroutineScope {
        dotsByRow.forEachIndexed { rowIndex, rowDots ->
            rowDots.forEach { dot ->
                scalingAnimatables[dot]?.let { dotScaleAnimatable ->
                    launch {
                        dotScaleAnimatable.animateTo(
                            targetValue =
                                FAILURE_ANIMATION_DOT_DIAMETER_DP / DOT_DIAMETER_DP.toFloat(),
                            animationSpec =
                                tween(
                                    durationMillis =
                                        FAILURE_ANIMATION_DOT_SHRINK_ANIMATION_DURATION_MS,
                                    delayMillis =
                                        rowIndex * FAILURE_ANIMATION_DOT_SHRINK_STAGGER_DELAY_MS,
                                    easing = Easings.LinearEasing,
                                ),
                        )

                        dotScaleAnimatable.animateTo(
                            targetValue = 1f,
                            animationSpec =
                                tween(
                                    durationMillis =
                                        FAILURE_ANIMATION_DOT_REVERT_ANIMATION_DURATION,
                                    easing = Easings.StandardEasing,
                                ),
                        )
                    }
                }
            }
        }
    }
}

private const val DOT_DIAMETER_DP = 16
private const val SELECTED_DOT_DIAMETER_DP = 24
private const val SELECTED_DOT_REACTION_ANIMATION_DURATION_MS = 83
private const val SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS = 750
private const val LINE_STROKE_WIDTH_DP = 16
private const val FAILURE_ANIMATION_DOT_DIAMETER_DP = 13
private const val FAILURE_ANIMATION_DOT_SHRINK_ANIMATION_DURATION_MS = 50
private const val FAILURE_ANIMATION_DOT_SHRINK_STAGGER_DELAY_MS = 33
private const val FAILURE_ANIMATION_DOT_REVERT_ANIMATION_DURATION = 617
Loading