diff options
14 files changed, 457 insertions, 94 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationScrimNestedScrollConnection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationScrimNestedScrollConnection.kt new file mode 100644 index 000000000000..2ba78cfd7785 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationScrimNestedScrollConnection.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2024 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.systemui.notifications.ui.composable + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import com.android.compose.nestedscroll.PriorityNestedScrollConnection + +/** + * A [NestedScrollConnection] that listens for all vertical scroll events and responds in the + * following way: + * - If you **scroll up**, it **first brings the [scrimOffset]** back to the [minScrimOffset] and + * then allows scrolling of the children (usually the content). + * - If you **scroll down**, it **first allows scrolling of the children** (usually the content) and + * then resets the [scrimOffset] to [maxScrimOffset]. + */ +fun NotificationScrimNestedScrollConnection( + scrimOffset: () -> Float, + onScrimOffsetChanged: (Float) -> Unit, + minScrimOffset: () -> Float, + maxScrimOffset: Float, + contentHeight: () -> Float, + minVisibleScrimHeight: () -> Float, +): PriorityNestedScrollConnection { + return PriorityNestedScrollConnection( + orientation = Orientation.Vertical, + // scrolling up and inner content is taller than the scrim, so scrim needs to + // expand; content can scroll once scrim is at the minScrimOffset. + canStartPreScroll = { offsetAvailable, offsetBeforeStart -> + offsetAvailable < 0 && + offsetBeforeStart == 0f && + contentHeight() > minVisibleScrimHeight() && + scrimOffset() > minScrimOffset() + }, + // scrolling down and content is done scrolling to top. After that, the scrim + // needs to collapse; collapse the scrim until it is at the maxScrimOffset. + canStartPostScroll = { offsetAvailable, _ -> + offsetAvailable > 0 && scrimOffset() < maxScrimOffset + }, + canStartPostFling = { false }, + canContinueScroll = { + val currentHeight = scrimOffset() + minScrimOffset() < currentHeight && currentHeight < maxScrimOffset + }, + canScrollOnFling = true, + onStart = { /* do nothing */}, + onScroll = { offsetAvailable -> + val currentHeight = scrimOffset() + val amountConsumed = + if (offsetAvailable > 0) { + val amountLeft = maxScrimOffset - currentHeight + offsetAvailable.coerceAtMost(amountLeft) + } else { + val amountLeft = minScrimOffset() - currentHeight + offsetAvailable.coerceAtLeast(amountLeft) + } + onScrimOffsetChanged(currentHeight + amountConsumed) + amountConsumed + }, + // Don't consume the velocity on pre/post fling + onStop = { 0f }, + ) +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt index e835d3e576d5..0e08a198c71e 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt @@ -20,32 +20,53 @@ package com.android.systemui.notifications.ui.composable import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.compose.animation.scene.ElementKey +import com.android.compose.animation.scene.NestedScrollBehavior import com.android.compose.animation.scene.SceneScope import com.android.compose.modifiers.height import com.android.systemui.notifications.ui.composable.Notifications.Form +import com.android.systemui.scene.ui.composable.Gone +import com.android.systemui.scene.ui.composable.Shade +import com.android.systemui.shade.ui.composable.ShadeHeader import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationsPlaceholderViewModel import kotlin.math.roundToInt @@ -100,33 +121,109 @@ fun SceneScope.NotificationStack( @Composable fun SceneScope.NotificationScrollingStack( viewModel: NotificationsPlaceholderViewModel, + maxScrimTop: () -> Float, modifier: Modifier = Modifier, ) { + val density = LocalDensity.current val cornerRadius by viewModel.cornerRadiusDp.collectAsState() + val expansionFraction by viewModel.expandFraction.collectAsState(0f) - val contentHeight by viewModel.intrinsicContentHeight.collectAsState() + val navBarHeight = + with(density) { WindowInsets.systemBars.asPaddingValues().calculateBottomPadding().toPx() } + val statusBarHeight = + with(density) { WindowInsets.systemBars.asPaddingValues().calculateTopPadding().toPx() } + val displayCutoutHeight = + with(density) { WindowInsets.displayCutout.asPaddingValues().calculateTopPadding().toPx() } + val screenHeight = + with(density) { LocalConfiguration.current.screenHeightDp.dp.toPx() } + + navBarHeight + + maxOf(statusBarHeight, displayCutoutHeight) - val expansionFraction by viewModel.expandFraction.collectAsState(0f) + val contentHeight = viewModel.intrinsicContentHeight.collectAsState() - Box( - modifier = - modifier - .verticalNestedScrollToScene() - .fillMaxWidth() - .element(Notifications.Elements.NotificationScrim) - .graphicsLayer { - shape = RoundedCornerShape(cornerRadius.dp) - clip = true - alpha = expansionFraction - } - .background(MaterialTheme.colorScheme.surface) - .debugBackground(viewModel, Color(0.5f, 0.5f, 0f, 0.2f)) - ) { - NotificationPlaceholder( - viewModel = viewModel, - form = Form.Stack, - modifier = Modifier.fillMaxWidth().height { contentHeight.roundToInt() } + // the offset for the notifications scrim. Its upper bound is 0, and its lower bound is + // calculated in minScrimOffset. The scrim is the same height as the screen minus the + // height of the Shade Header, and at rest (scrimOffset = 0) its top bound is at maxScrimStartY. + // When fully expanded (scrimOffset = minScrimOffset), its top bound is at minScrimStartY, + // which is equal to the height of the Shade Header. Thus, when the scrim is fully expanded, the + // entire height of the scrim is visible on screen. + val scrimOffset = remember { mutableStateOf(0f) } + + val minScrimTop = with(density) { ShadeHeader.Dimensions.CollapsedHeight.toPx() } + + // The minimum offset for the scrim. The scrim is considered fully expanded when it + // is at this offset. + val minScrimOffset: () -> Float = { minScrimTop - maxScrimTop() } + + // The height of the scrim visible on screen when it is in its resting (collapsed) state. + val minVisibleScrimHeight: () -> Float = { screenHeight - maxScrimTop() } + + // we are not scrolled to the top unless the scrim is at its maximum offset. + LaunchedEffect(viewModel, scrimOffset) { + snapshotFlow { scrimOffset.value >= 0f } + .collect { isScrolledToTop -> viewModel.setScrolledToTop(isScrolledToTop) } + } + + // if contentHeight drops below minimum visible scrim height while scrim is + // expanded, reset scrim offset. + LaunchedEffect(contentHeight, screenHeight, maxScrimTop, scrimOffset) { + snapshotFlow { contentHeight.value < minVisibleScrimHeight() && scrimOffset.value < 0f } + .collect { shouldCollapse -> if (shouldCollapse) scrimOffset.value = 0f } + } + + Box(modifier = modifier.element(Notifications.Elements.NotificationScrim)) { + Spacer( + modifier = + Modifier.fillMaxSize() + .graphicsLayer { + shape = RoundedCornerShape(cornerRadius.dp) + clip = true + } + .drawBehind { drawRect(Color.Black, blendMode = BlendMode.DstOut) } ) + Box( + modifier = + Modifier.fillMaxSize() + .offset { IntOffset(0, scrimOffset.value.roundToInt()) } + .graphicsLayer { + shape = RoundedCornerShape(cornerRadius.dp) + clip = true + alpha = + if (layoutState.isTransitioningBetween(Gone, Shade)) { + (expansionFraction / 0.3f).coerceAtMost(1f) + } else 1f + } + .background(MaterialTheme.colorScheme.surface) + .debugBackground(viewModel, Color(0.5f, 0.5f, 0f, 0.2f)) + ) { + NotificationPlaceholder( + viewModel = viewModel, + form = Form.Stack, + modifier = + Modifier.verticalNestedScrollToScene( + topBehavior = NestedScrollBehavior.EdgeWithPreview, + ) + .nestedScroll( + remember( + scrimOffset, + maxScrimTop, + minScrimTop, + ) { + NotificationScrimNestedScrollConnection( + scrimOffset = { scrimOffset.value }, + onScrimOffsetChanged = { scrimOffset.value = it }, + minScrimOffset = minScrimOffset, + maxScrimOffset = 0f, + contentHeight = { contentHeight.value }, + minVisibleScrimHeight = minVisibleScrimHeight, + ) + } + ) + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .height { (contentHeight.value + navBarHeight).roundToInt() }, + ) + } } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsScene.kt index bbfe0fda049a..5531f9cc5589 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsScene.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsScene.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass @@ -46,6 +47,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp @@ -56,7 +62,7 @@ import com.android.systemui.battery.BatteryMeterViewController import com.android.systemui.compose.modifiers.sysuiResTag import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application -import com.android.systemui.notifications.ui.composable.HeadsUpNotificationSpace +import com.android.systemui.notifications.ui.composable.Notifications import com.android.systemui.qs.footer.ui.compose.FooterActions import com.android.systemui.qs.ui.viewmodel.QuickSettingsSceneViewModel import com.android.systemui.scene.shared.model.SceneKey @@ -116,6 +122,8 @@ private fun SceneScope.QuickSettingsScene( statusBarIconController: StatusBarIconController, modifier: Modifier = Modifier, ) { + val cornerRadius by viewModel.notifications.cornerRadiusDp.collectAsState() + // TODO(b/280887232): implement the real UI. Box(modifier = modifier.fillMaxSize()) { val isCustomizing by viewModel.qsSceneAdapter.isCustomizing.collectAsState() @@ -234,10 +242,32 @@ private fun SceneScope.QuickSettingsScene( } } } - HeadsUpNotificationSpace( - viewModel = viewModel.notifications, - isPeekFromBottom = true, - modifier = Modifier.padding(16.dp).fillMaxSize(), + // Scrim with height 0 aligned to bottom of the screen to facilitate shared element + // transition from Shade scene. + Box( + modifier = + Modifier.element(Notifications.Elements.NotificationScrim) + .fillMaxWidth() + .height(0.dp) + .graphicsLayer { + shape = RoundedCornerShape(cornerRadius.dp) + clip = true + alpha = 1f + } + .background(MaterialTheme.colorScheme.surface) + .align(Alignment.BottomCenter) + .onPlaced { coordinates: LayoutCoordinates -> + viewModel.notifications.onContentTopChanged( + coordinates.positionInWindow().y + ) + val boundsInWindow = coordinates.boundsInWindow() + viewModel.notifications.onBoundsChanged( + left = boundsInWindow.left, + top = boundsInWindow.top, + right = boundsInWindow.right, + bottom = boundsInWindow.bottom, + ) + } ) } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/GoneScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/GoneScene.kt index 747faabe514b..770d654a4c88 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/GoneScene.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/GoneScene.kt @@ -18,13 +18,10 @@ package com.android.systemui.scene.ui.composable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.android.compose.animation.scene.SceneScope import com.android.systemui.dagger.SysUISingleton -import com.android.systemui.notifications.ui.composable.HeadsUpNotificationSpace import com.android.systemui.notifications.ui.composable.Notifications import com.android.systemui.scene.shared.model.Direction import com.android.systemui.scene.shared.model.Edge @@ -66,12 +63,6 @@ constructor( override fun SceneScope.Content( modifier: Modifier, ) { - Box(modifier = modifier) { - Box(modifier = Modifier.fillMaxSize().element(Notifications.Elements.NotificationScrim)) - HeadsUpNotificationSpace( - viewModel = notificationsViewModel, - modifier = Modifier.padding(16.dp).fillMaxSize(), - ) - } + Box(modifier = Modifier.fillMaxSize().element(Notifications.Elements.NotificationScrim)) } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt index 1545372686c9..497fe873e87d 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.dimensionResource @@ -62,6 +63,7 @@ import com.android.systemui.statusbar.phone.StatusBarLocation import com.android.systemui.util.animation.MeasurementInput import javax.inject.Inject import javax.inject.Named +import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -154,62 +156,104 @@ private fun SceneScope.ShadeScene( mediaHost: MediaHost, modifier: Modifier = Modifier, ) { - val localDensity = LocalDensity.current + val density = LocalDensity.current val layoutWidth = remember { mutableStateOf(0) } + val maxNotifScrimTop = remember { mutableStateOf(0f) } Box( modifier = modifier.element(Shade.Elements.Scrim).background(MaterialTheme.colorScheme.scrim), ) Box { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth().clickable(onClick = { viewModel.onContentClicked() }) - ) { - CollapsedShadeHeader( - viewModel = viewModel.shadeHeaderViewModel, - createTintedIconManager = createTintedIconManager, - createBatteryMeterViewController = createBatteryMeterViewController, - statusBarIconController = statusBarIconController, - modifier = Modifier.padding(horizontal = Shade.Dimensions.HorizontalPadding) - ) - QuickSettings( - modifier = Modifier.height(130.dp), - viewModel.qsSceneAdapter, - ) - - if (viewModel.isMediaVisible()) { - val mediaHeight = dimensionResource(R.dimen.qs_media_session_height_expanded) - MediaCarousel( - modifier = - Modifier.height(mediaHeight).fillMaxWidth().layout { measurable, constraints - -> - val placeable = measurable.measure(constraints) - - // Notify controller to size the carousel for the current space - mediaHost.measurementInput = - MeasurementInput(placeable.width, placeable.height) - mediaCarouselController.setSceneContainerSize( - placeable.width, - placeable.height + Layout( + contents = + listOf( + { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = { viewModel.onContentClicked() }) + ) { + CollapsedShadeHeader( + viewModel = viewModel.shadeHeaderViewModel, + createTintedIconManager = createTintedIconManager, + createBatteryMeterViewController = createBatteryMeterViewController, + statusBarIconController = statusBarIconController, + modifier = + Modifier.padding( + horizontal = Shade.Dimensions.HorizontalPadding + ) + ) + QuickSettings( + modifier = Modifier.height(130.dp), + viewModel.qsSceneAdapter, ) - layout(placeable.width, placeable.height) { - placeable.placeRelative(0, 0) + if (viewModel.isMediaVisible()) { + val mediaHeight = + dimensionResource(R.dimen.qs_media_session_height_expanded) + MediaCarousel( + modifier = + Modifier.height(mediaHeight).fillMaxWidth().layout { + measurable, + constraints -> + val placeable = measurable.measure(constraints) + + // Notify controller to size the carousel for the + // current space + mediaHost.measurementInput = + MeasurementInput(placeable.width, placeable.height) + mediaCarouselController.setSceneContainerSize( + placeable.width, + placeable.height + ) + + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + }, + mediaHost = mediaHost, + layoutWidth = layoutWidth.value, + layoutHeight = with(density) { mediaHeight.toPx() }.toInt(), + carouselController = mediaCarouselController, + ) } - }, - mediaHost = mediaHost, - layoutWidth = layoutWidth.value, - layoutHeight = with(localDensity) { mediaHeight.toPx() }.toInt(), - carouselController = mediaCarouselController, + + Spacer(modifier = Modifier.height(16.dp)) + } + }, + { + NotificationScrollingStack( + viewModel = viewModel.notifications, + maxScrimTop = { maxNotifScrimTop.value }, + ) + }, ) - } + ) { measurables, constraints -> + check(measurables.size == 2) + check(measurables[0].size == 1) + check(measurables[1].size == 1) - Spacer(modifier = Modifier.height(16.dp)) - NotificationScrollingStack( - viewModel = viewModel.notifications, - modifier = Modifier.fillMaxWidth().weight(1f), - ) + val quickSettingsPlaceable = measurables[0][0].measure(constraints) + + val notificationsMeasurable = measurables[1][0] + val notificationsScrimMaxHeight = + constraints.maxHeight - ShadeHeader.Dimensions.CollapsedHeight.roundToPx() + val notificationsPlaceable = + notificationsMeasurable.measure( + constraints.copy( + minHeight = notificationsScrimMaxHeight, + maxHeight = notificationsScrimMaxHeight + ) + ) + + maxNotifScrimTop.value = quickSettingsPlaceable.height.toFloat() + + layout(constraints.maxWidth, constraints.maxHeight) { + quickSettingsPlaceable.placeRelative(x = 0, y = 0) + notificationsPlaceable.placeRelative(x = 0, y = maxNotifScrimTop.value.roundToInt()) + } } } } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/NotificationStackAppearanceIntegrationTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/NotificationStackAppearanceIntegrationTest.kt index 6a2e31739c77..4d7d5d3fa664 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/NotificationStackAppearanceIntegrationTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/NotificationStackAppearanceIntegrationTest.kt @@ -87,21 +87,21 @@ class NotificationStackAppearanceIntegrationTest : SysuiTestCase() { } @Test - fun updateShadeExpansion() = + fun shadeExpansion_goneToShade() = testScope.runTest { - val expandFraction by collectLastValue(appearanceViewModel.expandFraction) - assertThat(expandFraction).isEqualTo(0f) - val transitionState = MutableStateFlow<ObservableTransitionState>( - ObservableTransitionState.Idle(scene = SceneKey.Lockscreen) + ObservableTransitionState.Idle(scene = SceneKey.Gone) ) sceneInteractor.setTransitionState(transitionState) + val expandFraction by collectLastValue(appearanceViewModel.expandFraction) + assertThat(expandFraction).isEqualTo(0f) + sceneInteractor.changeScene(SceneModel(SceneKey.Shade), "reason") val transitionProgress = MutableStateFlow(0f) transitionState.value = ObservableTransitionState.Transition( - fromScene = SceneKey.Lockscreen, + fromScene = SceneKey.Gone, toScene = SceneKey.Shade, progress = transitionProgress, isInitiatedByUserInput = false, @@ -118,4 +118,49 @@ class NotificationStackAppearanceIntegrationTest : SysuiTestCase() { sceneInteractor.onSceneChanged(SceneModel(SceneKey.Shade), "reason") assertThat(expandFraction).isWithin(0.01f).of(1f) } + + @Test + fun shadeExpansion_idleOnLockscreen() = + testScope.runTest { + val transitionState = + MutableStateFlow<ObservableTransitionState>( + ObservableTransitionState.Idle(scene = SceneKey.Lockscreen) + ) + sceneInteractor.setTransitionState(transitionState) + val expandFraction by collectLastValue(appearanceViewModel.expandFraction) + assertThat(expandFraction).isEqualTo(1f) + } + + @Test + fun shadeExpansion_shadeToQs() = + testScope.runTest { + val transitionState = + MutableStateFlow<ObservableTransitionState>( + ObservableTransitionState.Idle(scene = SceneKey.Shade) + ) + sceneInteractor.setTransitionState(transitionState) + val expandFraction by collectLastValue(appearanceViewModel.expandFraction) + assertThat(expandFraction).isEqualTo(1f) + + sceneInteractor.changeScene(SceneModel(SceneKey.QuickSettings), "reason") + val transitionProgress = MutableStateFlow(0f) + transitionState.value = + ObservableTransitionState.Transition( + fromScene = SceneKey.Shade, + toScene = SceneKey.QuickSettings, + progress = transitionProgress, + isInitiatedByUserInput = false, + isUserInputOngoing = flowOf(false), + ) + val steps = 10 + repeat(steps) { repetition -> + val progress = (1f / steps) * (repetition + 1) + transitionProgress.value = progress + runCurrent() + assertThat(expandFraction).isEqualTo(1f) + } + + sceneInteractor.onSceneChanged(SceneModel(SceneKey.QuickSettings), "reason") + assertThat(expandFraction).isEqualTo(1f) + } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java index 04db653282ac..90f2cd8af8d5 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayout.java @@ -617,7 +617,11 @@ public class NotificationStackScrollLayout extends ViewGroup implements Dumpable private final ScrollAdapter mScrollAdapter = new ScrollAdapter() { @Override public boolean isScrolledToTop() { - return mOwnScrollY == 0; + if (SceneContainerFlag.isEnabled()) { + return mController.isPlaceholderScrolledToTop(); + } else { + return mOwnScrollY == 0; + } } @Override @@ -1442,7 +1446,14 @@ public class NotificationStackScrollLayout extends ViewGroup implements Dumpable fraction = BouncerPanelExpansionCalculator.aboutToShowBouncerProgress(fraction); } final float stackY = MathUtils.lerp(0, endTopPosition, fraction); - mAmbientState.setStackY(stackY); + // TODO(b/322228881): Clean up scene container vs legacy behavior in NSSL + if (SceneContainerFlag.isEnabled()) { + // stackY should be driven by scene container, not NSSL + mAmbientState.setStackY(mTopPadding); + } else { + mAmbientState.setStackY(stackY); + } + if (mOnStackYChanged != null) { mOnStackYChanged.accept(listenerNeedsAnimation); } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutController.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutController.java index 1143481863f5..c4b5dc351120 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutController.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationStackScrollLayoutController.java @@ -1144,6 +1144,14 @@ public class NotificationStackScrollLayoutController implements Dumpable { return mStackAppearanceInteractor.getStackBounds().getValue().getTop(); } + /** + * Returns whether the notification stack is scrolled to the top; i.e., it cannot be scrolled + * down any further. + */ + public boolean isPlaceholderScrolledToTop() { + return mStackAppearanceInteractor.getScrolledToTop().getValue(); + } + /** Set the intrinsic height of the stack content without additional padding. */ public void setIntrinsicContentHeight(float intrinsicContentHeight) { mStackAppearanceInteractor.setIntrinsicContentHeight(intrinsicContentHeight); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/data/repository/NotificationStackAppearanceRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/data/repository/NotificationStackAppearanceRepository.kt index aac3c28a3426..01972646f394 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/data/repository/NotificationStackAppearanceRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/data/repository/NotificationStackAppearanceRepository.kt @@ -44,4 +44,10 @@ class NotificationStackAppearanceRepository @Inject constructor() { * screen. */ val contentTop = MutableStateFlow(0f) + + /** + * Whether the notification stack is scrolled to the top; i.e., it cannot be scrolled down any + * further. + */ + val scrolledToTop = MutableStateFlow(true) } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractor.kt index 1dfde09f3a85..8307397c57da 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/domain/interactor/NotificationStackAppearanceInteractor.kt @@ -42,10 +42,16 @@ constructor( * notifications, this can exceed the space available on screen to show notifications, at which * point the notification stack should become scrollable. */ - val intrinsicContentHeight = repository.intrinsicContentHeight.asStateFlow() + val intrinsicContentHeight: StateFlow<Float> = repository.intrinsicContentHeight.asStateFlow() /** The y-coordinate in px of top of the contents of the notification stack. */ - val contentTop = repository.contentTop.asStateFlow() + val contentTop: StateFlow<Float> = repository.contentTop.asStateFlow() + + /** + * Whether the notification stack is scrolled to the top; i.e., it cannot be scrolled down any + * further. + */ + val scrolledToTop: StateFlow<Boolean> = repository.scrolledToTop.asStateFlow() /** Sets the position of the notification stack in the current scene. */ fun setStackBounds(bounds: NotificationContainerBounds) { @@ -62,4 +68,9 @@ constructor( fun setContentTop(startY: Float) { repository.contentTop.value = startY } + + /** Sets whether the notification stack is scrolled to the top. */ + fun setScrolledToTop(scrolledToTop: Boolean) { + repository.scrolledToTop.value = scrolledToTop + } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationStackAppearanceViewBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationStackAppearanceViewBinder.kt index ed15f557fb39..6c2cbbecb477 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationStackAppearanceViewBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationStackAppearanceViewBinder.kt @@ -25,7 +25,6 @@ import com.android.systemui.statusbar.notification.stack.AmbientState import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayoutController import com.android.systemui.statusbar.notification.stack.ui.view.SharedNotificationContainer import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationStackAppearanceViewModel -import kotlin.math.pow import kotlin.math.roundToInt import kotlinx.coroutines.launch @@ -65,7 +64,9 @@ object NotificationStackAppearanceViewBinder { viewModel.expandFraction.collect { expandFraction -> ambientState.expansionFraction = expandFraction controller.expandedHeight = expandFraction * controller.view.height - controller.setMaxAlphaForExpansion(expandFraction.pow(0.75f)) + controller.setMaxAlphaForExpansion( + ((expandFraction - 0.5f) / 0.5f).coerceAtLeast(0f) + ) } } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModel.kt index 74db5831f7f8..56ff7f9e50df 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModel.kt @@ -19,11 +19,15 @@ package com.android.systemui.statusbar.notification.stack.ui.viewmodel import com.android.systemui.common.shared.model.NotificationContainerBounds import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.android.systemui.scene.shared.model.ObservableTransitionState +import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.shade.domain.interactor.ShadeInteractor import com.android.systemui.statusbar.notification.stack.domain.interactor.NotificationStackAppearanceInteractor import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine /** ViewModel which represents the state of the NSSL/Controller in the world of flexiglass */ @SysUISingleton @@ -32,9 +36,40 @@ class NotificationStackAppearanceViewModel constructor( stackAppearanceInteractor: NotificationStackAppearanceInteractor, shadeInteractor: ShadeInteractor, + sceneInteractor: SceneInteractor, ) { - /** The expansion fraction from the top of the notification shade. */ - val expandFraction: Flow<Float> = shadeInteractor.shadeExpansion + /** + * The expansion fraction of the notification stack. It should go from 0 to 1 when transitioning + * from Gone to Shade scenes, and remain at 1 when in Lockscreen or Shade scenes and while + * transitioning from Shade to QuickSettings scenes. + */ + val expandFraction: Flow<Float> = + combine( + shadeInteractor.shadeExpansion, + sceneInteractor.transitionState, + ) { shadeExpansion, transitionState -> + when (transitionState) { + is ObservableTransitionState.Idle -> { + if (transitionState.scene == SceneKey.Lockscreen) { + 1f + } else { + shadeExpansion + } + } + is ObservableTransitionState.Transition -> { + if ( + (transitionState.fromScene == SceneKey.Shade && + transitionState.toScene == SceneKey.QuickSettings) || + (transitionState.fromScene == SceneKey.QuickSettings && + transitionState.toScene == SceneKey.Shade) + ) { + 1f + } else { + shadeExpansion + } + } + } + } /** The bounds of the notification stack in the current scene. */ val stackBounds: Flow<NotificationContainerBounds> = stackAppearanceInteractor.stackBounds diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModel.kt index 385f0619288d..a436f1783a0c 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationsPlaceholderViewModel.kt @@ -87,4 +87,9 @@ constructor( fun onContentTopChanged(padding: Float) { interactor.setContentTop(padding) } + + /** Sets whether the notification stack is scrolled to the top. */ + fun setScrolledToTop(scrolledToTop: Boolean) { + interactor.setScrolledToTop(scrolledToTop) + } } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModelKosmos.kt index f2f3a5a1ad72..d79633ae72ba 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModelKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationStackAppearanceViewModelKosmos.kt @@ -18,6 +18,7 @@ package com.android.systemui.statusbar.notification.stack.ui.viewmodel import com.android.systemui.kosmos.Kosmos import com.android.systemui.kosmos.Kosmos.Fixture +import com.android.systemui.scene.domain.interactor.sceneInteractor import com.android.systemui.shade.domain.interactor.shadeInteractor import com.android.systemui.statusbar.notification.stack.domain.interactor.notificationStackAppearanceInteractor @@ -25,5 +26,6 @@ val Kosmos.notificationStackAppearanceViewModel by Fixture { NotificationStackAppearanceViewModel( stackAppearanceInteractor = notificationStackAppearanceInteractor, shadeInteractor = shadeInteractor, + sceneInteractor = sceneInteractor, ) } |