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

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

Merge changes from topic "flexi-bouncer-scene-ux-polish-1" into main

* changes:
  [flexiglass] Shortens password textfield focus routing to view-model.
  [flexiglass] UX polish for bouncer scene: PIN and Pattern
parents 725561ae 8afec3fa
Loading
Loading
Loading
Loading
+105 −18
Original line number Diff line number Diff line
@@ -34,9 +34,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -66,6 +66,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -97,7 +98,7 @@ import kotlin.math.pow
fun BouncerContent(
    viewModel: BouncerViewModel,
    dialogFactory: BouncerDialogFactory,
    modifier: Modifier
    modifier: Modifier = Modifier,
) {
    val isFullScreenUserSwitcherEnabled = viewModel.isUserSwitcherVisible
    val isSideBySideSupported by viewModel.isSideBySideSupported.collectAsState()
@@ -142,6 +143,7 @@ private fun StandardLayout(
    viewModel: BouncerViewModel,
    dialogFactory: BouncerDialogFactory,
    modifier: Modifier = Modifier,
    layout: BouncerSceneLayout = BouncerSceneLayout.STANDARD,
    outputOnly: Boolean = false,
) {
    val foldPosture: FoldPosture by foldPosture()
@@ -161,6 +163,7 @@ private fun StandardLayout(
            FoldSplittable(
                viewModel = viewModel,
                dialogFactory = dialogFactory,
                layout = layout,
                outputOnly = outputOnly,
                isSplit = false,
            )
@@ -170,6 +173,7 @@ private fun StandardLayout(
            FoldSplittable(
                viewModel = viewModel,
                dialogFactory = dialogFactory,
                layout = layout,
                outputOnly = outputOnly,
                isSplit = true,
            )
@@ -193,6 +197,7 @@ private fun StandardLayout(
private fun SceneScope.FoldSplittable(
    viewModel: BouncerViewModel,
    dialogFactory: BouncerDialogFactory,
    layout: BouncerSceneLayout,
    outputOnly: Boolean,
    isSplit: Boolean,
    modifier: Modifier = Modifier,
@@ -210,13 +215,21 @@ private fun SceneScope.FoldSplittable(
        // Content above the fold, when split on a foldable device in a "table top" posture:
        Box(
            modifier =
                Modifier.element(SceneElements.AboveFold).fillMaxWidth().thenIf(isSplit) {
                Modifier.element(SceneElements.AboveFold)
                    .fillMaxWidth()
                    .then(
                        if (isSplit) {
                            Modifier.weight(splitRatio)
                },
                        } else if (outputOnly) {
                            Modifier.fillMaxHeight()
                        } else {
                            Modifier
                        }
                    ),
        ) {
            Column(
                horizontalAlignment = Alignment.CenterHorizontally,
                modifier = Modifier.fillMaxWidth().padding(top = 92.dp),
                modifier = Modifier.fillMaxWidth().padding(top = layout.topPadding),
            ) {
                Crossfade(
                    targetState = message,
@@ -230,11 +243,23 @@ private fun SceneScope.FoldSplittable(
                    )
                }

                Spacer(Modifier.heightIn(min = 21.dp, max = 48.dp))
                if (!outputOnly) {
                    Spacer(Modifier.height(layout.spacingBetweenMessageAndEnteredInput))

                    UserInputArea(
                        viewModel = viewModel,
                        visibility = UserInputAreaVisibility.OUTPUT_ONLY,
                        layout = layout,
                    )
                }
            }

            if (outputOnly) {
                UserInputArea(
                    viewModel = viewModel,
                    visibility = UserInputAreaVisibility.OUTPUT_ONLY,
                    layout = layout,
                    modifier = Modifier.align(Alignment.Center),
                )
            }
        }
@@ -242,25 +267,32 @@ private fun SceneScope.FoldSplittable(
        // Content below the fold, when split on a foldable device in a "table top" posture:
        Box(
            modifier =
                Modifier.element(SceneElements.BelowFold).fillMaxWidth().thenIf(isSplit) {
                    Modifier.weight(1 - splitRatio)
                },
                Modifier.element(SceneElements.BelowFold)
                    .fillMaxWidth()
                    .weight(
                        if (isSplit) {
                            1 - splitRatio
                        } else {
                            1f
                        }
                    ),
        ) {
            Column(
                horizontalAlignment = Alignment.CenterHorizontally,
                modifier = Modifier.fillMaxWidth(),
                modifier = Modifier.fillMaxSize()
            ) {
                if (!outputOnly) {
                    Box(Modifier.weight(1f)) {
                        UserInputArea(
                            viewModel = viewModel,
                            visibility = UserInputAreaVisibility.INPUT_ONLY,
                            modifier = Modifier.align(Alignment.Center),
                            layout = layout,
                            modifier = Modifier.align(Alignment.BottomCenter),
                        )
                    }
                }

                Spacer(Modifier.heightIn(min = 21.dp, max = 48.dp))
                Spacer(Modifier.height(48.dp))

                val actionButtonModifier = Modifier.height(56.dp)

@@ -275,7 +307,7 @@ private fun SceneScope.FoldSplittable(
                    }
                }

                Spacer(Modifier.height(48.dp))
                Spacer(Modifier.height(layout.bottomPadding))
            }
        }

@@ -311,6 +343,7 @@ private fun SceneScope.FoldSplittable(
private fun UserInputArea(
    viewModel: BouncerViewModel,
    visibility: UserInputAreaVisibility,
    layout: BouncerSceneLayout,
    modifier: Modifier = Modifier,
) {
    val authMethodViewModel: AuthMethodBouncerViewModel? by
@@ -327,6 +360,7 @@ private fun UserInputArea(
                UserInputAreaVisibility.INPUT_ONLY ->
                    PinPad(
                        viewModel = nonNullViewModel,
                        layout = layout,
                        modifier = modifier,
                    )
            }
@@ -341,7 +375,8 @@ private fun UserInputArea(
            if (visibility == UserInputAreaVisibility.INPUT_ONLY) {
                PatternBouncer(
                    viewModel = nonNullViewModel,
                    modifier = modifier.aspectRatio(1f, matchHeightConstraintsFirst = false)
                    layout = layout,
                    modifier = modifier.aspectRatio(1f, matchHeightConstraintsFirst = false),
                )
            }
        else -> Unit
@@ -449,7 +484,7 @@ private fun UserSwitcher(
}

/**
 * Renders the dropdown menu that displays the actual users and/or user actions that can be
 * Renders the dropdowm menu that displays the actual users and/or user actions that can be
 * selected.
 */
@Composable
@@ -519,6 +554,7 @@ private fun SplitLayout(
            StandardLayout(
                viewModel = viewModel,
                dialogFactory = dialogFactory,
                layout = BouncerSceneLayout.SPLIT,
                outputOnly = true,
                modifier = startContentModifier,
            )
@@ -527,10 +563,12 @@ private fun SplitLayout(
            UserInputArea(
                viewModel = viewModel,
                visibility = UserInputAreaVisibility.INPUT_ONLY,
                layout = BouncerSceneLayout.SPLIT,
                modifier = endContentModifier,
            )
        },
        modifier = modifier
        layout = BouncerSceneLayout.SPLIT,
        modifier = modifier,
    )
}

@@ -542,6 +580,7 @@ private fun SplitLayout(
private fun SwappableLayout(
    startContent: @Composable (Modifier) -> Unit,
    endContent: @Composable (Modifier) -> Unit,
    layout: BouncerSceneLayout,
    modifier: Modifier = Modifier,
) {
    val layoutDirection = LocalLayoutDirection.current
@@ -597,7 +636,7 @@ private fun SwappableLayout(
                    alpha = animatedAlpha(animatedOffset)
                }
        ) {
            endContent(Modifier.widthIn(max = 400.dp).align(Alignment.BottomCenter))
            endContent(Modifier.align(layout.swappableEndContentAlignment).widthIn(max = 400.dp))
        }
    }
}
@@ -635,9 +674,11 @@ private fun SideBySideLayout(
            StandardLayout(
                viewModel = viewModel,
                dialogFactory = dialogFactory,
                layout = BouncerSceneLayout.SIDE_BY_SIDE,
                modifier = endContentModifier,
            )
        },
        layout = BouncerSceneLayout.SIDE_BY_SIDE,
        modifier = modifier,
    )
}
@@ -663,6 +704,7 @@ private fun StackedLayout(
        StandardLayout(
            viewModel = viewModel,
            dialogFactory = dialogFactory,
            layout = BouncerSceneLayout.STACKED,
            modifier = Modifier.fillMaxWidth().weight(1f),
        )
    }
@@ -732,3 +774,48 @@ private object SceneElements {
private val SceneTransitions = transitions {
    from(SceneKeys.ContiguousSceneKey, to = SceneKeys.SplitSceneKey) { spec = tween() }
}

/** Whether a more compact size should be used for various spacing dimensions. */
internal val BouncerSceneLayout.isUseCompactSize: Boolean
    get() =
        when (this) {
            BouncerSceneLayout.SIDE_BY_SIDE -> true
            BouncerSceneLayout.SPLIT -> true
            else -> false
        }

/** Amount of space to place between the message and the entered input UI elements, in dips. */
private val BouncerSceneLayout.spacingBetweenMessageAndEnteredInput: Dp
    get() =
        when {
            this == BouncerSceneLayout.STACKED -> 24.dp
            isUseCompactSize -> 96.dp
            else -> 128.dp
        }

/** Amount of space to place above the topmost UI element, in dips. */
private val BouncerSceneLayout.topPadding: Dp
    get() =
        if (this == BouncerSceneLayout.SPLIT) {
            40.dp
        } else {
            92.dp
        }

/** Amount of space to place below the bottommost UI element, in dips. */
private val BouncerSceneLayout.bottomPadding: Dp
    get() =
        if (this == BouncerSceneLayout.SPLIT) {
            40.dp
        } else {
            48.dp
        }

/** The in-a-box alignment for the content on the "end" side of a swappable layout. */
private val BouncerSceneLayout.swappableEndContentAlignment: Alignment
    get() =
        if (this == BouncerSceneLayout.SPLIT) {
            Alignment.Center
        } else {
            Alignment.BottomCenter
        }
+1 −10
Original line number Diff line number Diff line
@@ -18,8 +18,6 @@ package com.android.systemui.bouncer.ui.composable

import android.view.ViewTreeObserver
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
@@ -31,7 +29,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -64,10 +61,6 @@ internal fun PasswordBouncer(
            focusRequester.requestFocus()
        }
    }
    val (isTextFieldFocused, onTextFieldFocusChanged) = remember { mutableStateOf(false) }
    LaunchedEffect(isTextFieldFocused) {
        viewModel.onTextFieldFocusChanged(isFocused = isTextFieldFocused)
    }

    val password: String by viewModel.password.collectAsState()
    val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
@@ -113,7 +106,7 @@ internal fun PasswordBouncer(
                ),
            modifier =
                Modifier.focusRequester(focusRequester)
                    .onFocusChanged { onTextFieldFocusChanged(it.isFocused) }
                    .onFocusChanged { viewModel.onTextFieldFocusChanged(it.isFocused) }
                    .drawBehind {
                        drawLine(
                            color = color,
@@ -123,8 +116,6 @@ internal fun PasswordBouncer(
                        )
                    },
        )

        Spacer(Modifier.height(100.dp))
    }
}

+67 −11
Original line number Diff line number Diff line
@@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp
import com.android.compose.animation.Easings
import com.android.compose.modifiers.thenIf
import com.android.internal.R
import com.android.systemui.bouncer.ui.helper.BouncerSceneLayout
import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel
import kotlin.math.min
@@ -64,6 +65,7 @@ import kotlinx.coroutines.launch
@Composable
internal fun PatternBouncer(
    viewModel: PatternBouncerViewModel,
    layout: BouncerSceneLayout,
    modifier: Modifier = Modifier,
) {
    DisposableEffect(Unit) {
@@ -190,6 +192,8 @@ internal fun PatternBouncer(
    // This is the position of the input pointer.
    var inputPosition: Offset? by remember { mutableStateOf(null) }
    var gridCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
    var offset: Offset by remember { mutableStateOf(Offset.Zero) }
    var scale: Float by remember { mutableStateOf(1f) }

    Canvas(
        modifier
@@ -224,21 +228,42 @@ internal fun PatternBouncer(
                            },
                        ) { change, _ ->
                            inputPosition = change.position
                            change.position.minus(offset).div(scale).let {
                                viewModel.onDrag(
                                xPx = change.position.x,
                                yPx = change.position.y,
                                    xPx = it.x,
                                    yPx = it.y,
                                    containerSizePx = size.width,
                                )
                            }
                        }
                    }
            }
    ) {
        gridCoordinates?.let { nonNullCoordinates ->
            val containerSize = nonNullCoordinates.size
            if (containerSize.width <= 0 || containerSize.height <= 0) {
                return@let
            }

            val horizontalSpacing = containerSize.width.toFloat() / colCount
            val verticalSpacing = containerSize.height.toFloat() / rowCount
            val spacing = min(horizontalSpacing, verticalSpacing)
            val verticalOffset = containerSize.height - spacing * rowCount
            val horizontalOffset =
                offset(
                    availableSize = containerSize.width,
                    spacingPerDot = spacing,
                    dotCount = colCount,
                    isCentered = true,
                )
            val verticalOffset =
                offset(
                    availableSize = containerSize.height,
                    spacingPerDot = spacing,
                    dotCount = rowCount,
                    isCentered = layout.isCenteredVertically,
                )
            offset = Offset(horizontalOffset, verticalOffset)
            scale = (colCount * spacing) / containerSize.width

            if (isAnimationEnabled) {
                // Draw lines between dots.
@@ -248,8 +273,9 @@ internal fun PatternBouncer(
                        val lineFadeOutAnimationProgress =
                            lineFadeOutAnimatables[previousDot]!!.value
                        val startLerp = 1 - lineFadeOutAnimationProgress
                        val from = pixelOffset(previousDot, spacing, verticalOffset)
                        val to = pixelOffset(dot, spacing, verticalOffset)
                        val from =
                            pixelOffset(previousDot, spacing, horizontalOffset, verticalOffset)
                        val to = pixelOffset(dot, spacing, horizontalOffset, verticalOffset)
                        val lerpedFrom =
                            Offset(
                                x = from.x + (to.x - from.x) * startLerp,
@@ -270,7 +296,7 @@ internal fun PatternBouncer(
                // position.
                inputPosition?.let { lineEnd ->
                    currentDot?.let { dot ->
                        val from = pixelOffset(dot, spacing, verticalOffset)
                        val from = pixelOffset(dot, spacing, horizontalOffset, verticalOffset)
                        val lineLength =
                            sqrt((from.y - lineEnd.y).pow(2) + (from.x - lineEnd.x).pow(2))
                        drawLine(
@@ -288,7 +314,7 @@ internal fun PatternBouncer(
            // Draw each dot on the grid.
            dots.forEach { dot ->
                drawCircle(
                    center = pixelOffset(dot, spacing, verticalOffset),
                    center = pixelOffset(dot, spacing, horizontalOffset, verticalOffset),
                    color = dotColor,
                    radius = dotRadius * (dotScalingAnimatables[dot]?.value ?: 1f),
                )
@@ -301,10 +327,11 @@ internal fun PatternBouncer(
private fun pixelOffset(
    dot: PatternDotViewModel,
    spacing: Float,
    horizontalOffset: Float,
    verticalOffset: Float,
): Offset {
    return Offset(
        x = dot.x * spacing + spacing / 2,
        x = dot.x * spacing + spacing / 2 + horizontalOffset,
        y = dot.y * spacing + spacing / 2 + verticalOffset,
    )
}
@@ -371,6 +398,35 @@ private suspend fun showFailureAnimation(
    }
}

/**
 * Returns the amount of offset along the axis, in pixels, that should be applied to all dots.
 *
 * @param availableSize The size of the container, along the axis of interest.
 * @param spacingPerDot The amount of pixels that each dot should take (including the area around
 *   that dot).
 * @param dotCount The number of dots along the axis (e.g. if the axis of interest is the
 *   horizontal/x axis, this is the number of columns in the dot grid).
 * @param isCentered Whether the dots should be centered along the axis of interest; if `false`, the
 *   dots will be pushed towards to end/bottom of the axis.
 */
private fun offset(
    availableSize: Int,
    spacingPerDot: Float,
    dotCount: Int,
    isCentered: Boolean = false,
): Float {
    val default = availableSize - spacingPerDot * dotCount
    return if (isCentered) {
        default / 2
    } else {
        default
    }
}

/** Whether the UI should be centered vertically. */
private val BouncerSceneLayout.isCenteredVertically: Boolean
    get() = this == BouncerSceneLayout.SPLIT

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
+32 −7
Original line number Diff line number Diff line
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
import com.android.compose.animation.Easings
import com.android.compose.grid.VerticalGrid
import com.android.compose.modifiers.thenIf
import com.android.systemui.bouncer.ui.helper.BouncerSceneLayout
import com.android.systemui.bouncer.ui.viewmodel.ActionButtonAppearance
import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
import com.android.systemui.common.shared.model.ContentDescription
@@ -65,9 +66,11 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

/** Renders the PIN button pad. */
@Composable
fun PinPad(
    viewModel: PinBouncerViewModel,
    layout: BouncerSceneLayout,
    modifier: Modifier = Modifier,
) {
    DisposableEffect(Unit) {
@@ -92,9 +95,9 @@ fun PinPad(
    }

    VerticalGrid(
        columns = 3,
        verticalSpacing = 12.dp,
        horizontalSpacing = 20.dp,
        columns = columns,
        verticalSpacing = layout.verticalSpacing,
        horizontalSpacing = calculateHorizontalSpacingBetweenColumns(layout.gridWidth),
        modifier = modifier,
    ) {
        repeat(9) { index ->
@@ -254,7 +257,7 @@ private fun PinPadButton(

    val cornerRadius: Dp by
        animateDpAsState(
            if (isAnimationEnabled && isPressed) 24.dp else pinButtonSize / 2,
            if (isAnimationEnabled && isPressed) 24.dp else pinButtonMaxSize / 2,
            label = "PinButton round corners",
            animationSpec = tween(animDurationMillis, easing = animEasing)
        )
@@ -284,7 +287,7 @@ private fun PinPadButton(
        contentAlignment = Alignment.Center,
        modifier =
            modifier
                .sizeIn(maxWidth = pinButtonSize, maxHeight = pinButtonSize)
                .sizeIn(maxWidth = pinButtonMaxSize, maxHeight = pinButtonMaxSize)
                .aspectRatio(1f)
                .drawBehind {
                    drawRoundRect(
@@ -345,10 +348,32 @@ private suspend fun showFailureAnimation(
    }
}

private val pinButtonSize = 84.dp
private val pinButtonErrorShrinkFactor = 67.dp / pinButtonSize
/** Returns the amount of horizontal spacing between columns, in dips. */
private fun calculateHorizontalSpacingBetweenColumns(
    gridWidth: Dp,
): Dp {
    return (gridWidth - (pinButtonMaxSize * columns)) / (columns - 1)
}

/** The width of the grid of PIN pad buttons, in dips. */
private val BouncerSceneLayout.gridWidth: Dp
    get() = if (isUseCompactSize) 292.dp else 300.dp

/** The spacing between rows of PIN pad buttons, in dips. */
private val BouncerSceneLayout.verticalSpacing: Dp
    get() = if (isUseCompactSize) 8.dp else 12.dp

/** Number of columns in the PIN pad grid. */
private const val columns = 3
/** Maximum size (width and height) of each PIN pad button. */
private val pinButtonMaxSize = 84.dp
/** Scale factor to apply to buttons when animating the "error" animation on them. */
private val pinButtonErrorShrinkFactor = 67.dp / pinButtonMaxSize
/** Animation duration of the "shrink" phase of the error animation, on each PIN pad button. */
private const val pinButtonErrorShrinkMs = 50
/** Amount of time to wait between application of the "error" animation to each row of buttons. */
private const val pinButtonErrorStaggerDelayMs = 33
/** Animation duration of the "revert" phase of the error animation, on each PIN pad button. */
private const val pinButtonErrorRevertMs = 617

// Pin button motion spec: http://shortn/_9TTIG6SoEa