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