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