Loading packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt +222 −78 Original line number Diff line number Diff line Loading @@ -14,40 +14,49 @@ * 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.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.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.material3.windowsizeclass.WindowWidthSizeClass 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.android.compose.animation.scene.ElementKey import com.android.compose.animation.scene.SceneScope import com.android.compose.windowsizeclass.LocalWindowSizeClass import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel Loading Loading @@ -103,25 +112,59 @@ private fun SceneScope.BouncerScene( dialogFactory: BouncerSceneDialogFactory, 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) } val backgroundColor = MaterialTheme.colorScheme.surface val windowSizeClass = LocalWindowSizeClass.current Box(modifier) { Canvas(Modifier.element(Bouncer.Elements.Background).fillMaxSize()) { drawRect(color = backgroundColor) } val childModifier = Modifier.element(Bouncer.Elements.Content).fillMaxSize() when (windowSizeClass.widthSizeClass) { WindowWidthSizeClass.Expanded -> SideBySide( viewModel = viewModel, dialogFactory = dialogFactory, modifier = childModifier, ) WindowWidthSizeClass.Medium -> Stacked( viewModel = viewModel, dialogFactory = dialogFactory, modifier = childModifier, ) else -> Bouncer( viewModel = viewModel, dialogFactory = dialogFactory, modifier = childModifier, ) } } } /** * Renders the contents of the actual bouncer UI, the area that takes user input to do an * authentication attempt, including all messaging UI (directives, reasoning, errors, etc.). */ @Composable private fun Bouncer( viewModel: BouncerViewModel, dialogFactory: BouncerSceneDialogFactory, 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) } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(60.dp), modifier = Modifier.element(Bouncer.Elements.Content) .fillMaxSize() .padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 32.dp) modifier = modifier.padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 32.dp) ) { Crossfade( targetState = message, Loading Loading @@ -194,6 +237,107 @@ private fun SceneScope.BouncerScene( } } } /** Renders the UI of the user switcher that's displayed on large screens next to the bouncer UI. */ @Composable private fun UserSwitcher( modifier: Modifier = Modifier, ) { Box(modifier) { Text( text = "TODO: the user switcher goes here", modifier = Modifier.align(Alignment.Center) ) } } /** * 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, modifier: Modifier = Modifier, ) { val layoutDirection = LocalLayoutDirection.current val isLeftToRight = layoutDirection == LayoutDirection.Ltr val (isUserSwitcherFirst, setUserSwitcherFirst) = rememberSaveable(isLeftToRight) { mutableStateOf(isLeftToRight) } Row( modifier = modifier.pointerInput(Unit) { 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) } ) }, ) { 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. 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. 1f } else { // Since the user switcher is not first, the elements have to be swapped // horizontally. In the case of RTL locales, this means pushing the user // switcher to the left, hence the negative number. -1f }, label = "offset", ) UserSwitcher( modifier = Modifier.fillMaxHeight().weight(1f).graphicsLayer { translationX = size.width * animatedOffset }, ) Bouncer( viewModel = viewModel, dialogFactory = dialogFactory, modifier = Modifier.fillMaxHeight().weight(1f).graphicsLayer { // A negative sign is used to make sure this is offset in the direction that's // opposite of the direction that the user switcher is pushed in. translationX = -size.width * animatedOffset }, ) } } /** Arranges the bouncer contents and user switcher contents one on top of the other. */ @Composable private fun Stacked( viewModel: BouncerViewModel, dialogFactory: BouncerSceneDialogFactory, modifier: Modifier = Modifier, ) { Column( modifier = modifier, ) { UserSwitcher( modifier = Modifier.fillMaxWidth().weight(1f), ) Bouncer( viewModel = viewModel, dialogFactory = dialogFactory, modifier = Modifier.fillMaxWidth().weight(1f), ) } } interface BouncerSceneDialogFactory { Loading Loading
packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt +222 −78 Original line number Diff line number Diff line Loading @@ -14,40 +14,49 @@ * 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.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.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.material3.windowsizeclass.WindowWidthSizeClass 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.android.compose.animation.scene.ElementKey import com.android.compose.animation.scene.SceneScope import com.android.compose.windowsizeclass.LocalWindowSizeClass import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel Loading Loading @@ -103,25 +112,59 @@ private fun SceneScope.BouncerScene( dialogFactory: BouncerSceneDialogFactory, 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) } val backgroundColor = MaterialTheme.colorScheme.surface val windowSizeClass = LocalWindowSizeClass.current Box(modifier) { Canvas(Modifier.element(Bouncer.Elements.Background).fillMaxSize()) { drawRect(color = backgroundColor) } val childModifier = Modifier.element(Bouncer.Elements.Content).fillMaxSize() when (windowSizeClass.widthSizeClass) { WindowWidthSizeClass.Expanded -> SideBySide( viewModel = viewModel, dialogFactory = dialogFactory, modifier = childModifier, ) WindowWidthSizeClass.Medium -> Stacked( viewModel = viewModel, dialogFactory = dialogFactory, modifier = childModifier, ) else -> Bouncer( viewModel = viewModel, dialogFactory = dialogFactory, modifier = childModifier, ) } } } /** * Renders the contents of the actual bouncer UI, the area that takes user input to do an * authentication attempt, including all messaging UI (directives, reasoning, errors, etc.). */ @Composable private fun Bouncer( viewModel: BouncerViewModel, dialogFactory: BouncerSceneDialogFactory, 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) } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(60.dp), modifier = Modifier.element(Bouncer.Elements.Content) .fillMaxSize() .padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 32.dp) modifier = modifier.padding(start = 32.dp, top = 92.dp, end = 32.dp, bottom = 32.dp) ) { Crossfade( targetState = message, Loading Loading @@ -194,6 +237,107 @@ private fun SceneScope.BouncerScene( } } } /** Renders the UI of the user switcher that's displayed on large screens next to the bouncer UI. */ @Composable private fun UserSwitcher( modifier: Modifier = Modifier, ) { Box(modifier) { Text( text = "TODO: the user switcher goes here", modifier = Modifier.align(Alignment.Center) ) } } /** * 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, modifier: Modifier = Modifier, ) { val layoutDirection = LocalLayoutDirection.current val isLeftToRight = layoutDirection == LayoutDirection.Ltr val (isUserSwitcherFirst, setUserSwitcherFirst) = rememberSaveable(isLeftToRight) { mutableStateOf(isLeftToRight) } Row( modifier = modifier.pointerInput(Unit) { 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) } ) }, ) { 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. 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. 1f } else { // Since the user switcher is not first, the elements have to be swapped // horizontally. In the case of RTL locales, this means pushing the user // switcher to the left, hence the negative number. -1f }, label = "offset", ) UserSwitcher( modifier = Modifier.fillMaxHeight().weight(1f).graphicsLayer { translationX = size.width * animatedOffset }, ) Bouncer( viewModel = viewModel, dialogFactory = dialogFactory, modifier = Modifier.fillMaxHeight().weight(1f).graphicsLayer { // A negative sign is used to make sure this is offset in the direction that's // opposite of the direction that the user switcher is pushed in. translationX = -size.width * animatedOffset }, ) } } /** Arranges the bouncer contents and user switcher contents one on top of the other. */ @Composable private fun Stacked( viewModel: BouncerViewModel, dialogFactory: BouncerSceneDialogFactory, modifier: Modifier = Modifier, ) { Column( modifier = modifier, ) { UserSwitcher( modifier = Modifier.fillMaxWidth().weight(1f), ) Bouncer( viewModel = viewModel, dialogFactory = dialogFactory, modifier = Modifier.fillMaxWidth().weight(1f), ) } } interface BouncerSceneDialogFactory { Loading