1 /*
2 * 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 package com.android.systemui.shade.ui.composable
18
19 import android.view.ViewGroup
20 import androidx.compose.animation.core.animateDpAsState
21 import androidx.compose.animation.core.animateFloatAsState
22 import androidx.compose.animation.core.tween
23 import androidx.compose.foundation.background
24 import androidx.compose.foundation.clickable
25 import androidx.compose.foundation.clipScrollableContainer
26 import androidx.compose.foundation.gestures.Orientation
27 import androidx.compose.foundation.layout.Arrangement
28 import androidx.compose.foundation.layout.Box
29 import androidx.compose.foundation.layout.Column
30 import androidx.compose.foundation.layout.Row
31 import androidx.compose.foundation.layout.WindowInsets
32 import androidx.compose.foundation.layout.asPaddingValues
33 import androidx.compose.foundation.layout.displayCutoutPadding
34 import androidx.compose.foundation.layout.fillMaxHeight
35 import androidx.compose.foundation.layout.fillMaxSize
36 import androidx.compose.foundation.layout.fillMaxWidth
37 import androidx.compose.foundation.layout.navigationBars
38 import androidx.compose.foundation.layout.offset
39 import androidx.compose.foundation.layout.padding
40 import androidx.compose.foundation.rememberScrollState
41 import androidx.compose.foundation.shape.RoundedCornerShape
42 import androidx.compose.foundation.verticalScroll
43 import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
44 import androidx.compose.runtime.Composable
45 import androidx.compose.runtime.DisposableEffect
46 import androidx.compose.runtime.LaunchedEffect
47 import androidx.compose.runtime.getValue
48 import androidx.compose.runtime.mutableStateOf
49 import androidx.compose.runtime.remember
50 import androidx.compose.ui.Alignment
51 import androidx.compose.ui.Modifier
52 import androidx.compose.ui.graphics.CompositingStrategy
53 import androidx.compose.ui.graphics.graphicsLayer
54 import androidx.compose.ui.layout.Layout
55 import androidx.compose.ui.layout.layoutId
56 import androidx.compose.ui.platform.LocalDensity
57 import androidx.compose.ui.platform.LocalLifecycleOwner
58 import androidx.compose.ui.res.colorResource
59 import androidx.compose.ui.unit.dp
60 import androidx.lifecycle.compose.collectAsStateWithLifecycle
61 import com.android.compose.animation.scene.ElementKey
62 import com.android.compose.animation.scene.LowestZIndexScenePicker
63 import com.android.compose.animation.scene.SceneScope
64 import com.android.compose.animation.scene.TransitionState
65 import com.android.compose.animation.scene.UserAction
66 import com.android.compose.animation.scene.UserActionResult
67 import com.android.compose.animation.scene.animateSceneDpAsState
68 import com.android.compose.animation.scene.animateSceneFloatAsState
69 import com.android.compose.modifiers.padding
70 import com.android.compose.modifiers.thenIf
71 import com.android.compose.windowsizeclass.LocalWindowSizeClass
72 import com.android.systemui.battery.BatteryMeterViewController
73 import com.android.systemui.common.ui.compose.windowinsets.CutoutLocation
74 import com.android.systemui.common.ui.compose.windowinsets.LocalDisplayCutout
75 import com.android.systemui.common.ui.compose.windowinsets.LocalScreenCornerRadius
76 import com.android.systemui.compose.modifiers.sysuiResTag
77 import com.android.systemui.dagger.SysUISingleton
78 import com.android.systemui.media.controls.ui.composable.MediaCarousel
79 import com.android.systemui.media.controls.ui.controller.MediaCarouselController
80 import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager
81 import com.android.systemui.media.controls.ui.view.MediaHost
82 import com.android.systemui.media.controls.ui.view.MediaHostState
83 import com.android.systemui.media.dagger.MediaModule.QUICK_QS_PANEL
84 import com.android.systemui.notifications.ui.composable.NotificationScrollingStack
85 import com.android.systemui.qs.footer.ui.compose.FooterActionsWithAnimatedVisibility
86 import com.android.systemui.qs.ui.composable.BrightnessMirror
87 import com.android.systemui.qs.ui.composable.QSMediaMeasurePolicy
88 import com.android.systemui.qs.ui.composable.QuickSettings
89 import com.android.systemui.qs.ui.composable.QuickSettings.SharedValues.MediaLandscapeTopOffset
90 import com.android.systemui.qs.ui.composable.QuickSettings.SharedValues.MediaOffset.InQQS
91 import com.android.systemui.res.R
92 import com.android.systemui.scene.session.ui.composable.SaveableSession
93 import com.android.systemui.scene.shared.model.Scenes
94 import com.android.systemui.scene.ui.composable.ComposableScene
95 import com.android.systemui.shade.shared.model.ShadeMode
96 import com.android.systemui.shade.ui.viewmodel.ShadeSceneViewModel
97 import com.android.systemui.statusbar.notification.stack.ui.view.NotificationScrollView
98 import com.android.systemui.statusbar.phone.StatusBarLocation
99 import com.android.systemui.statusbar.phone.ui.StatusBarIconController
100 import com.android.systemui.statusbar.phone.ui.TintedIconManager
101 import dagger.Lazy
102 import javax.inject.Inject
103 import javax.inject.Named
104 import kotlin.math.roundToInt
105 import kotlinx.coroutines.flow.StateFlow
106
107 object Shade {
108 object Elements {
109 val MediaCarousel = ElementKey("ShadeMediaCarousel")
110 val BackgroundScrim =
111 ElementKey("ShadeBackgroundScrim", scenePicker = LowestZIndexScenePicker)
112 val SplitShadeStartColumn = ElementKey("SplitShadeStartColumn")
113 }
114
115 object Dimensions {
116 val ScrimCornerSize = 32.dp
117 val HorizontalPadding = 16.dp
118 val ScrimOverscrollLimit = 32.dp
119 const val ScrimVisibilityThreshold = 5f
120 }
121
122 object Shapes {
123 val Scrim =
124 RoundedCornerShape(
125 topStart = Dimensions.ScrimCornerSize,
126 topEnd = Dimensions.ScrimCornerSize,
127 )
128 }
129 }
130
131 /** The shade scene shows scrolling list of notifications and some of the quick setting tiles. */
132 @SysUISingleton
133 class ShadeScene
134 @Inject
135 constructor(
136 private val shadeSession: SaveableSession,
137 private val notificationStackScrollView: Lazy<NotificationScrollView>,
138 private val viewModel: ShadeSceneViewModel,
139 private val tintedIconManagerFactory: TintedIconManager.Factory,
140 private val batteryMeterViewControllerFactory: BatteryMeterViewController.Factory,
141 private val statusBarIconController: StatusBarIconController,
142 private val mediaCarouselController: MediaCarouselController,
143 @Named(QUICK_QS_PANEL) private val mediaHost: MediaHost,
144 ) : ComposableScene {
145
146 override val key = Scenes.Shade
147
148 override val destinationScenes: StateFlow<Map<UserAction, UserActionResult>> =
149 viewModel.destinationScenes
150
151 @Composable
Contentnull152 override fun SceneScope.Content(
153 modifier: Modifier,
154 ) =
155 ShadeScene(
156 notificationStackScrollView.get(),
157 viewModel = viewModel,
158 createTintedIconManager = tintedIconManagerFactory::create,
159 createBatteryMeterViewController = batteryMeterViewControllerFactory::create,
160 statusBarIconController = statusBarIconController,
161 mediaCarouselController = mediaCarouselController,
162 mediaHost = mediaHost,
163 modifier = modifier,
164 shadeSession = shadeSession,
165 )
166
167 init {
168 mediaHost.expansion = MediaHostState.EXPANDED
169 mediaHost.showsOnlyActiveMedia = true
170 mediaHost.init(MediaHierarchyManager.LOCATION_QQS)
171 }
172 }
173
174 @Composable
ShadeScenenull175 private fun SceneScope.ShadeScene(
176 notificationStackScrollView: NotificationScrollView,
177 viewModel: ShadeSceneViewModel,
178 createTintedIconManager: (ViewGroup, StatusBarLocation) -> TintedIconManager,
179 createBatteryMeterViewController: (ViewGroup, StatusBarLocation) -> BatteryMeterViewController,
180 statusBarIconController: StatusBarIconController,
181 mediaCarouselController: MediaCarouselController,
182 mediaHost: MediaHost,
183 modifier: Modifier = Modifier,
184 shadeSession: SaveableSession,
185 ) {
186 val shadeMode by viewModel.shadeMode.collectAsStateWithLifecycle()
187 when (shadeMode) {
188 is ShadeMode.Single ->
189 SingleShade(
190 notificationStackScrollView = notificationStackScrollView,
191 viewModel = viewModel,
192 createTintedIconManager = createTintedIconManager,
193 createBatteryMeterViewController = createBatteryMeterViewController,
194 statusBarIconController = statusBarIconController,
195 mediaCarouselController = mediaCarouselController,
196 mediaHost = mediaHost,
197 modifier = modifier,
198 shadeSession = shadeSession,
199 )
200 is ShadeMode.Split ->
201 SplitShade(
202 notificationStackScrollView = notificationStackScrollView,
203 viewModel = viewModel,
204 createTintedIconManager = createTintedIconManager,
205 createBatteryMeterViewController = createBatteryMeterViewController,
206 statusBarIconController = statusBarIconController,
207 mediaCarouselController = mediaCarouselController,
208 mediaHost = mediaHost,
209 modifier = modifier,
210 shadeSession = shadeSession,
211 )
212 is ShadeMode.Dual -> error("Dual shade is not yet implemented!")
213 }
214 }
215
216 @Composable
SingleShadenull217 private fun SceneScope.SingleShade(
218 notificationStackScrollView: NotificationScrollView,
219 viewModel: ShadeSceneViewModel,
220 createTintedIconManager: (ViewGroup, StatusBarLocation) -> TintedIconManager,
221 createBatteryMeterViewController: (ViewGroup, StatusBarLocation) -> BatteryMeterViewController,
222 statusBarIconController: StatusBarIconController,
223 mediaCarouselController: MediaCarouselController,
224 mediaHost: MediaHost,
225 modifier: Modifier = Modifier,
226 shadeSession: SaveableSession,
227 ) {
228 val cutoutLocation = LocalDisplayCutout.current.location
229
230 val maxNotifScrimTop = remember { mutableStateOf(0f) }
231 val tileSquishiness by
232 animateSceneFloatAsState(
233 value = 1f,
234 key = QuickSettings.SharedValues.TilesSquishiness,
235 canOverflow = false
236 )
237 val isClickable by viewModel.isClickable.collectAsStateWithLifecycle()
238 val isMediaVisible by viewModel.isMediaVisible.collectAsStateWithLifecycle()
239
240 val shouldPunchHoleBehindScrim =
241 layoutState.isTransitioningBetween(Scenes.Gone, Scenes.Shade) ||
242 layoutState.isTransitioningBetween(Scenes.Lockscreen, Scenes.Shade)
243 // Media is visible and we are in landscape on a small height screen
244 val mediaInRow =
245 isMediaVisible &&
246 LocalWindowSizeClass.current.heightSizeClass == WindowHeightSizeClass.Compact
247 val mediaOffset by
248 animateSceneDpAsState(value = InQQS, key = MediaLandscapeTopOffset, canOverflow = false)
249
250 Box(
251 modifier =
252 modifier.thenIf(shouldPunchHoleBehindScrim) {
253 // Render the scene to an offscreen buffer so that BlendMode.DstOut only clears this
254 // scene
255 // (and not the one under it) during a scene transition.
256 Modifier.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
257 }
258 ) {
259 Box(
260 modifier =
261 Modifier.fillMaxSize()
262 .element(Shade.Elements.BackgroundScrim)
263 .background(colorResource(R.color.shade_scrim_background_dark)),
264 )
265 Layout(
266 contents =
267 listOf(
268 {
269 Column(
270 horizontalAlignment = Alignment.CenterHorizontally,
271 modifier =
272 Modifier.fillMaxWidth()
273 .thenIf(isClickable) {
274 Modifier.clickable(
275 onClick = { viewModel.onContentClicked() }
276 )
277 }
278 .thenIf(cutoutLocation != CutoutLocation.CENTER) {
279 Modifier.displayCutoutPadding()
280 },
281 ) {
282 CollapsedShadeHeader(
283 viewModel = viewModel.shadeHeaderViewModel,
284 createTintedIconManager = createTintedIconManager,
285 createBatteryMeterViewController = createBatteryMeterViewController,
286 statusBarIconController = statusBarIconController,
287 )
288
289 val content: @Composable () -> Unit = {
290 Box(
291 Modifier.element(QuickSettings.Elements.QuickQuickSettings)
292 .layoutId(QSMediaMeasurePolicy.LayoutId.QS)
293 ) {
294 QuickSettings(
295 viewModel.qsSceneAdapter,
296 { viewModel.qsSceneAdapter.qqsHeight },
297 isSplitShade = false,
298 squishiness = { tileSquishiness },
299 )
300 }
301
302 MediaCarousel(
303 isVisible = isMediaVisible,
304 mediaHost = mediaHost,
305 modifier =
306 Modifier.fillMaxWidth()
307 .layoutId(QSMediaMeasurePolicy.LayoutId.Media),
308 carouselController = mediaCarouselController,
309 )
310 }
311 val landscapeQsMediaMeasurePolicy = remember {
312 QSMediaMeasurePolicy(
313 { viewModel.qsSceneAdapter.qqsHeight },
314 { mediaOffset.roundToPx() },
315 )
316 }
317 if (mediaInRow) {
318 Layout(
319 content = content,
320 measurePolicy = landscapeQsMediaMeasurePolicy,
321 )
322 } else {
323 content()
324 }
325 }
326 },
327 {
328 NotificationScrollingStack(
329 shadeSession = shadeSession,
330 stackScrollView = notificationStackScrollView,
331 viewModel = viewModel.notifications,
332 maxScrimTop = { maxNotifScrimTop.value },
333 shadeMode = ShadeMode.Single,
334 shouldPunchHoleBehindScrim = shouldPunchHoleBehindScrim,
335 )
336 },
337 )
338 ) { measurables, constraints ->
339 check(measurables.size == 2)
340 check(measurables[0].size == 1)
341 check(measurables[1].size == 1)
342
343 val quickSettingsPlaceable = measurables[0][0].measure(constraints)
344 val notificationsPlaceable = measurables[1][0].measure(constraints)
345
346 maxNotifScrimTop.value = quickSettingsPlaceable.height.toFloat()
347
348 layout(constraints.maxWidth, constraints.maxHeight) {
349 quickSettingsPlaceable.placeRelative(x = 0, y = 0)
350 notificationsPlaceable.placeRelative(x = 0, y = maxNotifScrimTop.value.roundToInt())
351 }
352 }
353 }
354 }
355
356 @Composable
SplitShadenull357 private fun SceneScope.SplitShade(
358 notificationStackScrollView: NotificationScrollView,
359 viewModel: ShadeSceneViewModel,
360 createTintedIconManager: (ViewGroup, StatusBarLocation) -> TintedIconManager,
361 createBatteryMeterViewController: (ViewGroup, StatusBarLocation) -> BatteryMeterViewController,
362 statusBarIconController: StatusBarIconController,
363 mediaCarouselController: MediaCarouselController,
364 mediaHost: MediaHost,
365 modifier: Modifier = Modifier,
366 shadeSession: SaveableSession,
367 ) {
368 val screenCornerRadius = LocalScreenCornerRadius.current
369
370 val isCustomizing by viewModel.qsSceneAdapter.isCustomizing.collectAsStateWithLifecycle()
371 val isCustomizerShowing by
372 viewModel.qsSceneAdapter.isCustomizerShowing.collectAsStateWithLifecycle()
373 val customizingAnimationDuration by
374 viewModel.qsSceneAdapter.customizerAnimationDuration.collectAsStateWithLifecycle()
375 val lifecycleOwner = LocalLifecycleOwner.current
376 val footerActionsViewModel =
377 remember(lifecycleOwner, viewModel) { viewModel.getFooterActionsViewModel(lifecycleOwner) }
378 val tileSquishiness by
379 animateSceneFloatAsState(
380 value = 1f,
381 key = QuickSettings.SharedValues.TilesSquishiness,
382 canOverflow = false,
383 )
384 val unfoldTranslationXForStartSide by
385 viewModel
386 .unfoldTranslationX(
387 isOnStartSide = true,
388 )
389 .collectAsStateWithLifecycle(0f)
390 val unfoldTranslationXForEndSide by
391 viewModel
392 .unfoldTranslationX(
393 isOnStartSide = false,
394 )
395 .collectAsStateWithLifecycle(0f)
396
397 val navBarBottomHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
398 val bottomPadding by
399 animateDpAsState(
400 targetValue = if (isCustomizing) 0.dp else navBarBottomHeight,
401 animationSpec = tween(customizingAnimationDuration),
402 label = "animateQSSceneBottomPaddingAsState"
403 )
404 val density = LocalDensity.current
405 LaunchedEffect(navBarBottomHeight, density) {
406 with(density) {
407 viewModel.qsSceneAdapter.applyBottomNavBarPadding(navBarBottomHeight.roundToPx())
408 }
409 }
410
411 val quickSettingsScrollState = rememberScrollState()
412 val isScrollable = layoutState.transitionState is TransitionState.Idle
413 LaunchedEffect(isCustomizing, quickSettingsScrollState) {
414 if (isCustomizing) {
415 quickSettingsScrollState.scrollTo(0)
416 }
417 }
418
419 val brightnessMirrorShowing by
420 viewModel.brightnessMirrorViewModel.isShowing.collectAsStateWithLifecycle()
421 val contentAlpha by
422 animateFloatAsState(
423 targetValue = if (brightnessMirrorShowing) 0f else 1f,
424 label = "alphaAnimationBrightnessMirrorContentHiding",
425 )
426
427 viewModel.notifications.setAlphaForBrightnessMirror(contentAlpha)
428 DisposableEffect(Unit) { onDispose { viewModel.notifications.setAlphaForBrightnessMirror(1f) } }
429
430 val isMediaVisible by viewModel.isMediaVisible.collectAsStateWithLifecycle()
431
432 val brightnessMirrorShowingModifier = Modifier.graphicsLayer { alpha = contentAlpha }
433
434 Box(
435 modifier =
436 modifier
437 .fillMaxSize()
438 .element(Shade.Elements.BackgroundScrim)
439 // Cannot set the alpha of the whole element to 0, because the mirror should be
440 // in the QS column.
441 .background(
442 colorResource(R.color.shade_scrim_background_dark).copy(alpha = contentAlpha)
443 )
444 ) {
445 Column(
446 modifier = Modifier.fillMaxSize(),
447 ) {
448 CollapsedShadeHeader(
449 viewModel = viewModel.shadeHeaderViewModel,
450 createTintedIconManager = createTintedIconManager,
451 createBatteryMeterViewController = createBatteryMeterViewController,
452 statusBarIconController = statusBarIconController,
453 modifier =
454 Modifier.then(brightnessMirrorShowingModifier)
455 .padding(
456 horizontal = { unfoldTranslationXForStartSide.roundToInt() },
457 )
458 )
459
460 Row(modifier = Modifier.fillMaxWidth().weight(1f)) {
461 Box(
462 modifier =
463 Modifier.element(Shade.Elements.SplitShadeStartColumn)
464 .weight(1f)
465 .graphicsLayer { translationX = unfoldTranslationXForStartSide },
466 ) {
467 BrightnessMirror(
468 viewModel = viewModel.brightnessMirrorViewModel,
469 qsSceneAdapter = viewModel.qsSceneAdapter,
470 // Need to remove the offset of the header height, as the mirror uses
471 // the position of the Brightness slider in the window
472 modifier = Modifier.offset(y = -ShadeHeader.Dimensions.CollapsedHeight)
473 )
474 Column(
475 verticalArrangement = Arrangement.Top,
476 modifier = Modifier.fillMaxSize().padding(bottom = bottomPadding),
477 ) {
478 Column(
479 modifier =
480 Modifier.fillMaxSize()
481 .sysuiResTag("expanded_qs_scroll_view")
482 .weight(1f)
483 .thenIf(!isCustomizerShowing) {
484 Modifier.verticalNestedScrollToScene()
485 .verticalScroll(
486 quickSettingsScrollState,
487 enabled = isScrollable
488 )
489 .clipScrollableContainer(Orientation.Horizontal)
490 }
491 .then(brightnessMirrorShowingModifier)
492 ) {
493 Box(
494 modifier =
495 Modifier.element(QuickSettings.Elements.SplitShadeQuickSettings)
496 ) {
497 QuickSettings(
498 qsSceneAdapter = viewModel.qsSceneAdapter,
499 heightProvider = { viewModel.qsSceneAdapter.qsHeight },
500 isSplitShade = true,
501 modifier = Modifier.fillMaxWidth(),
502 squishiness = { tileSquishiness },
503 )
504 }
505
506 MediaCarousel(
507 isVisible = isMediaVisible,
508 mediaHost = mediaHost,
509 modifier = Modifier.fillMaxWidth(),
510 carouselController = mediaCarouselController,
511 )
512 }
513 FooterActionsWithAnimatedVisibility(
514 viewModel = footerActionsViewModel,
515 isCustomizing = isCustomizing,
516 customizingAnimationDuration = customizingAnimationDuration,
517 lifecycleOwner = lifecycleOwner,
518 modifier =
519 Modifier.align(Alignment.CenterHorizontally)
520 .sysuiResTag("qs_footer_actions")
521 .then(brightnessMirrorShowingModifier),
522 )
523 }
524 }
525
526 NotificationScrollingStack(
527 shadeSession = shadeSession,
528 stackScrollView = notificationStackScrollView,
529 viewModel = viewModel.notifications,
530 maxScrimTop = { 0f },
531 shouldPunchHoleBehindScrim = false,
532 shadeMode = ShadeMode.Split,
533 modifier =
534 Modifier.weight(1f)
535 .fillMaxHeight()
536 .padding(end = screenCornerRadius / 2f, bottom = navBarBottomHeight)
537 .then(brightnessMirrorShowingModifier)
538 )
539 }
540 }
541 }
542 }
543