• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  *
16  */
17 
18 package com.android.systemui.notifications.ui.composable
19 
20 import android.util.Log
21 import androidx.compose.animation.core.Animatable
22 import androidx.compose.animation.core.AnimationVector1D
23 import androidx.compose.animation.core.DecayAnimationSpec
24 import androidx.compose.animation.core.tween
25 import androidx.compose.animation.splineBasedDecay
26 import androidx.compose.foundation.ScrollState
27 import androidx.compose.foundation.background
28 import androidx.compose.foundation.clickable
29 import androidx.compose.foundation.gestures.Orientation
30 import androidx.compose.foundation.gestures.animateScrollBy
31 import androidx.compose.foundation.gestures.rememberScrollableState
32 import androidx.compose.foundation.gestures.scrollBy
33 import androidx.compose.foundation.gestures.scrollable
34 import androidx.compose.foundation.layout.Box
35 import androidx.compose.foundation.layout.Column
36 import androidx.compose.foundation.layout.ExperimentalLayoutApi
37 import androidx.compose.foundation.layout.Spacer
38 import androidx.compose.foundation.layout.WindowInsets
39 import androidx.compose.foundation.layout.absoluteOffset
40 import androidx.compose.foundation.layout.asPaddingValues
41 import androidx.compose.foundation.layout.fillMaxSize
42 import androidx.compose.foundation.layout.fillMaxWidth
43 import androidx.compose.foundation.layout.height
44 import androidx.compose.foundation.layout.imeAnimationTarget
45 import androidx.compose.foundation.layout.offset
46 import androidx.compose.foundation.layout.padding
47 import androidx.compose.foundation.layout.safeDrawing
48 import androidx.compose.foundation.layout.systemBars
49 import androidx.compose.foundation.layout.windowInsetsBottomHeight
50 import androidx.compose.foundation.overscroll
51 import androidx.compose.foundation.shape.RoundedCornerShape
52 import androidx.compose.foundation.verticalScroll
53 import androidx.compose.material3.MaterialTheme
54 import androidx.compose.runtime.Composable
55 import androidx.compose.runtime.DisposableEffect
56 import androidx.compose.runtime.LaunchedEffect
57 import androidx.compose.runtime.derivedStateOf
58 import androidx.compose.runtime.getValue
59 import androidx.compose.runtime.mutableFloatStateOf
60 import androidx.compose.runtime.mutableIntStateOf
61 import androidx.compose.runtime.mutableStateOf
62 import androidx.compose.runtime.remember
63 import androidx.compose.runtime.setValue
64 import androidx.compose.runtime.snapshotFlow
65 import androidx.compose.ui.Alignment
66 import androidx.compose.ui.Modifier
67 import androidx.compose.ui.draw.drawBehind
68 import androidx.compose.ui.geometry.Rect
69 import androidx.compose.ui.graphics.BlendMode
70 import androidx.compose.ui.graphics.Color
71 import androidx.compose.ui.graphics.graphicsLayer
72 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
73 import androidx.compose.ui.input.nestedscroll.nestedScroll
74 import androidx.compose.ui.layout.LayoutCoordinates
75 import androidx.compose.ui.layout.boundsInWindow
76 import androidx.compose.ui.layout.onGloballyPositioned
77 import androidx.compose.ui.layout.onSizeChanged
78 import androidx.compose.ui.layout.positionInWindow
79 import androidx.compose.ui.platform.LocalConfiguration
80 import androidx.compose.ui.platform.LocalDensity
81 import androidx.compose.ui.platform.LocalView
82 import androidx.compose.ui.res.dimensionResource
83 import androidx.compose.ui.unit.Dp
84 import androidx.compose.ui.unit.IntOffset
85 import androidx.compose.ui.unit.Velocity
86 import androidx.compose.ui.unit.dp
87 import androidx.compose.ui.util.lerp
88 import androidx.lifecycle.compose.collectAsStateWithLifecycle
89 import com.android.compose.animation.scene.ContentKey
90 import com.android.compose.animation.scene.ContentScope
91 import com.android.compose.animation.scene.ElementKey
92 import com.android.compose.animation.scene.LowestZIndexContentPicker
93 import com.android.compose.animation.scene.SceneTransitionLayoutState
94 import com.android.compose.animation.scene.content.state.TransitionState
95 import com.android.compose.gesture.NestedScrollableBound
96 import com.android.compose.gesture.effect.OffsetOverscrollEffect
97 import com.android.compose.gesture.effect.rememberOffsetOverscrollEffect
98 import com.android.compose.modifiers.thenIf
99 import com.android.internal.jank.InteractionJankMonitor
100 import com.android.internal.jank.InteractionJankMonitor.CUJ_NOTIFICATION_SHADE_SCROLL_FLING
101 import com.android.systemui.common.ui.compose.windowinsets.LocalScreenCornerRadius
102 import com.android.systemui.res.R
103 import com.android.systemui.scene.session.ui.composable.SaveableSession
104 import com.android.systemui.scene.session.ui.composable.rememberSession
105 import com.android.systemui.scene.session.ui.composable.sessionCoroutineScope
106 import com.android.systemui.scene.shared.model.Scenes
107 import com.android.systemui.shade.ui.composable.ShadeHeader
108 import com.android.systemui.statusbar.notification.stack.shared.model.AccessibilityScrollEvent
109 import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimBounds
110 import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimRounding
111 import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrollState
112 import com.android.systemui.statusbar.notification.stack.ui.view.NotificationScrollView
113 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationTransitionThresholds.EXPANSION_FOR_MAX_CORNER_RADIUS
114 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationTransitionThresholds.EXPANSION_FOR_MAX_SCRIM_ALPHA
115 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationsPlaceholderViewModel
116 import kotlin.math.max
117 import kotlin.math.roundToInt
118 import kotlinx.coroutines.awaitCancellation
119 import kotlinx.coroutines.coroutineScope
120 import kotlinx.coroutines.launch
121 
122 object Notifications {
123     object Elements {
124         val NotificationScrim = ElementKey("NotificationScrim")
125         val NotificationStackPlaceholder = ElementKey("NotificationStackPlaceholder")
126         val HeadsUpNotificationPlaceholder =
127             ElementKey("HeadsUpNotificationPlaceholder", contentPicker = LowestZIndexContentPicker)
128         val NotificationStackCutoffGuideline = ElementKey("NotificationStackCutoffGuideline")
129     }
130 }
131 
132 /**
133  * Adds the space where heads up notifications can appear in the scene. This should generally be the
134  * entire size of the scene.
135  */
136 @Composable
HeadsUpNotificationSpacenull137 fun ContentScope.HeadsUpNotificationSpace(
138     stackScrollView: NotificationScrollView,
139     viewModel: NotificationsPlaceholderViewModel,
140     useHunBounds: () -> Boolean = { true },
141     modifier: Modifier = Modifier,
142 ) {
143     Box(
144         modifier =
145             modifier
146                 .element(Notifications.Elements.HeadsUpNotificationPlaceholder)
147                 .fillMaxWidth()
148                 .notificationHeadsUpHeight(stackScrollView)
149                 .debugBackground(viewModel, DEBUG_HUN_COLOR)
coordinatesnull150                 .onGloballyPositioned { coordinates: LayoutCoordinates ->
151                     // This element is sometimes opted out of the shared element system, so there
152                     // can be multiple instances of it during a transition. Thus we need to
153                     // determine which instance should feed its bounds to NSSL to avoid providing
154                     // conflicting values.
155                     val useBounds = useHunBounds()
156                     if (useBounds) {
157                         val positionInWindow = coordinates.positionInWindow()
158                         val boundsInWindow = coordinates.boundsInWindow()
159                         debugLog(viewModel) {
160                             "HUNS onGloballyPositioned:" +
161                                 " size=${coordinates.size}" +
162                                 " bounds=$boundsInWindow"
163                         }
164                         // Note: boundsInWindow doesn't scroll off the screen, so use
165                         // positionInWindow for top bound, which can scroll off screen while
166                         // snoozing.
167                         stackScrollView.setHeadsUpTop(positionInWindow.y)
168                         stackScrollView.setHeadsUpBottom(boundsInWindow.bottom)
169                     }
170                 }
171     )
172 }
173 
174 /**
175  * A version of [HeadsUpNotificationSpace] that can be swiped up off the top edge of the screen by
176  * the user. When swiped up, the heads up notification is snoozed.
177  */
178 @Composable
ContentScopenull179 fun ContentScope.SnoozeableHeadsUpNotificationSpace(
180     stackScrollView: NotificationScrollView,
181     viewModel: NotificationsPlaceholderViewModel,
182 ) {
183 
184     val isSnoozable by viewModel.isHeadsUpOrAnimatingAway.collectAsStateWithLifecycle(false)
185 
186     var scrollOffset by remember { mutableFloatStateOf(0f) }
187     val headsUpInset = with(LocalDensity.current) { headsUpTopInset().toPx() }
188     val minScrollOffset = -headsUpInset
189     val maxScrollOffset = 0f
190 
191     val scrollableState = rememberScrollableState { delta ->
192         consumeDeltaWithinRange(
193             current = scrollOffset,
194             setCurrent = { scrollOffset = it },
195             min = minScrollOffset,
196             max = maxScrollOffset,
197             delta,
198         )
199     }
200 
201     val snoozeScrollConnection =
202         object : NestedScrollConnection {
203             override suspend fun onPreFling(available: Velocity): Velocity {
204                 if (
205                     velocityOrPositionalThresholdReached(scrollOffset, minScrollOffset, available.y)
206                 ) {
207                     scrollableState.animateScrollBy(minScrollOffset, tween())
208                 } else {
209                     scrollableState.animateScrollBy(-minScrollOffset, tween())
210                 }
211                 return available
212             }
213         }
214 
215     LaunchedEffect(isSnoozable) { scrollOffset = 0f }
216 
217     LaunchedEffect(scrollableState.isScrollInProgress) {
218         if (!scrollableState.isScrollInProgress && scrollOffset <= minScrollOffset) {
219             viewModel.setHeadsUpAnimatingAway(false)
220             viewModel.snoozeHun()
221         }
222     }
223 
224     HeadsUpNotificationSpace(
225         stackScrollView = stackScrollView,
226         viewModel = viewModel,
227         modifier =
228             Modifier.absoluteOffset {
229                     IntOffset(
230                         x = 0,
231                         y =
232                             calculateHeadsUpPlaceholderYOffset(
233                                 scrollOffset.roundToInt(),
234                                 minScrollOffset.roundToInt(),
235                                 stackScrollView.topHeadsUpHeight,
236                             ),
237                     )
238                 }
239                 .thenIf(isSnoozable) { Modifier.nestedScroll(snoozeScrollConnection) }
240                 .scrollable(orientation = Orientation.Vertical, state = scrollableState),
241     )
242 }
243 
244 /** Y position of the HUNs at rest, when the shade is closed. */
245 @Composable
headsUpTopInsetnull246 fun headsUpTopInset(): Dp =
247     WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() +
248         dimensionResource(R.dimen.heads_up_status_bar_padding)
249 
250 /** Adds the space where notification stack should appear in the scene. */
251 @Composable
252 fun ContentScope.ConstrainedNotificationStack(
253     stackScrollView: NotificationScrollView,
254     viewModel: NotificationsPlaceholderViewModel,
255     modifier: Modifier = Modifier,
256 ) {
257     Box(
258         modifier =
259             modifier.onSizeChanged { viewModel.onConstrainedAvailableSpaceChanged(it.height) }
260     ) {
261         NotificationPlaceholder(
262             stackScrollView = stackScrollView,
263             viewModel = viewModel,
264             useStackBounds = { shouldUseLockscreenStackBounds(layoutState.transitionState) },
265             modifier = Modifier.fillMaxSize(),
266         )
267         HeadsUpNotificationSpace(
268             stackScrollView = stackScrollView,
269             viewModel = viewModel,
270             useHunBounds = {
271                 shouldUseLockscreenHunBounds(
272                     layoutState.transitionState,
273                     viewModel.quickSettingsShadeContentKey,
274                 )
275             },
276             modifier = Modifier.align(Alignment.TopCenter),
277         )
278         NotificationStackCutoffGuideline(
279             stackScrollView = stackScrollView,
280             viewModel = viewModel,
281             modifier = Modifier.align(Alignment.BottomCenter),
282         )
283     }
284 }
285 
286 /**
287  * Adds the space where notification stack should appear in the scene, with a scrim and nested
288  * scrolling.
289  */
290 @OptIn(ExperimentalLayoutApi::class)
291 @Composable
ContentScopenull292 fun ContentScope.NotificationScrollingStack(
293     shadeSession: SaveableSession,
294     stackScrollView: NotificationScrollView,
295     viewModel: NotificationsPlaceholderViewModel,
296     jankMonitor: InteractionJankMonitor,
297     maxScrimTop: () -> Float,
298     shouldPunchHoleBehindScrim: Boolean,
299     stackTopPadding: Dp,
300     stackBottomPadding: Dp,
301     modifier: Modifier = Modifier,
302     shouldFillMaxSize: Boolean = true,
303     shouldIncludeHeadsUpSpace: Boolean = true,
304     shouldShowScrim: Boolean = true,
305     supportNestedScrolling: Boolean,
306     onEmptySpaceClick: (() -> Unit)? = null,
307 ) {
308     val composeViewRoot = LocalView.current
309     val coroutineScope = shadeSession.sessionCoroutineScope()
310     val density = LocalDensity.current
311     val screenCornerRadius = LocalScreenCornerRadius.current
312     val scrimCornerRadius = dimensionResource(R.dimen.notification_scrim_corner_radius)
313     val scrimBackgroundColor = MaterialTheme.colorScheme.surface
314     val scrollState =
315         shadeSession.rememberSaveableSession(saver = ScrollState.Saver, key = null) {
316             ScrollState(initial = 0)
317         }
318     val syntheticScroll = viewModel.syntheticScroll.collectAsStateWithLifecycle(0f)
319     val expansionFraction by viewModel.expandFraction.collectAsStateWithLifecycle(0f)
320     val shadeToQsFraction by viewModel.shadeToQsFraction.collectAsStateWithLifecycle(0f)
321 
322     val navBarHeight = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding()
323     val screenHeight = with(density) { LocalConfiguration.current.screenHeightDp.dp.toPx() }
324 
325     /**
326      * The height in px of the contents of notification stack. Depending on the number of
327      * notifications, this can exceed the space available on screen to show notifications, at which
328      * point the notification stack should become scrollable.
329      */
330     val stackHeight = remember { mutableIntStateOf(0) }
331 
332     /**
333      * Space available for the notification stack on the screen. These bounds don't scroll off the
334      * screen, and respect the scrim paddings, scrim clipping.
335      */
336     val stackBoundsOnScreen = remember { mutableStateOf(Rect.Zero) }
337 
338     val scrimRounding =
339         viewModel.shadeScrimRounding.collectAsStateWithLifecycle(ShadeScrimRounding())
340 
341     // the offset for the notifications scrim. Its upper bound is 0, and its lower bound is
342     // calculated in minScrimOffset. The scrim is the same height as the screen minus the
343     // height of the Shade Header, and at rest (scrimOffset = 0) its top bound is at maxScrimStartY.
344     // When fully expanded (scrimOffset = minScrimOffset), its top bound is at minScrimStartY,
345     // which is equal to the height of the Shade Header. Thus, when the scrim is fully expanded, the
346     // entire height of the scrim is visible on screen.
347     val scrimOffset = shadeSession.rememberSession { Animatable(0f) }
348 
349     // set the bounds to null when the scrim disappears
350     DisposableEffect(Unit) { onDispose { viewModel.onScrimBoundsChanged(null) } }
351 
352     // Top position if the scrim, when it is fully expanded.
353     val minScrimTop = ShadeHeader.Dimensions.CollapsedHeight
354 
355     // The minimum offset for the scrim. The scrim is considered fully expanded when it
356     // is at this offset.
357     val minScrimOffset: () -> Float = { with(density) { minScrimTop.toPx() } - maxScrimTop() }
358 
359     // The height of the scrim visible on screen when it is in its resting (collapsed) state.
360     val minVisibleScrimHeight: () -> Float = {
361         screenHeight - maxScrimTop() - with(density) { navBarHeight.toPx() }
362     }
363 
364     val isRemoteInputActive by viewModel.isRemoteInputActive.collectAsStateWithLifecycle(false)
365 
366     // The bottom Y bound of the currently focused remote input notification.
367     val remoteInputRowBottom by viewModel.remoteInputRowBottomBound.collectAsStateWithLifecycle(0f)
368 
369     // The top y bound of the IME.
370     val imeTop = remember { mutableFloatStateOf(0f) }
371 
372     val shadeScrollState by remember {
373         derivedStateOf {
374             ShadeScrollState(
375                 // we are not scrolled to the top unless the scroll position is zero,
376                 // and the scrim is at its maximum offset
377                 isScrolledToTop = scrimOffset.value >= 0f && scrollState.value == 0,
378                 scrollPosition = scrollState.value,
379                 maxScrollPosition = scrollState.maxValue,
380             )
381         }
382     }
383 
384     LaunchedEffect(shadeScrollState) { viewModel.setScrollState(shadeScrollState) }
385 
386     // if contentHeight drops below minimum visible scrim height while scrim is
387     // expanded and IME is not showing, reset scrim offset.
388     LaunchedEffect(stackHeight, scrimOffset, imeTop) {
389         snapshotFlow {
390                 stackHeight.intValue < minVisibleScrimHeight() &&
391                     scrimOffset.value < 0f &&
392                     imeTop.floatValue <= 0f
393             }
394             .collect { shouldCollapse -> if (shouldCollapse) scrimOffset.animateTo(0f, tween()) }
395     }
396 
397     // if we receive scroll delta from NSSL, offset the scrim and placeholder accordingly.
398     LaunchedEffect(syntheticScroll, scrimOffset, scrollState) {
399         snapshotFlow { syntheticScroll.value }
400             .collect { delta ->
401                 scrollNotificationStack(
402                     delta = delta,
403                     animate = false,
404                     scrimOffset = scrimOffset,
405                     minScrimOffset = minScrimOffset,
406                     scrollState = scrollState,
407                 )
408             }
409     }
410 
411     // if remote input state changes, compare the row and IME's overlap and offset the scrim and
412     // placeholder accordingly.
413     LaunchedEffect(isRemoteInputActive, remoteInputRowBottom, imeTop) {
414         imeTop.floatValue = 0f
415         snapshotFlow { imeTop.floatValue }
416             .collect { imeTopValue ->
417                 // only scroll the stack if ime value has been populated (ime placeholder has been
418                 // composed at least once), and our remote input row overlaps with the ime bounds.
419                 if (isRemoteInputActive && imeTopValue > 0f && remoteInputRowBottom > imeTopValue) {
420                     scrollNotificationStack(
421                         delta = remoteInputRowBottom - imeTopValue,
422                         animate = true,
423                         scrimOffset = scrimOffset,
424                         minScrimOffset = minScrimOffset,
425                         scrollState = scrollState,
426                     )
427                 }
428             }
429     }
430 
431     // TalkBack sends a scroll event, when it wants to navigate to an item that is not displayed in
432     // the current viewport.
433     LaunchedEffect(viewModel) {
434         viewModel.setAccessibilityScrollEventConsumer { event ->
435             // scroll up, or down by the height of the visible portion of the notification stack
436             val direction =
437                 when (event) {
438                     AccessibilityScrollEvent.SCROLL_UP -> -1
439                     AccessibilityScrollEvent.SCROLL_DOWN -> 1
440                 }
441             val viewPortHeight = stackBoundsOnScreen.value.height
442             val scrollStep = max(0f, viewPortHeight - stackScrollView.stackBottomInset)
443             val scrollPosition = scrollState.value.toFloat()
444             val scrollRange = scrollState.maxValue.toFloat()
445             val targetScroll = (scrollPosition + direction * scrollStep).coerceIn(0f, scrollRange)
446             coroutineScope.launch {
447                 scrollNotificationStack(
448                     delta = targetScroll - scrollPosition,
449                     animate = false,
450                     scrimOffset = scrimOffset,
451                     minScrimOffset = minScrimOffset,
452                     scrollState = scrollState,
453                 )
454             }
455         }
456         try {
457             awaitCancellation()
458         } finally {
459             viewModel.setAccessibilityScrollEventConsumer(null)
460         }
461     }
462 
463     val scrimNestedScrollConnection =
464         shadeSession.rememberSession(
465             scrimOffset,
466             minScrimTop,
467             viewModel.isCurrentGestureOverscroll,
468             density,
469         ) {
470             val flingSpec: DecayAnimationSpec<Float> = splineBasedDecay(density)
471             val flingBehavior = NotificationScrimFlingBehavior(flingSpec)
472             NotificationScrimNestedScrollConnection(
473                 scrimOffset = { scrimOffset.value },
474                 snapScrimOffset = { value -> coroutineScope.launch { scrimOffset.snapTo(value) } },
475                 animateScrimOffset = { value ->
476                     coroutineScope.launch { scrimOffset.animateTo(value) }
477                 },
478                 minScrimOffset = minScrimOffset,
479                 maxScrimOffset = 0f,
480                 contentHeight = { stackHeight.intValue.toFloat() },
481                 minVisibleScrimHeight = minVisibleScrimHeight,
482                 isCurrentGestureOverscroll = { viewModel.isCurrentGestureOverscroll },
483                 flingBehavior = flingBehavior,
484             )
485         }
486 
487     val overScrollEffect: OffsetOverscrollEffect = rememberOffsetOverscrollEffect()
488     // whether the stack is moving due to a swipe or fling
489     val isScrollInProgress =
490         scrollState.isScrollInProgress || overScrollEffect.isInProgress || scrimOffset.isRunning
491 
492     LaunchedEffect(isScrollInProgress) {
493         if (isScrollInProgress) {
494             jankMonitor.begin(composeViewRoot, CUJ_NOTIFICATION_SHADE_SCROLL_FLING)
495             debugLog(viewModel) { "STACK scroll begins" }
496         } else {
497             debugLog(viewModel) { "STACK scroll ends" }
498             jankMonitor.end(CUJ_NOTIFICATION_SHADE_SCROLL_FLING)
499         }
500     }
501 
502     Box(
503         modifier =
504             modifier
505                 .element(Notifications.Elements.NotificationScrim)
506                 .overscroll(verticalOverscrollEffect)
507                 .offset {
508                     // if scrim is expanded while transitioning to Gone or QS scene, increase the
509                     // offset in step with the corresponding transition so that it is 0 when it
510                     // completes.
511                     if (
512                         scrimOffset.value < 0 &&
513                             (layoutState.isTransitioning(
514                                 from = viewModel.notificationsShadeContentKey,
515                                 to = Scenes.Gone,
516                             ) ||
517                                 layoutState.isTransitioning(
518                                     from = viewModel.notificationsShadeContentKey,
519                                     to = Scenes.Lockscreen,
520                                 ))
521                     ) {
522                         IntOffset(x = 0, y = (scrimOffset.value * expansionFraction).roundToInt())
523                     } else if (
524                         scrimOffset.value < 0 &&
525                             layoutState.isTransitioning(
526                                 from = Scenes.Shade,
527                                 to = Scenes.QuickSettings,
528                             )
529                     ) {
530                         IntOffset(
531                             x = 0,
532                             y = (scrimOffset.value * (1 - shadeToQsFraction)).roundToInt(),
533                         )
534                     } else {
535                         IntOffset(x = 0, y = scrimOffset.value.roundToInt())
536                     }
537                 }
538                 .graphicsLayer {
539                     shape =
540                         calculateCornerRadius(
541                                 scrimCornerRadius,
542                                 screenCornerRadius,
543                                 { expansionFraction },
544                                 shouldAnimateScrimCornerRadius(
545                                     layoutState,
546                                     shouldPunchHoleBehindScrim,
547                                     viewModel.notificationsShadeContentKey,
548                                 ),
549                             )
550                             .let { scrimRounding.value.toRoundedCornerShape(it) }
551                     clip = true
552                 }
553                 .onGloballyPositioned { coordinates ->
554                     val boundsInWindow = coordinates.boundsInWindow()
555                     debugLog(viewModel) {
556                         "SCRIM onGloballyPositioned:" +
557                             " size=${coordinates.size}" +
558                             " bounds=$boundsInWindow"
559                     }
560                     viewModel.onScrimBoundsChanged(
561                         ShadeScrimBounds(
562                             left = boundsInWindow.left,
563                             top = boundsInWindow.top,
564                             right = boundsInWindow.right,
565                             bottom = boundsInWindow.bottom,
566                         )
567                     )
568                 }
569                 .thenIf(onEmptySpaceClick != null) {
570                     Modifier.clickable(onClick = { onEmptySpaceClick?.invoke() })
571                 }
572     ) {
573         // Creates a cutout in the background scrim in the shape of the notifications scrim.
574         // Only visible when notif scrim alpha < 1, during shade expansion.
575         if (shouldPunchHoleBehindScrim) {
576             Spacer(
577                 modifier =
578                     Modifier.fillMaxSize().drawBehind {
579                         drawRect(Color.Black, blendMode = BlendMode.DstOut)
580                     }
581             )
582         }
583         Box(
584             modifier =
585                 Modifier.graphicsLayer {
586                         alpha =
587                             if (shouldPunchHoleBehindScrim) {
588                                 (expansionFraction / EXPANSION_FOR_MAX_SCRIM_ALPHA).coerceAtMost(1f)
589                             } else 1f
590                     }
591                     .thenIf(shouldShowScrim) { Modifier.background(scrimBackgroundColor) }
592                     .thenIf(shouldFillMaxSize) { Modifier.fillMaxSize() }
593                     .thenIf(supportNestedScrolling) { Modifier.padding(bottom = minScrimTop) }
594                     .debugBackground(viewModel, DEBUG_BOX_COLOR)
595         ) {
596             Column(
597                 modifier =
598                     Modifier.disableSwipesWhenScrolling(NestedScrollableBound.BottomRight)
599                         .thenIf(supportNestedScrolling) {
600                             Modifier.nestedScroll(scrimNestedScrollConnection)
601                         }
602                         .verticalScroll(scrollState, overscrollEffect = overScrollEffect)
603                         .padding(top = stackTopPadding, bottom = stackBottomPadding)
604                         .fillMaxWidth()
605                         .onGloballyPositioned { coordinates ->
606                             stackBoundsOnScreen.value = coordinates.boundsInWindow()
607                         }
608             ) {
609                 NotificationPlaceholder(
610                     stackScrollView = stackScrollView,
611                     viewModel = viewModel,
612                     useStackBounds = {
613                         !shouldUseLockscreenStackBounds(layoutState.transitionState)
614                     },
615                     modifier =
616                         Modifier.notificationStackHeight(view = stackScrollView).onSizeChanged {
617                             size ->
618                             stackHeight.intValue = size.height
619                         },
620                 )
621                 Spacer(
622                     modifier =
623                         Modifier.windowInsetsBottomHeight(WindowInsets.imeAnimationTarget)
624                             .onGloballyPositioned { coordinates: LayoutCoordinates ->
625                                 imeTop.floatValue = screenHeight - coordinates.size.height
626                             }
627                 )
628             }
629         }
630         if (shouldIncludeHeadsUpSpace) {
631             HeadsUpNotificationSpace(
632                 stackScrollView = stackScrollView,
633                 viewModel = viewModel,
634                 useHunBounds = {
635                     !shouldUseLockscreenHunBounds(
636                         layoutState.transitionState,
637                         viewModel.quickSettingsShadeContentKey,
638                     )
639                 },
640                 modifier = Modifier.padding(top = stackTopPadding),
641             )
642         }
643     }
644 }
645 
646 /**
647  * A 0 height horizontal spacer to be placed at the bottom-most position in the current scene, where
648  * the notification contents (stack, footer, shelf) should be drawn.
649  */
650 @Composable
ContentScopenull651 fun ContentScope.NotificationStackCutoffGuideline(
652     stackScrollView: NotificationScrollView,
653     viewModel: NotificationsPlaceholderViewModel,
654     modifier: Modifier = Modifier,
655 ) {
656     Spacer(
657         modifier =
658             modifier
659                 .element(key = Notifications.Elements.NotificationStackCutoffGuideline)
660                 .fillMaxWidth()
661                 .height(0.dp)
662                 .onGloballyPositioned { coordinates ->
663                     val positionY = coordinates.positionInWindow().y
664                     debugLog(viewModel) { "STACK cutoff onGloballyPositioned: y=$positionY" }
665                     stackScrollView.setStackCutoff(positionY)
666                 }
667     )
668 }
669 
670 @Composable
ContentScopenull671 private fun ContentScope.NotificationPlaceholder(
672     stackScrollView: NotificationScrollView,
673     viewModel: NotificationsPlaceholderViewModel,
674     useStackBounds: () -> Boolean,
675     modifier: Modifier = Modifier,
676 ) {
677     Box(
678         modifier =
679             modifier
680                 .element(Notifications.Elements.NotificationStackPlaceholder)
681                 .debugBackground(viewModel, DEBUG_STACK_COLOR)
682                 .onSizeChanged { size -> debugLog(viewModel) { "STACK onSizeChanged: size=$size" } }
683                 .onGloballyPositioned { coordinates: LayoutCoordinates ->
684                     // This element is opted out of the shared element system, so there can be
685                     // multiple instances of it during a transition. Thus we need to determine which
686                     // instance should feed its bounds to NSSL to avoid providing conflicting values
687                     val useBounds = useStackBounds()
688                     if (useBounds) {
689                         // NOTE: positionInWindow.y scrolls off screen, but boundsInWindow.top won't
690                         val positionInWindow = coordinates.positionInWindow()
691                         debugLog(viewModel) {
692                             "STACK onGloballyPositioned:" +
693                                 " size=${coordinates.size}" +
694                                 " position=$positionInWindow" +
695                                 " bounds=${coordinates.boundsInWindow()}"
696                         }
697                         stackScrollView.setStackTop(positionInWindow.y)
698                     }
699                 }
700     )
701 }
702 
scrollNotificationStacknull703 private suspend fun scrollNotificationStack(
704     delta: Float,
705     animate: Boolean,
706     scrimOffset: Animatable<Float, AnimationVector1D>,
707     minScrimOffset: () -> Float,
708     scrollState: ScrollState,
709 ) {
710     val minOffset = minScrimOffset()
711     if (scrimOffset.value > minOffset) {
712         val remainingDelta =
713             (minOffset - (scrimOffset.value - delta)).coerceAtLeast(0f).roundToInt()
714         if (remainingDelta > 0) {
715             if (animate) {
716                 // launch a new coroutine for the remainder animation so that it doesn't suspend the
717                 // scrim animation, allowing both to play simultaneously.
718                 coroutineScope { launch { scrollState.animateScrollTo(remainingDelta) } }
719             } else {
720                 scrollState.scrollTo(remainingDelta)
721             }
722         }
723         val newScrimOffset = (scrimOffset.value - delta).coerceAtLeast(minOffset)
724         if (animate) {
725             scrimOffset.animateTo(newScrimOffset)
726         } else {
727             scrimOffset.snapTo(newScrimOffset)
728         }
729     } else {
730         if (animate) {
731             scrollState.animateScrollBy(delta)
732         } else {
733             scrollState.scrollBy(delta)
734         }
735     }
736 }
737 
isOnLockscreennull738 private fun TransitionState.isOnLockscreen(): Boolean {
739     return currentScene == Scenes.Lockscreen && currentOverlays.isEmpty()
740 }
741 
shouldUseLockscreenStackBoundsnull742 private fun shouldUseLockscreenStackBounds(state: TransitionState): Boolean {
743     return state is TransitionState.Idle && state.isOnLockscreen()
744 }
745 
shouldUseLockscreenHunBoundsnull746 private fun shouldUseLockscreenHunBounds(
747     state: TransitionState,
748     quickSettingsShade: ContentKey,
749 ): Boolean {
750     return when (state) {
751         is TransitionState.Idle -> state.isOnLockscreen()
752         is TransitionState.Transition ->
753             state.isTransitioning(from = quickSettingsShade, to = Scenes.Lockscreen)
754     }
755 }
756 
shouldAnimateScrimCornerRadiusnull757 private fun shouldAnimateScrimCornerRadius(
758     state: SceneTransitionLayoutState,
759     shouldPunchHoleBehindScrim: Boolean,
760     notificationsShade: ContentKey,
761 ): Boolean {
762     return shouldPunchHoleBehindScrim ||
763         state.isTransitioning(from = notificationsShade, to = Scenes.Lockscreen)
764 }
765 
calculateCornerRadiusnull766 private fun calculateCornerRadius(
767     scrimCornerRadius: Dp,
768     screenCornerRadius: Dp,
769     expansionFraction: () -> Float,
770     transitioning: Boolean,
771 ): Dp {
772     return if (transitioning) {
773         lerp(
774                 start = screenCornerRadius.value,
775                 stop = scrimCornerRadius.value,
776                 fraction = (expansionFraction() / EXPANSION_FOR_MAX_CORNER_RADIUS).coerceIn(0f, 1f),
777             )
778             .dp
779     } else {
780         scrimCornerRadius
781     }
782 }
783 
calculateHeadsUpPlaceholderYOffsetnull784 private fun calculateHeadsUpPlaceholderYOffset(
785     scrollOffset: Int,
786     minScrollOffset: Int,
787     topHeadsUpHeight: Int,
788 ): Int {
789     return -minScrollOffset +
790         (scrollOffset * (-minScrollOffset + topHeadsUpHeight) / -minScrollOffset)
791 }
792 
velocityOrPositionalThresholdReachednull793 private fun velocityOrPositionalThresholdReached(
794     scrollOffset: Float,
795     minScrollOffset: Float,
796     availableVelocityY: Float,
797 ): Boolean {
798     return availableVelocityY < HUN_SNOOZE_VELOCITY_THRESHOLD ||
799         (availableVelocityY <= 0f &&
800             scrollOffset < minScrollOffset * HUN_SNOOZE_POSITIONAL_THRESHOLD_FRACTION)
801 }
802 
803 /**
804  * Takes a range, current value, and delta, and updates the current value by the delta, coercing the
805  * result within the given range. Returns how much of the delta was consumed.
806  */
consumeDeltaWithinRangenull807 private fun consumeDeltaWithinRange(
808     current: Float,
809     setCurrent: (Float) -> Unit,
810     min: Float,
811     max: Float,
812     delta: Float,
813 ): Float {
814     return if (delta < 0 && current > min) {
815         val remainder = (current + delta - min).coerceAtMost(0f)
816         setCurrent((current + delta).coerceAtLeast(min))
817         delta - remainder
818     } else if (delta > 0 && current < max) {
819         val remainder = (current + delta).coerceAtLeast(0f)
820         setCurrent((current + delta).coerceAtMost(max))
821         delta - remainder
822     } else 0f
823 }
824 
debugLognull825 private inline fun debugLog(viewModel: NotificationsPlaceholderViewModel, msg: () -> Any) {
826     if (viewModel.isDebugLoggingEnabled) {
827         Log.d(TAG, msg().toString())
828     }
829 }
830 
debugBackgroundnull831 private fun Modifier.debugBackground(
832     viewModel: NotificationsPlaceholderViewModel,
833     color: Color,
834 ): Modifier =
835     if (viewModel.isVisualDebuggingEnabled) {
836         background(color)
837     } else {
838         this
839     }
840 
toRoundedCornerShapenull841 private fun ShadeScrimRounding.toRoundedCornerShape(radius: Dp): RoundedCornerShape {
842     val topRadius = if (isTopRounded) radius else 0.dp
843     val bottomRadius = if (isBottomRounded) radius else 0.dp
844     return RoundedCornerShape(
845         topStart = topRadius,
846         topEnd = topRadius,
847         bottomStart = bottomRadius,
848         bottomEnd = bottomRadius,
849     )
850 }
851 
852 private const val TAG = "FlexiNotifs"
853 private val DEBUG_STACK_COLOR = Color(1f, 0f, 0f, 0.2f)
854 private val DEBUG_HUN_COLOR = Color(0f, 0f, 1f, 0.2f)
855 private val DEBUG_BOX_COLOR = Color(0f, 1f, 0f, 0.2f)
856 private const val HUN_SNOOZE_POSITIONAL_THRESHOLD_FRACTION = 0.25f
857 private const val HUN_SNOOZE_VELOCITY_THRESHOLD = -70f
858