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

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

Merge "[flexiglass] Support for split layout bouncer." into main

parents bad148cc bf203344
Loading
Loading
Loading
Loading
+237 −111
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@ package com.android.systemui.bouncer.ui.composable
import android.app.AlertDialog
import android.app.Dialog
import android.content.DialogInterface
import android.content.res.Configuration
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
@@ -50,6 +51,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -63,6 +65,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
@@ -136,7 +139,7 @@ private fun SceneScope.BouncerScene(
    modifier: Modifier = Modifier,
) {
    val backgroundColor = MaterialTheme.colorScheme.surface
    val windowSizeClass = LocalWindowSizeClass.current
    val layout = calculateLayout()

    Box(modifier) {
        Canvas(Modifier.element(Bouncer.Elements.Background).fillMaxSize()) {
@@ -146,22 +149,30 @@ private fun SceneScope.BouncerScene(
        val childModifier = Modifier.element(Bouncer.Elements.Content).fillMaxSize()
        val isFullScreenUserSwitcherEnabled = viewModel.isUserSwitcherVisible

        when {
            windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded ->
        when (layout) {
            Layout.STANDARD ->
                Bouncer(
                    viewModel = viewModel,
                    dialogFactory = dialogFactory,
                    isUserInputAreaVisible = true,
                    modifier = childModifier,
                )
            Layout.SIDE_BY_SIDE ->
                SideBySide(
                    viewModel = viewModel,
                    dialogFactory = dialogFactory,
                    isUserSwitcherVisible = isFullScreenUserSwitcherEnabled,
                    modifier = childModifier,
                )
            isFullScreenUserSwitcherEnabled &&
                windowSizeClass.widthSizeClass == WindowWidthSizeClass.Medium ->
            Layout.STACKED ->
                Stacked(
                    viewModel = viewModel,
                    dialogFactory = dialogFactory,
                    isUserSwitcherVisible = isFullScreenUserSwitcherEnabled,
                    modifier = childModifier,
                )
            else ->
                Bouncer(
            Layout.SPLIT ->
                Split(
                    viewModel = viewModel,
                    dialogFactory = dialogFactory,
                    modifier = childModifier,
@@ -178,11 +189,10 @@ private fun SceneScope.BouncerScene(
private fun Bouncer(
    viewModel: BouncerViewModel,
    dialogFactory: BouncerSceneDialogFactory,
    isUserInputAreaVisible: Boolean,
    modifier: Modifier = Modifier,
) {
    val message: BouncerViewModel.MessageViewModel by viewModel.message.collectAsState()
    val authMethodViewModel: AuthMethodBouncerViewModel? by
        viewModel.authMethodViewModel.collectAsState()
    val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState()
    var dialog: Dialog? by remember { mutableStateOf(null) }

@@ -204,25 +214,11 @@ private fun Bouncer(
        }

        Box(Modifier.weight(1f)) {
            when (val nonNullViewModel = authMethodViewModel) {
                is PinBouncerViewModel ->
                    PinBouncer(
                        viewModel = nonNullViewModel,
                        modifier = Modifier.align(Alignment.Center),
                    )
                is PasswordBouncerViewModel ->
                    PasswordBouncer(
                        viewModel = nonNullViewModel,
            if (isUserInputAreaVisible) {
                UserInputArea(
                    viewModel = viewModel,
                    modifier = Modifier.align(Alignment.Center),
                )
                is PatternBouncerViewModel ->
                    PatternBouncer(
                        viewModel = nonNullViewModel,
                        modifier =
                            Modifier.aspectRatio(1f, matchHeightConstraintsFirst = false)
                                .align(Alignment.BottomCenter),
                    )
                else -> Unit
            }
        }

@@ -265,6 +261,40 @@ private fun Bouncer(
    }
}

/**
 * Renders the user input area, where the user interacts with the UI to enter their credentials.
 *
 * For example, this can be the pattern input area, the password text box, or pin pad.
 */
@Composable
private fun UserInputArea(
    viewModel: BouncerViewModel,
    modifier: Modifier = Modifier,
) {
    val authMethodViewModel: AuthMethodBouncerViewModel? by
        viewModel.authMethodViewModel.collectAsState()

    when (val nonNullViewModel = authMethodViewModel) {
        is PinBouncerViewModel ->
            PinBouncer(
                viewModel = nonNullViewModel,
                modifier = modifier,
            )
        is PasswordBouncerViewModel ->
            PasswordBouncer(
                viewModel = nonNullViewModel,
                modifier = modifier,
            )
        is PatternBouncerViewModel ->
            PatternBouncer(
                viewModel = nonNullViewModel,
                modifier =
                    Modifier.aspectRatio(1f, matchHeightConstraintsFirst = false).then(modifier)
            )
        else -> Unit
    }
}

/** Renders the UI of the user switcher that's displayed on large screens next to the bouncer UI. */
@Composable
private fun UserSwitcher(
@@ -287,19 +317,9 @@ private fun UserSwitcher(
            )
        }

        UserSwitcherDropdown(
            items = dropdownItems,
        )
    }
}

@Composable
private fun UserSwitcherDropdown(
    items: List<BouncerViewModel.UserSwitcherDropdownItemViewModel>,
) {
        val (isDropdownExpanded, setDropdownExpanded) = remember { mutableStateOf(false) }

    items.firstOrNull()?.let { firstDropdownItem ->
        dropdownItems.firstOrNull()?.let { firstDropdownItem ->
            Spacer(modifier = Modifier.height(40.dp))

            Box {
@@ -336,13 +356,18 @@ private fun UserSwitcherDropdown(

                UserSwitcherDropdownMenu(
                    isExpanded = isDropdownExpanded,
                items = items,
                    items = dropdownItems,
                    onDismissed = { setDropdownExpanded(false) },
                )
            }
        }
    }
}

/**
 * Renders the dropdowm menu that displays the actual users and/or user actions that can be
 * selected.
 */
@Composable
private fun UserSwitcherDropdownMenu(
    isExpanded: Boolean,
@@ -396,19 +421,47 @@ private fun UserSwitcherDropdownMenu(
}

/**
 * Arranges the bouncer contents and user switcher contents side-by-side, supporting a double tap
 * anywhere on the background to flip their positions.
 * Renders the bouncer UI in split mode, with half on one side and half on the other side, swappable
 * by double-tapping on the side.
 */
@Composable
private fun SideBySide(
private fun Split(
    viewModel: BouncerViewModel,
    dialogFactory: BouncerSceneDialogFactory,
    modifier: Modifier = Modifier,
) {
    SwappableLayout(
        startContent = { startContentModifier ->
            Bouncer(
                viewModel = viewModel,
                dialogFactory = dialogFactory,
                isUserInputAreaVisible = false,
                modifier = startContentModifier,
            )
        },
        endContent = { endContentModifier ->
            UserInputArea(
                viewModel = viewModel,
                modifier = endContentModifier,
            )
        },
        modifier = modifier
    )
}

/**
 * Arranges the given two contents side-by-side, supporting a double tap anywhere on the background
 * to flip their positions.
 */
@Composable
private fun SwappableLayout(
    startContent: @Composable (Modifier) -> Unit,
    endContent: @Composable (Modifier) -> Unit,
    modifier: Modifier = Modifier,
) {
    val layoutDirection = LocalLayoutDirection.current
    val isLeftToRight = layoutDirection == LayoutDirection.Ltr
    val (isUserSwitcherFirst, setUserSwitcherFirst) =
        rememberSaveable(isLeftToRight) { mutableStateOf(isLeftToRight) }
    val (isSwapped, setSwapped) = rememberSaveable(isLeftToRight) { mutableStateOf(!isLeftToRight) }

    Row(
        modifier =
@@ -416,9 +469,8 @@ private fun SideBySide(
                detectTapGestures(
                    onDoubleTap = { offset ->
                        // Depending on where the user double tapped, switch the elements such that
                        // the bouncer contents element is closer to the side that was double
                        // tapped.
                        setUserSwitcherFirst(offset.x > size.width / 2)
                        // the endContent is closer to the side that was double tapped.
                        setSwapped(offset.x < size.width / 2)
                    }
                )
            },
@@ -426,39 +478,30 @@ private fun SideBySide(
        val animatedOffset by
            animateFloatAsState(
                targetValue =
                    if (isUserSwitcherFirst) {
                        // When the user switcher is first, both elements have their natural
                        // placement so they are not offset in any way.
                    if (!isSwapped) {
                        // When startContent is first, both elements have their natural placement so
                        // they are not offset in any way.
                        0f
                    } else if (isLeftToRight) {
                        // Since the user switcher is not first, the elements have to be swapped
                        // horizontally. In the case of LTR locales, this means pushing the user
                        // switcher to the right, hence the positive number.
                        // Since startContent is not first, the elements have to be swapped
                        // horizontally. In the case of LTR locales, this means pushing startContent
                        // to the right, hence the positive number.
                        1f
                    } else {
                        // Since the user switcher is not first, the elements have to be swapped
                        // horizontally. In the case of RTL locale, this means pushing the user
                        // switcher to the left, hence the negative number.
                        // Since startContent is not first, the elements have to be swapped
                        // horizontally. In the case of RTL locales, this means pushing startContent
                        // to the left, hence the negative number.
                        -1f
                    },
                label = "offset",
            )

        val userSwitcherModifier =
        startContent(
            Modifier.fillMaxHeight().weight(1f).graphicsLayer {
                translationX = size.width * animatedOffset
                alpha = animatedAlpha(animatedOffset)
            }
        if (viewModel.isUserSwitcherVisible) {
            UserSwitcher(
                viewModel = viewModel,
                modifier = userSwitcherModifier,
        )
        } else {
            Box(
                modifier = userSwitcherModifier,
            )
        }

        Box(
            modifier =
@@ -469,41 +512,124 @@ private fun SideBySide(
                    alpha = animatedAlpha(animatedOffset)
                }
        ) {
            endContent(Modifier.widthIn(max = 400.dp).align(Alignment.BottomCenter))
        }
    }
}

/**
 * Arranges the bouncer contents and user switcher contents side-by-side, supporting a double tap
 * anywhere on the background to flip their positions.
 */
@Composable
private fun SideBySide(
    viewModel: BouncerViewModel,
    dialogFactory: BouncerSceneDialogFactory,
    isUserSwitcherVisible: Boolean,
    modifier: Modifier = Modifier,
) {
    SwappableLayout(
        startContent = { startContentModifier ->
            if (isUserSwitcherVisible) {
                UserSwitcher(
                    viewModel = viewModel,
                    modifier = startContentModifier,
                )
            } else {
                Box(
                    modifier = startContentModifier,
                )
            }
        },
        endContent = { endContentModifier ->
            Bouncer(
                viewModel = viewModel,
                dialogFactory = dialogFactory,
                modifier = Modifier.widthIn(max = 400.dp).align(Alignment.BottomCenter),
                isUserInputAreaVisible = true,
                modifier = endContentModifier,
            )
        },
        modifier = modifier,
    )
        }
    }
}

/** Arranges the bouncer contents and user switcher contents one on top of the other. */
/** Arranges the bouncer contents and user switcher contents one on top of the other, vertically. */
@Composable
private fun Stacked(
    viewModel: BouncerViewModel,
    dialogFactory: BouncerSceneDialogFactory,
    isUserSwitcherVisible: Boolean,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier,
    ) {
        if (isUserSwitcherVisible) {
            UserSwitcher(
                viewModel = viewModel,
                modifier = Modifier.fillMaxWidth().weight(1f),
            )
        }

        Bouncer(
            viewModel = viewModel,
            dialogFactory = dialogFactory,
            isUserInputAreaVisible = true,
            modifier = Modifier.fillMaxWidth().weight(1f),
        )
    }
}

@Composable
private fun calculateLayout(): Layout {
    val windowSizeClass = LocalWindowSizeClass.current
    val width = windowSizeClass.widthSizeClass
    val height = windowSizeClass.heightSizeClass
    val isLarge = width > WindowWidthSizeClass.Compact && height > WindowHeightSizeClass.Compact
    val isTall =
        when (height) {
            WindowHeightSizeClass.Expanded -> width < WindowWidthSizeClass.Expanded
            WindowHeightSizeClass.Medium -> width < WindowWidthSizeClass.Medium
            else -> false
        }
    val isSquare =
        when (width) {
            WindowWidthSizeClass.Compact -> height == WindowHeightSizeClass.Compact
            WindowWidthSizeClass.Medium -> height == WindowHeightSizeClass.Medium
            WindowWidthSizeClass.Expanded -> height == WindowHeightSizeClass.Expanded
            else -> false
        }
    val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE

    return when {
        // Small and tall devices (i.e. phone/folded in portrait) or square device not in landscape
        // mode (unfolded with hinge along horizontal plane).
        (!isLarge && isTall) || (isSquare && !isLandscape) -> Layout.STANDARD
        // Small and wide devices (i.e. phone/folded in landscape).
        !isLarge -> Layout.SPLIT
        // Large and tall devices (i.e. tablet in portrait).
        isTall -> Layout.STACKED
        // Large and wide/square devices (i.e. tablet in landscape, unfolded).
        else -> Layout.SIDE_BY_SIDE
    }
}

interface BouncerSceneDialogFactory {
    operator fun invoke(): AlertDialog
}

/** Enumerates all known adaptive layout configurations. */
private enum class Layout {
    /** The default UI with the bouncer laid out normally. */
    STANDARD,
    /** The bouncer is displayed vertically stacked with the user switcher. */
    STACKED,
    /** The bouncer is displayed side-by-side with the user switcher or an empty space. */
    SIDE_BY_SIDE,
    /** The bouncer is split in two with both sides shown side-by-side. */
    SPLIT,
}

/**
 * Calculates an alpha for the user switcher and bouncer such that it's at `1` when the offset of
 * the two reaches a stopping point but `0` in the middle of the transition.