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.foundation.ScrollState
23 import androidx.compose.foundation.background
24 import androidx.compose.foundation.gestures.scrollBy
25 import androidx.compose.foundation.layout.Box
26 import androidx.compose.foundation.layout.Spacer
27 import androidx.compose.foundation.layout.WindowInsets
28 import androidx.compose.foundation.layout.asPaddingValues
29 import androidx.compose.foundation.layout.fillMaxSize
30 import androidx.compose.foundation.layout.fillMaxWidth
31 import androidx.compose.foundation.layout.offset
32 import androidx.compose.foundation.layout.padding
33 import androidx.compose.foundation.layout.systemBars
34 import androidx.compose.foundation.shape.RoundedCornerShape
35 import androidx.compose.foundation.verticalScroll
36 import androidx.compose.material3.MaterialTheme
37 import androidx.compose.material3.Text
38 import androidx.compose.runtime.Composable
39 import androidx.compose.runtime.DisposableEffect
40 import androidx.compose.runtime.LaunchedEffect
41 import androidx.compose.runtime.getValue
42 import androidx.compose.runtime.mutableIntStateOf
43 import androidx.compose.runtime.remember
44 import androidx.compose.runtime.rememberCoroutineScope
45 import androidx.compose.runtime.snapshotFlow
46 import androidx.compose.ui.Alignment
47 import androidx.compose.ui.Modifier
48 import androidx.compose.ui.draw.clip
49 import androidx.compose.ui.draw.drawBehind
50 import androidx.compose.ui.graphics.BlendMode
51 import androidx.compose.ui.graphics.Color
52 import androidx.compose.ui.graphics.graphicsLayer
53 import androidx.compose.ui.input.nestedscroll.nestedScroll
54 import androidx.compose.ui.layout.LayoutCoordinates
55 import androidx.compose.ui.layout.boundsInWindow
56 import androidx.compose.ui.layout.onGloballyPositioned
57 import androidx.compose.ui.layout.onPlaced
58 import androidx.compose.ui.layout.onSizeChanged
59 import androidx.compose.ui.layout.positionInWindow
60 import androidx.compose.ui.platform.LocalDensity
61 import androidx.compose.ui.res.dimensionResource
62 import androidx.compose.ui.unit.Dp
63 import androidx.compose.ui.unit.IntOffset
64 import androidx.compose.ui.unit.dp
65 import androidx.compose.ui.util.lerp
66 import androidx.lifecycle.compose.collectAsStateWithLifecycle
67 import com.android.compose.animation.scene.ElementKey
68 import com.android.compose.animation.scene.NestedScrollBehavior
69 import com.android.compose.animation.scene.SceneScope
70 import com.android.compose.modifiers.thenIf
71 import com.android.systemui.common.ui.compose.windowinsets.LocalRawScreenHeight
72 import com.android.systemui.common.ui.compose.windowinsets.LocalScreenCornerRadius
73 import com.android.systemui.res.R
74 import com.android.systemui.scene.session.ui.composable.SaveableSession
75 import com.android.systemui.scene.session.ui.composable.rememberSession
76 import com.android.systemui.scene.shared.model.Scenes
77 import com.android.systemui.shade.shared.model.ShadeMode
78 import com.android.systemui.shade.ui.composable.ShadeHeader
79 import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimBounds
80 import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimRounding
81 import com.android.systemui.statusbar.notification.stack.ui.view.NotificationScrollView
82 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationTransitionThresholds.EXPANSION_FOR_MAX_CORNER_RADIUS
83 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationTransitionThresholds.EXPANSION_FOR_MAX_SCRIM_ALPHA
84 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationsPlaceholderViewModel
85 import kotlin.math.roundToInt
86 import kotlinx.coroutines.launch
87 
88 object Notifications {
89     object Elements {
90         val NotificationScrim = ElementKey("NotificationScrim")
91         val NotificationStackPlaceholder = ElementKey("NotificationStackPlaceholder")
92         val HeadsUpNotificationPlaceholder = ElementKey("HeadsUpNotificationPlaceholder")
93         val ShelfSpace = ElementKey("ShelfSpace")
94     }
95 
96     // Expansion fraction thresholds (between 0-1f) at which the corresponding value should be
97     // at its maximum, given they are at their minimum value at expansion = 0f.
98     object TransitionThresholds {
99         const val EXPANSION_FOR_MAX_CORNER_RADIUS = 0.1f
100         const val EXPANSION_FOR_MAX_SCRIM_ALPHA = 0.3f
101     }
102 }
103 
104 /**
105  * Adds the space where heads up notifications can appear in the scene. This should generally be the
106  * entire size of the scene.
107  */
108 @Composable
HeadsUpNotificationSpacenull109 fun SceneScope.HeadsUpNotificationSpace(
110     stackScrollView: NotificationScrollView,
111     viewModel: NotificationsPlaceholderViewModel,
112     modifier: Modifier = Modifier,
113     isPeekFromBottom: Boolean = false,
114 ) {
115     Element(
116         Notifications.Elements.HeadsUpNotificationPlaceholder,
117         modifier =
118             modifier
119                 .fillMaxWidth()
120                 .notificationHeadsUpHeight(stackScrollView)
121                 .debugBackground(viewModel, DEBUG_HUN_COLOR)
122                 .onGloballyPositioned { coordinates: LayoutCoordinates ->
123                     val boundsInWindow = coordinates.boundsInWindow()
124                     debugLog(viewModel) {
125                         "HUNS onGloballyPositioned:" +
126                             " size=${coordinates.size}" +
127                             " bounds=$boundsInWindow"
128                     }
129                     // Note: boundsInWindow doesn't scroll off the screen
130                     stackScrollView.setHeadsUpTop(boundsInWindow.top)
131                 }
132     ) {
133         content {}
134     }
135 }
136 
137 /** Adds the space where notification stack should appear in the scene. */
138 @Composable
SceneScopenull139 fun SceneScope.ConstrainedNotificationStack(
140     stackScrollView: NotificationScrollView,
141     viewModel: NotificationsPlaceholderViewModel,
142     modifier: Modifier = Modifier,
143 ) {
144     Box(
145         modifier =
146             modifier.onSizeChanged { viewModel.onConstrainedAvailableSpaceChanged(it.height) }
147     ) {
148         NotificationPlaceholder(
149             stackScrollView = stackScrollView,
150             viewModel = viewModel,
151             modifier = Modifier.fillMaxSize(),
152         )
153         HeadsUpNotificationSpace(
154             stackScrollView = stackScrollView,
155             viewModel = viewModel,
156             modifier = Modifier.align(Alignment.TopCenter),
157         )
158     }
159 }
160 
161 /**
162  * Adds the space where notification stack should appear in the scene, with a scrim and nested
163  * scrolling.
164  */
165 @Composable
NotificationScrollingStacknull166 fun SceneScope.NotificationScrollingStack(
167     shadeSession: SaveableSession,
168     stackScrollView: NotificationScrollView,
169     viewModel: NotificationsPlaceholderViewModel,
170     maxScrimTop: () -> Float,
171     shouldPunchHoleBehindScrim: Boolean,
172     shouldFillMaxSize: Boolean = true,
173     shouldReserveSpaceForNavBar: Boolean = true,
174     shadeMode: ShadeMode,
175     modifier: Modifier = Modifier,
176 ) {
177     val coroutineScope = rememberCoroutineScope()
178     val density = LocalDensity.current
179     val screenCornerRadius = LocalScreenCornerRadius.current
180     val scrimCornerRadius = dimensionResource(R.dimen.notification_scrim_corner_radius)
181     val scrollState =
182         shadeSession.rememberSaveableSession(saver = ScrollState.Saver, key = null) {
183             ScrollState(initial = 0)
184         }
185     val syntheticScroll = viewModel.syntheticScroll.collectAsStateWithLifecycle(0f)
186     val isCurrentGestureOverscroll =
187         viewModel.isCurrentGestureOverscroll.collectAsStateWithLifecycle(false)
188     val expansionFraction by viewModel.expandFraction.collectAsStateWithLifecycle(0f)
189 
190     val navBarHeight =
191         with(density) { WindowInsets.systemBars.asPaddingValues().calculateBottomPadding().toPx() }
192     val screenHeight = LocalRawScreenHeight.current
193 
194     /**
195      * The height in px of the contents of notification stack. Depending on the number of
196      * notifications, this can exceed the space available on screen to show notifications, at which
197      * point the notification stack should become scrollable.
198      */
199     val stackHeight = remember { mutableIntStateOf(0) }
200 
201     val scrimRounding =
202         viewModel.shadeScrimRounding.collectAsStateWithLifecycle(ShadeScrimRounding())
203 
204     // the offset for the notifications scrim. Its upper bound is 0, and its lower bound is
205     // calculated in minScrimOffset. The scrim is the same height as the screen minus the
206     // height of the Shade Header, and at rest (scrimOffset = 0) its top bound is at maxScrimStartY.
207     // When fully expanded (scrimOffset = minScrimOffset), its top bound is at minScrimStartY,
208     // which is equal to the height of the Shade Header. Thus, when the scrim is fully expanded, the
209     // entire height of the scrim is visible on screen.
210     val scrimOffset = shadeSession.rememberSession { Animatable(0f) }
211 
212     // set the bounds to null when the scrim disappears
213     DisposableEffect(Unit) { onDispose { viewModel.onScrimBoundsChanged(null) } }
214 
215     val minScrimTop = with(density) { ShadeHeader.Dimensions.CollapsedHeight.toPx() }
216 
217     // The minimum offset for the scrim. The scrim is considered fully expanded when it
218     // is at this offset.
219     val minScrimOffset: () -> Float = { minScrimTop - maxScrimTop() }
220 
221     // The height of the scrim visible on screen when it is in its resting (collapsed) state.
222     val minVisibleScrimHeight: () -> Float = { screenHeight - maxScrimTop() }
223 
224     // we are not scrolled to the top unless the scrim is at its maximum offset.
225     LaunchedEffect(viewModel, scrimOffset) {
226         snapshotFlow { scrimOffset.value >= 0f }
227             .collect { isScrolledToTop -> viewModel.setScrolledToTop(isScrolledToTop) }
228     }
229 
230     // if contentHeight drops below minimum visible scrim height while scrim is
231     // expanded, reset scrim offset.
232     LaunchedEffect(stackHeight, scrimOffset) {
233         snapshotFlow { stackHeight.intValue < minVisibleScrimHeight() && scrimOffset.value < 0f }
234             .collect { shouldCollapse -> if (shouldCollapse) scrimOffset.snapTo(0f) }
235     }
236 
237     // if we receive scroll delta from NSSL, offset the scrim and placeholder accordingly.
238     LaunchedEffect(syntheticScroll, scrimOffset, scrollState) {
239         snapshotFlow { syntheticScroll.value }
240             .collect { delta ->
241                 val minOffset = minScrimOffset()
242                 if (scrimOffset.value > minOffset) {
243                     val remainingDelta = (minOffset - (scrimOffset.value - delta)).coerceAtLeast(0f)
244                     scrimOffset.snapTo((scrimOffset.value - delta).coerceAtLeast(minOffset))
245                     if (remainingDelta > 0f) {
246                         scrollState.scrollBy(remainingDelta)
247                     }
248                 } else {
249                     scrollState.scrollTo(delta.roundToInt())
250                 }
251             }
252     }
253 
254     val scrimNestedScrollConnection =
255         shadeSession.rememberSession(
256             scrimOffset,
257             maxScrimTop,
258             minScrimTop,
259             isCurrentGestureOverscroll,
260         ) {
261             NotificationScrimNestedScrollConnection(
262                 scrimOffset = { scrimOffset.value },
263                 snapScrimOffset = { value -> coroutineScope.launch { scrimOffset.snapTo(value) } },
264                 animateScrimOffset = { value ->
265                     coroutineScope.launch { scrimOffset.animateTo(value) }
266                 },
267                 minScrimOffset = minScrimOffset,
268                 maxScrimOffset = 0f,
269                 contentHeight = { stackHeight.intValue.toFloat() },
270                 minVisibleScrimHeight = minVisibleScrimHeight,
271                 isCurrentGestureOverscroll = { isCurrentGestureOverscroll.value },
272             )
273         }
274 
275     Box(
276         modifier =
277             modifier
278                 .element(Notifications.Elements.NotificationScrim)
279                 .offset {
280                     // if scrim is expanded while transitioning to Gone scene, increase the offset
281                     // in step with the transition so that it is 0 when it completes.
282                     if (
283                         scrimOffset.value < 0 &&
284                             layoutState.isTransitioning(from = Scenes.Shade, to = Scenes.Gone) ||
285                             layoutState.isTransitioning(from = Scenes.Shade, to = Scenes.Lockscreen)
286                     ) {
287                         IntOffset(x = 0, y = (scrimOffset.value * expansionFraction).roundToInt())
288                     } else {
289                         IntOffset(x = 0, y = scrimOffset.value.roundToInt())
290                     }
291                 }
292                 .graphicsLayer {
293                     shape =
294                         calculateCornerRadius(
295                                 scrimCornerRadius,
296                                 screenCornerRadius,
297                                 { expansionFraction },
298                                 shouldPunchHoleBehindScrim,
299                             )
300                             .let { scrimRounding.value.toRoundedCornerShape(it) }
301                     clip = true
302                 }
303                 .onGloballyPositioned { coordinates ->
304                     val boundsInWindow = coordinates.boundsInWindow()
305                     debugLog(viewModel) {
306                         "SCRIM onGloballyPositioned:" +
307                             " size=${coordinates.size}" +
308                             " bounds=$boundsInWindow"
309                     }
310                     viewModel.onScrimBoundsChanged(
311                         ShadeScrimBounds(
312                             left = boundsInWindow.left,
313                             top = boundsInWindow.top,
314                             right = boundsInWindow.right,
315                             bottom = boundsInWindow.bottom,
316                         )
317                     )
318                 }
319     ) {
320         // Creates a cutout in the background scrim in the shape of the notifications scrim.
321         // Only visible when notif scrim alpha < 1, during shade expansion.
322         if (shouldPunchHoleBehindScrim) {
323             Spacer(
324                 modifier =
325                     Modifier.fillMaxSize().drawBehind {
326                         drawRect(Color.Black, blendMode = BlendMode.DstOut)
327                     }
328             )
329         }
330         Box(
331             modifier =
332                 Modifier.graphicsLayer {
333                         alpha =
334                             if (shouldPunchHoleBehindScrim) {
335                                 (expansionFraction / EXPANSION_FOR_MAX_SCRIM_ALPHA).coerceAtMost(1f)
336                             } else 1f
337                     }
338                     .background(MaterialTheme.colorScheme.surface)
339                     .thenIf(shouldFillMaxSize) { Modifier.fillMaxSize() }
340                     .debugBackground(viewModel, DEBUG_BOX_COLOR)
341         ) {
342             NotificationPlaceholder(
343                 stackScrollView = stackScrollView,
344                 viewModel = viewModel,
345                 modifier =
346                     Modifier.verticalNestedScrollToScene(
347                             topBehavior = NestedScrollBehavior.EdgeWithPreview,
348                             isExternalOverscrollGesture = { isCurrentGestureOverscroll.value }
349                         )
350                         .thenIf(shadeMode == ShadeMode.Single) {
351                             Modifier.nestedScroll(scrimNestedScrollConnection)
352                         }
353                         .verticalScroll(scrollState)
354                         .fillMaxWidth()
355                         .notificationStackHeight(
356                             view = stackScrollView,
357                             padding = if (shouldReserveSpaceForNavBar) navBarHeight.toInt() else 0
358                         )
359                         .onSizeChanged { size -> stackHeight.intValue = size.height },
360             )
361         }
362         HeadsUpNotificationSpace(stackScrollView = stackScrollView, viewModel = viewModel)
363     }
364 }
365 
366 /**
367  * This may be added to the lockscreen to provide a space to the start of the lock icon where the
368  * short shelf has room to flow vertically below the lock icon, but to its start, allowing more
369  * notifications to fit in the stack itself. (see: b/213934746)
370  *
371  * NOTE: this is totally unused for now; it is here to clarify the future plan
372  */
373 @Composable
NotificationShelfSpacenull374 fun SceneScope.NotificationShelfSpace(
375     viewModel: NotificationsPlaceholderViewModel,
376     modifier: Modifier = Modifier,
377 ) {
378     Text(
379         text = "Shelf Space",
380         modifier
381             .element(key = Notifications.Elements.ShelfSpace)
382             .fillMaxWidth()
383             .onPlaced { coordinates: LayoutCoordinates ->
384                 debugLog(viewModel) {
385                     ("SHELF onPlaced:" +
386                         " size=${coordinates.size}" +
387                         " bounds=${coordinates.boundsInWindow()}")
388                 }
389             }
390             .clip(RoundedCornerShape(24.dp))
391             .background(MaterialTheme.colorScheme.primaryContainer)
392             .padding(16.dp),
393         style = MaterialTheme.typography.titleLarge,
394         color = MaterialTheme.colorScheme.onPrimaryContainer,
395     )
396 }
397 
398 @Composable
NotificationPlaceholdernull399 private fun SceneScope.NotificationPlaceholder(
400     stackScrollView: NotificationScrollView,
401     viewModel: NotificationsPlaceholderViewModel,
402     modifier: Modifier = Modifier,
403 ) {
404     Box(
405         modifier =
406             modifier
407                 .element(Notifications.Elements.NotificationStackPlaceholder)
408                 .debugBackground(viewModel, DEBUG_STACK_COLOR)
409                 .onSizeChanged { size -> debugLog(viewModel) { "STACK onSizeChanged: size=$size" } }
410                 .onGloballyPositioned { coordinates: LayoutCoordinates ->
411                     val positionInWindow = coordinates.positionInWindow()
412                     debugLog(viewModel) {
413                         "STACK onGloballyPositioned:" +
414                             " size=${coordinates.size}" +
415                             " position=$positionInWindow" +
416                             " bounds=${coordinates.boundsInWindow()}"
417                     }
418                     // NOTE: positionInWindow.y scrolls off screen, but boundsInWindow.top will not
419                     stackScrollView.setStackTop(positionInWindow.y)
420                     stackScrollView.setStackBottom(positionInWindow.y + coordinates.size.height)
421                 }
422     )
423 }
424 
calculateCornerRadiusnull425 private fun calculateCornerRadius(
426     scrimCornerRadius: Dp,
427     screenCornerRadius: Dp,
428     expansionFraction: () -> Float,
429     transitioning: Boolean,
430 ): Dp {
431     return if (transitioning) {
432         lerp(
433                 start = screenCornerRadius.value,
434                 stop = scrimCornerRadius.value,
435                 fraction = (expansionFraction() / EXPANSION_FOR_MAX_CORNER_RADIUS).coerceIn(0f, 1f),
436             )
437             .dp
438     } else {
439         scrimCornerRadius
440     }
441 }
442 
debugLognull443 private inline fun debugLog(
444     viewModel: NotificationsPlaceholderViewModel,
445     msg: () -> Any,
446 ) {
447     if (viewModel.isDebugLoggingEnabled) {
448         Log.d(TAG, msg().toString())
449     }
450 }
451 
debugBackgroundnull452 private fun Modifier.debugBackground(
453     viewModel: NotificationsPlaceholderViewModel,
454     color: Color,
455 ): Modifier =
456     if (viewModel.isVisualDebuggingEnabled) {
457         background(color)
458     } else {
459         this
460     }
461 
toRoundedCornerShapenull462 private fun ShadeScrimRounding.toRoundedCornerShape(radius: Dp): RoundedCornerShape {
463     val topRadius = if (isTopRounded) radius else 0.dp
464     val bottomRadius = if (isBottomRounded) radius else 0.dp
465     return RoundedCornerShape(
466         topStart = topRadius,
467         topEnd = topRadius,
468         bottomStart = bottomRadius,
469         bottomEnd = bottomRadius,
470     )
471 }
472 
473 private const val TAG = "FlexiNotifs"
474 private val DEBUG_STACK_COLOR = Color(1f, 0f, 0f, 0.2f)
475 private val DEBUG_HUN_COLOR = Color(0f, 0f, 1f, 0.2f)
476 private val DEBUG_BOX_COLOR = Color(0f, 1f, 0f, 0.2f)
477