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 package com.android.systemui.statusbar.phone
18
19 import android.app.Dialog
20 import android.content.Context
21 import android.content.res.Configuration
22 import android.os.Bundle
23 import androidx.annotation.GravityInt
24 import androidx.compose.foundation.clickable
25 import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
26 import androidx.compose.foundation.gestures.AnchoredDraggableState
27 import androidx.compose.foundation.gestures.DraggableAnchors
28 import androidx.compose.foundation.gestures.Orientation
29 import androidx.compose.foundation.gestures.anchoredDraggable
30 import androidx.compose.foundation.gestures.detectTapGestures
31 import androidx.compose.foundation.interaction.MutableInteractionSource
32 import androidx.compose.foundation.interaction.collectIsDraggedAsState
33 import androidx.compose.foundation.layout.Box
34 import androidx.compose.foundation.layout.Column
35 import androidx.compose.foundation.layout.WindowInsets
36 import androidx.compose.foundation.layout.offset
37 import androidx.compose.foundation.layout.padding
38 import androidx.compose.foundation.layout.safeDrawing
39 import androidx.compose.foundation.layout.size
40 import androidx.compose.foundation.layout.widthIn
41 import androidx.compose.foundation.layout.wrapContentWidth
42 import androidx.compose.foundation.shape.RoundedCornerShape
43 import androidx.compose.material3.LocalContentColor
44 import androidx.compose.material3.MaterialTheme
45 import androidx.compose.material3.Surface
46 import androidx.compose.runtime.Composable
47 import androidx.compose.runtime.CompositionLocalProvider
48 import androidx.compose.runtime.LaunchedEffect
49 import androidx.compose.runtime.getValue
50 import androidx.compose.runtime.remember
51 import androidx.compose.ui.Alignment
52 import androidx.compose.ui.Modifier
53 import androidx.compose.ui.input.pointer.pointerInput
54 import androidx.compose.ui.layout.onSizeChanged
55 import androidx.compose.ui.platform.ComposeView
56 import androidx.compose.ui.platform.LocalConfiguration
57 import androidx.compose.ui.platform.LocalDensity
58 import androidx.compose.ui.platform.LocalLayoutDirection
59 import androidx.compose.ui.res.dimensionResource
60 import androidx.compose.ui.res.stringResource
61 import androidx.compose.ui.semantics.contentDescription
62 import androidx.compose.ui.semantics.hideFromAccessibility
63 import androidx.compose.ui.semantics.semantics
64 import androidx.compose.ui.unit.Dp
65 import androidx.compose.ui.unit.IntOffset
66 import androidx.compose.ui.unit.dp
67 import androidx.compose.ui.unit.isSpecified
68 import com.android.compose.theme.PlatformTheme
69 import com.android.systemui.keyboard.shortcut.ui.composable.hasCompactWindowSize
70 import com.android.systemui.res.R
71 import kotlin.math.roundToInt
72
73 /**
74 * Create a [SystemUIDialog] with the given [content].
75 *
76 * Note that the returned dialog will already have a background so the content should not draw an
77 * additional background.
78 *
79 * Example:
80 * ```
81 * val dialog = systemUiDialogFactory.create {
82 * AlertDialogContent(
83 * title = { Text("My title") },
84 * content = { Text("My content") },
85 * )
86 * }
87 *
88 * dialogTransitionAnimator.showFromView(dialog, viewThatWasClicked)
89 * ```
90 *
91 * @param context the [Context] in which the dialog will be constructed.
92 * @param dismissOnDeviceLock whether the dialog should be automatically dismissed when the device
93 * is locked (true by default).
94 * @param dialogGravity is one of the [android.view.Gravity] and determines dialog position on the
95 * screen.
96 */
97 fun SystemUIDialogFactory.create(
98 context: Context = this.applicationContext,
99 theme: Int = SystemUIDialog.DEFAULT_THEME,
100 dismissOnDeviceLock: Boolean = SystemUIDialog.DEFAULT_DISMISS_ON_DEVICE_LOCK,
101 @GravityInt dialogGravity: Int? = null,
102 dialogDelegate: DialogDelegate<SystemUIDialog> =
103 object : DialogDelegate<SystemUIDialog> {
104 override fun onCreate(dialog: SystemUIDialog, savedInstanceState: Bundle?) {
105 super.onCreate(dialog, savedInstanceState)
106 dialogGravity?.let { dialog.window?.setGravity(it) }
107 }
108 },
109 content: @Composable (SystemUIDialog) -> Unit,
110 ): ComponentSystemUIDialog {
111 return create(
112 context = context,
113 theme = theme,
114 dismissOnDeviceLock = dismissOnDeviceLock,
115 delegate = dialogDelegate,
116 content = content,
117 )
118 }
119
120 /** Same as [create] but creates a bottom sheet dialog. */
SystemUIDialogFactorynull121 fun SystemUIDialogFactory.createBottomSheet(
122 context: Context = this.applicationContext,
123 theme: Int = R.style.Theme_SystemUI_BottomSheet,
124 dismissOnDeviceLock: Boolean = SystemUIDialog.DEFAULT_DISMISS_ON_DEVICE_LOCK,
125 content: @Composable (SystemUIDialog) -> Unit,
126 isDraggable: Boolean = true,
127 // TODO(b/337205027): remove maxWidth parameter when aligned to M3 spec
128 maxWidth: Dp = Dp.Unspecified,
129 ): ComponentSystemUIDialog {
130 return create(
131 context = context,
132 theme = theme,
133 dismissOnDeviceLock = dismissOnDeviceLock,
134 delegate = EdgeToEdgeDialogDelegate(),
135 content = { dialog ->
136 val dragState =
137 if (isDraggable)
138 remember { AnchoredDraggableState(initialValue = DragAnchors.Start) }
139 else null
140 val interactionSource =
141 if (isDraggable) remember { MutableInteractionSource() } else null
142 if (dragState != null) {
143 val isDragged by interactionSource!!.collectIsDraggedAsState()
144 LaunchedEffect(dragState.currentValue, isDragged) {
145 if (!isDragged && dragState.currentValue == DragAnchors.End) dialog.dismiss()
146 }
147 }
148 Box(
149 modifier =
150 Modifier.bottomSheetClickable { dialog.dismiss() }
151 .then(
152 if (isDraggable)
153 Modifier.anchoredDraggable(
154 state = dragState!!,
155 interactionSource = interactionSource,
156 orientation = Orientation.Vertical,
157 flingBehavior =
158 AnchoredDraggableDefaults.flingBehavior(
159 state = dragState
160 ),
161 )
162 .offset {
163 IntOffset(x = 0, y = dragState.requireOffset().roundToInt())
164 }
165 .onSizeChanged { layoutSize ->
166 val dragEndPoint = layoutSize.height - dialog.height
167 dragState.updateAnchors(
168 DraggableAnchors {
169 DragAnchors.entries.forEach { anchor ->
170 anchor at dragEndPoint * anchor.fraction
171 }
172 }
173 )
174 }
175 .padding(top = draggableTopPadding())
176 else Modifier // No-Op
177 ),
178 contentAlignment = Alignment.BottomCenter,
179 ) {
180 val radius = dimensionResource(R.dimen.bottom_sheet_corner_radius)
181 Surface(
182 modifier =
183 Modifier.bottomSheetPaddings()
184 // consume input so it doesn't get to the parent Composable
185 .bottomSheetClickable {}
186 .widthIn(
187 max =
188 if (maxWidth.isSpecified) maxWidth
189 else DraggableBottomSheet.MaxWidth
190 ),
191 shape = RoundedCornerShape(topStart = radius, topEnd = radius),
192 color = MaterialTheme.colorScheme.surfaceContainer,
193 ) {
194 Box(
195 Modifier.padding(
196 bottom =
197 with(LocalDensity.current) {
198 WindowInsets.safeDrawing.getBottom(this).toDp()
199 }
200 )
201 ) {
202 if (isDraggable) {
203 Column(
204 Modifier.wrapContentWidth(Alignment.CenterHorizontally),
205 horizontalAlignment = Alignment.CenterHorizontally,
206 ) {
207 DragHandle(dialog)
208 content(dialog)
209 }
210 } else {
211 content(dialog)
212 }
213 }
214 }
215 }
216 },
217 )
218 }
219
220 private enum class DragAnchors(val fraction: Float) {
221 Start(0f),
222 End(1f),
223 }
224
SystemUIDialogFactorynull225 private fun SystemUIDialogFactory.create(
226 context: Context,
227 theme: Int,
228 dismissOnDeviceLock: Boolean,
229 delegate: DialogDelegate<SystemUIDialog>,
230 content: @Composable (SystemUIDialog) -> Unit,
231 ): ComponentSystemUIDialog {
232 val dialog = create(context, theme, dismissOnDeviceLock, delegate)
233
234 // Create the dialog so that it is properly constructed before we set the Compose content.
235 // Otherwise, the ComposeView won't render properly.
236 dialog.create()
237
238 // Set the content. Note that the background of the dialog is drawn on the DecorView of the
239 // dialog directly, which makes it automatically work nicely with DialogTransitionAnimator.
240 dialog.setContentView(
241 ComposeView(context).apply {
242 setContent {
243 PlatformTheme {
244 val defaultContentColor = MaterialTheme.colorScheme.onSurfaceVariant
245 CompositionLocalProvider(LocalContentColor provides defaultContentColor) {
246 content(dialog)
247 }
248 }
249 }
250 }
251 )
252
253 return dialog
254 }
255
256 /** Adds paddings for the bottom sheet surface. */
257 @Composable
Modifiernull258 private fun Modifier.bottomSheetPaddings(): Modifier {
259 val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
260 return with(LocalDensity.current) {
261 val insets = WindowInsets.safeDrawing
262 // TODO(b/337205027) change paddings
263 val horizontalPadding: Dp = if (isPortrait) 0.dp else 48.dp
264 padding(
265 start = insets.getLeft(this, LocalLayoutDirection.current).toDp() + horizontalPadding,
266 top = insets.getTop(this).toDp(),
267 end = insets.getRight(this, LocalLayoutDirection.current).toDp() + horizontalPadding,
268 )
269 }
270 }
271
272 /**
273 * For some reason adding clickable modifier onto the VolumePanel affects the traversal order:
274 * b/331155283.
275 *
276 * TODO(b/334870995) revert this to Modifier.clickable
277 */
278 @Composable
bottomSheetClickablenull279 private fun Modifier.bottomSheetClickable(onClick: () -> Unit) =
280 pointerInput(onClick) { detectTapGestures { onClick() } }
281
282 @Composable
DragHandlenull283 private fun DragHandle(dialog: Dialog) {
284 // TODO(b/373340318): Rename drag handle string resource.
285 val dragHandleContentDescription =
286 stringResource(id = R.string.shortcut_helper_content_description_drag_handle)
287 Surface(
288 modifier =
289 Modifier.padding(top = 16.dp, bottom = 6.dp)
290 .semantics {
291 contentDescription = dragHandleContentDescription
292 hideFromAccessibility()
293 }
294 .clickable { dialog.dismiss() },
295 color = MaterialTheme.colorScheme.onSurfaceVariant,
296 shape = MaterialTheme.shapes.extraLarge,
297 ) {
298 Box(Modifier.size(width = 32.dp, height = 4.dp))
299 }
300 }
301
302 @Composable
draggableTopPaddingnull303 private fun draggableTopPadding(): Dp {
304 return if (hasCompactWindowSize()) DraggableBottomSheet.DefaultTopPadding
305 else DraggableBottomSheet.LargeScreenTopPadding
306 }
307
308 private object DraggableBottomSheet {
309 val DefaultTopPadding = 64.dp
310 val LargeScreenTopPadding = 56.dp
311 val MaxWidth = 640.dp
312 }
313