Merge "[flexiglass] Add nested scrolling to NSSL and notif scrim in Flexiglass" into main
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 0000000..2ba78cf
--- /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 e835d3e..0e08a19 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 @@
 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 @@
 @Composable
 fun SceneScope.NotificationScrollingStack(
     viewModel: NotificationsPlaceholderViewModel,
+    maxScrimTop: () -> Float,
     modifier: Modifier = Modifier,
 ) {
+    val density = LocalDensity.current
     val cornerRadius by viewModel.cornerRadiusDp.collectAsState()
-
-    val contentHeight by viewModel.intrinsicContentHeight.collectAsState()
-
     val expansionFraction by viewModel.expandFraction.collectAsState(0f)
 
-    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() }
+    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 contentHeight = viewModel.intrinsicContentHeight.collectAsState()
+
+    // 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 bbfe0fd..5531f9c 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.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.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.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 @@
     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 @@
                 }
             }
         }
-        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 747faab..770d654 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 @@
 
 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 @@
     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 1545372..497fe87 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.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.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 @@
     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)
-                            }
-                        },
-                    mediaHost = mediaHost,
-                    layoutWidth = layoutWidth.value,
-                    layoutHeight = with(localDensity) { mediaHeight.toPx() }.toInt(),
-                    carouselController = mediaCarouselController,
-                )
-            }
+                            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)
 
-            Spacer(modifier = Modifier.height(16.dp))
-            NotificationScrollingStack(
-                viewModel = viewModel.notifications,
-                modifier = Modifier.fillMaxWidth().weight(1f),
-            )
+                                            // 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,
+                                )
+                            }
+
+                            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)
+
+            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 6a2e317..4d7d5d3 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 @@
         }
 
     @Test
-    fun updateShadeExpansion() =
+    fun shadeExpansion_goneToShade() =
         testScope.runTest {
+            val transitionState =
+                MutableStateFlow<ObservableTransitionState>(
+                    ObservableTransitionState.Idle(scene = SceneKey.Gone)
+                )
+            sceneInteractor.setTransitionState(transitionState)
             val expandFraction by collectLastValue(appearanceViewModel.expandFraction)
             assertThat(expandFraction).isEqualTo(0f)
 
-            val transitionState =
-                MutableStateFlow<ObservableTransitionState>(
-                    ObservableTransitionState.Idle(scene = SceneKey.Lockscreen)
-                )
-            sceneInteractor.setTransitionState(transitionState)
             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 @@
             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 dd04531..b9afb14 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 @@
     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 @@
             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 49fde39..ed26677 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 @@
         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 aac3c28..0197264 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 @@
      * 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 1dfde09..8307397 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 @@
      * 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 @@
     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 ed15f55..6c2cbbe 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.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 @@
                     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 74db583..56ff7f9 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 @@
 
 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 @@
 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 385f061..a436f17 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 @@
     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 f2f3a5a..d79633a 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 @@
 
 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 @@
     NotificationStackAppearanceViewModel(
         stackAppearanceInteractor = notificationStackAppearanceInteractor,
         shadeInteractor = shadeInteractor,
+        sceneInteractor = sceneInteractor,
     )
 }