1 /*
2 * Copyright 2024 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 androidx.compose.material3
18
19 import android.content.Context
20 import android.graphics.Outline
21 import android.os.Build
22 import android.view.ContextThemeWrapper
23 import android.view.MotionEvent
24 import android.view.View
25 import android.view.ViewOutlineProvider
26 import android.view.Window
27 import android.view.WindowManager
28 import android.window.BackEvent
29 import android.window.OnBackAnimationCallback
30 import android.window.OnBackInvokedCallback
31 import android.window.OnBackInvokedDispatcher
32 import androidx.activity.ComponentDialog
33 import androidx.activity.addCallback
34 import androidx.annotation.DoNotInline
35 import androidx.annotation.RequiresApi
36 import androidx.compose.foundation.isSystemInDarkTheme
37 import androidx.compose.foundation.layout.Box
38 import androidx.compose.material3.internal.PredictiveBack
39 import androidx.compose.material3.internal.shouldApplySecureFlag
40 import androidx.compose.runtime.Composable
41 import androidx.compose.runtime.CompositionContext
42 import androidx.compose.runtime.DisposableEffect
43 import androidx.compose.runtime.Immutable
44 import androidx.compose.runtime.SideEffect
45 import androidx.compose.runtime.getValue
46 import androidx.compose.runtime.mutableStateOf
47 import androidx.compose.runtime.remember
48 import androidx.compose.runtime.rememberCompositionContext
49 import androidx.compose.runtime.rememberUpdatedState
50 import androidx.compose.runtime.saveable.rememberSaveable
51 import androidx.compose.runtime.setValue
52 import androidx.compose.ui.Modifier
53 import androidx.compose.ui.R
54 import androidx.compose.ui.platform.AbstractComposeView
55 import androidx.compose.ui.platform.LocalDensity
56 import androidx.compose.ui.platform.LocalLayoutDirection
57 import androidx.compose.ui.platform.LocalView
58 import androidx.compose.ui.platform.ViewRootForInspector
59 import androidx.compose.ui.semantics.dialog
60 import androidx.compose.ui.semantics.semantics
61 import androidx.compose.ui.unit.Density
62 import androidx.compose.ui.unit.LayoutDirection
63 import androidx.compose.ui.unit.dp
64 import androidx.compose.ui.window.DialogWindowProvider
65 import androidx.compose.ui.window.SecureFlagPolicy
66 import androidx.core.view.WindowCompat
67 import androidx.lifecycle.findViewTreeLifecycleOwner
68 import androidx.lifecycle.findViewTreeViewModelStoreOwner
69 import androidx.lifecycle.setViewTreeLifecycleOwner
70 import androidx.lifecycle.setViewTreeViewModelStoreOwner
71 import androidx.savedstate.findViewTreeSavedStateRegistryOwner
72 import androidx.savedstate.setViewTreeSavedStateRegistryOwner
73 import java.util.UUID
74
75 // Logic forked from androidx.compose.ui.window.DialogProperties. Removed dismissOnClickOutside
76 // and usePlatformDefaultWidth as they are not relevant for fullscreen experience.
77 /**
78 * Properties used to customize the behavior of a [ModalWideNavigationRail].
79 *
80 * @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the modal
81 * navigation rail's window.
82 * @param shouldDismissOnBackPress Whether the modal navigation rail can be dismissed by pressing
83 * the back button. If true, pressing the back button will call onDismissRequest.
84 */
85 @Immutable
86 @ExperimentalMaterial3ExpressiveApi
87 actual class ModalWideNavigationRailProperties(
88 val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
89 @get:Suppress("GetterSetterNames") actual val shouldDismissOnBackPress: Boolean = true,
90 ) {
91 actual constructor(
92 shouldDismissOnBackPress: Boolean,
93 ) : this(
94 securePolicy = SecureFlagPolicy.Inherit,
95 shouldDismissOnBackPress = shouldDismissOnBackPress
96 )
97
equalsnull98 override fun equals(other: Any?): Boolean {
99 if (this === other) return true
100 if (other !is ModalWideNavigationRailProperties) return false
101 if (securePolicy != other.securePolicy) return false
102
103 return true
104 }
105
hashCodenull106 override fun hashCode(): Int {
107 var result = securePolicy.hashCode()
108 result = 31 * result + shouldDismissOnBackPress.hashCode()
109 return result
110 }
111 }
112
113 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
createDefaultModalWideNavigationRailPropertiesnull114 internal actual fun createDefaultModalWideNavigationRailProperties() =
115 ModalWideNavigationRailProperties()
116
117 // Fork of androidx.compose.ui.window.AndroidDialog_androidKt.Dialog
118 // Added predictiveBackProgress param to pass into ModalWideNavigationRailDialogWrapper.
119 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
120 @Composable
121 internal actual fun ModalWideNavigationRailDialog(
122 onDismissRequest: () -> Unit,
123 properties: ModalWideNavigationRailProperties,
124 onPredictiveBack: (Float) -> Unit,
125 onPredictiveBackCancelled: () -> Unit,
126 predictiveBackState: RailPredictiveBackState,
127 content: @Composable () -> Unit
128 ) {
129 val view = LocalView.current
130 val density = LocalDensity.current
131 val layoutDirection = LocalLayoutDirection.current
132 val composition = rememberCompositionContext()
133 val currentContent by rememberUpdatedState(content)
134 val dialogId = rememberSaveable { UUID.randomUUID() }
135 val darkThemeEnabled = isSystemInDarkTheme()
136 val dialog =
137 remember(view, density) {
138 ModalWideNavigationRailDialogWrapper(
139 onDismissRequest,
140 properties,
141 view,
142 layoutDirection,
143 density,
144 dialogId,
145 onPredictiveBack,
146 onPredictiveBackCancelled,
147 predictiveBackState,
148 darkThemeEnabled,
149 )
150 .apply {
151 setContent(composition) {
152 Box(
153 Modifier.semantics { dialog() },
154 ) {
155 currentContent()
156 }
157 }
158 }
159 }
160
161 DisposableEffect(dialog) {
162 dialog.show()
163
164 onDispose {
165 dialog.dismiss()
166 dialog.disposeComposition()
167 }
168 }
169
170 SideEffect {
171 dialog.updateParameters(
172 onDismissRequest = onDismissRequest,
173 properties = properties,
174 layoutDirection = layoutDirection
175 )
176 }
177 }
178
179 // Fork of androidx.compose.ui.window.DialogLayout
180 // Additional parameters required for current predictive back implementation.
181 @Suppress("ViewConstructor")
182 private class ModalWideNavigationRailDialogLayout(
183 context: Context,
184 override val window: Window,
185 val shouldDismissOnBackPress: Boolean,
186 private val onDismissRequest: () -> Unit,
187 private val onPredictiveBack: (Float) -> Unit,
188 private val onPredictiveBackCancelled: () -> Unit,
189 private val predictiveBackState: RailPredictiveBackState,
190 private val layoutDirection: LayoutDirection,
191 ) : AbstractComposeView(context), DialogWindowProvider {
192
193 private var content: @Composable () -> Unit by mutableStateOf({})
194
195 private var backCallback: Any? = null
196
197 override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
198 private set
199
setContentnull200 fun setContent(parent: CompositionContext, content: @Composable () -> Unit) {
201 setParentCompositionContext(parent)
202 this.content = content
203 shouldCreateCompositionOnAttachedToWindow = true
204 createComposition()
205 }
206
207 // Display width and height logic removed, size will always span fillMaxSize().
208
209 @Composable
Contentnull210 override fun Content() {
211 content()
212 }
213
214 // Existing predictive back behavior below.
onAttachedToWindownull215 override fun onAttachedToWindow() {
216 super.onAttachedToWindow()
217
218 maybeRegisterBackCallback()
219 }
220
onDetachedFromWindownull221 override fun onDetachedFromWindow() {
222 super.onDetachedFromWindow()
223
224 maybeUnregisterBackCallback()
225 }
226
maybeRegisterBackCallbacknull227 private fun maybeRegisterBackCallback() {
228 if (!shouldDismissOnBackPress || Build.VERSION.SDK_INT < 33) {
229 return
230 }
231 if (backCallback == null) {
232 backCallback =
233 if (Build.VERSION.SDK_INT >= 34) {
234 Api34Impl.createBackCallback(
235 onDismissRequest = onDismissRequest,
236 onPredictiveBack = onPredictiveBack,
237 onPredictiveBackCancelled = onPredictiveBackCancelled,
238 predictiveBackState = predictiveBackState,
239 layoutDirection = layoutDirection
240 )
241 } else {
242 Api33Impl.createBackCallback(onDismissRequest)
243 }
244 }
245 Api33Impl.maybeRegisterBackCallback(this, backCallback)
246 }
247
maybeUnregisterBackCallbacknull248 private fun maybeUnregisterBackCallback() {
249 if (Build.VERSION.SDK_INT >= 33) {
250 Api33Impl.maybeUnregisterBackCallback(this, backCallback)
251 }
252 backCallback = null
253 }
254
255 @RequiresApi(34)
256 private object Api34Impl {
257 @JvmStatic
258 @DoNotInline
createBackCallbacknull259 fun createBackCallback(
260 onDismissRequest: () -> Unit,
261 onPredictiveBack: (Float) -> Unit,
262 onPredictiveBackCancelled: () -> Unit,
263 predictiveBackState: RailPredictiveBackState,
264 layoutDirection: LayoutDirection
265 ) =
266 object : OnBackAnimationCallback {
267 override fun onBackStarted(backEvent: BackEvent) {
268 predictiveBackState.update(
269 isSwipeEdgeLeft = backEvent.swipeEdge == BackEvent.EDGE_LEFT,
270 isRtl = layoutDirection == LayoutDirection.Rtl
271 )
272 onPredictiveBack(PredictiveBack.transform(backEvent.progress))
273 }
274
275 override fun onBackProgressed(backEvent: BackEvent) {
276 predictiveBackState.update(
277 isSwipeEdgeLeft = backEvent.swipeEdge == BackEvent.EDGE_LEFT,
278 isRtl = layoutDirection == LayoutDirection.Rtl
279 )
280 onPredictiveBack(PredictiveBack.transform(backEvent.progress))
281 }
282
283 override fun onBackInvoked() {
284 onDismissRequest()
285 }
286
287 override fun onBackCancelled() {
288 onPredictiveBackCancelled()
289 }
290 }
291 }
292
293 @RequiresApi(33)
294 private object Api33Impl {
295 @JvmStatic
296 @DoNotInline
createBackCallbacknull297 fun createBackCallback(onDismissRequest: () -> Unit) =
298 OnBackInvokedCallback(onDismissRequest)
299
300 @JvmStatic
301 @DoNotInline
302 fun maybeRegisterBackCallback(view: View, backCallback: Any?) {
303 if (backCallback is OnBackInvokedCallback) {
304 view
305 .findOnBackInvokedDispatcher()
306 ?.registerOnBackInvokedCallback(
307 OnBackInvokedDispatcher.PRIORITY_OVERLAY,
308 backCallback
309 )
310 }
311 }
312
313 @JvmStatic
314 @DoNotInline
maybeUnregisterBackCallbacknull315 fun maybeUnregisterBackCallback(view: View, backCallback: Any?) {
316 if (backCallback is OnBackInvokedCallback) {
317 view.findOnBackInvokedDispatcher()?.unregisterOnBackInvokedCallback(backCallback)
318 }
319 }
320 }
321 }
322
323 // Fork of androidx.compose.ui.window.DialogWrapper.
324 // scope and predictive back related params added for predictive back implementation.
325 // EdgeToEdgeFloatingDialogWindowTheme provided to allow theme to extend into status bar.
326 @ExperimentalMaterial3ExpressiveApi
327 private class ModalWideNavigationRailDialogWrapper(
328 private var onDismissRequest: () -> Unit,
329 private var properties: ModalWideNavigationRailProperties,
330 private val composeView: View,
331 layoutDirection: LayoutDirection,
332 density: Density,
333 dialogId: UUID,
334 onPredictiveBack: (Float) -> Unit,
335 onPredictiveBackCancelled: () -> Unit,
336 predictiveBackState: RailPredictiveBackState,
337 darkThemeEnabled: Boolean,
338 ) :
339 ComponentDialog(
340 ContextThemeWrapper(
341 composeView.context,
342 androidx.compose.material3.R.style.EdgeToEdgeFloatingDialogWindowTheme
343 )
344 ),
345 ViewRootForInspector {
346
347 private val dialogLayout: ModalWideNavigationRailDialogLayout
348
349 // On systems older than Android S, there is a bug in the surface insets matrix math used by
350 // elevation, so high values of maxSupportedElevation break accessibility services: b/232788477.
351 private val maxSupportedElevation = 8.dp
352
353 override val subCompositionView: AbstractComposeView
354 get() = dialogLayout
355
<lambda>null356 init {
357 val window = window ?: error("Dialog has no window")
358 window.requestFeature(Window.FEATURE_NO_TITLE)
359 window.setBackgroundDrawableResource(android.R.color.transparent)
360 WindowCompat.setDecorFitsSystemWindows(window, false)
361 dialogLayout =
362 ModalWideNavigationRailDialogLayout(
363 context = context,
364 window = window,
365 shouldDismissOnBackPress = properties.shouldDismissOnBackPress,
366 onDismissRequest = onDismissRequest,
367 onPredictiveBack = onPredictiveBack,
368 onPredictiveBackCancelled = onPredictiveBackCancelled,
369 predictiveBackState = predictiveBackState,
370 layoutDirection = layoutDirection,
371 )
372 .apply {
373 // Set unique id for AbstractComposeView. This allows state restoration for the
374 // state
375 // defined inside the Dialog via rememberSaveable()
376 setTag(R.id.compose_view_saveable_id_tag, "Dialog:$dialogId")
377 // Enable children to draw their shadow by not clipping them
378 clipChildren = false
379 // Allocate space for elevation
380 with(density) { elevation = maxSupportedElevation.toPx() }
381 // Simple outline to force window manager to allocate space for shadow.
382 // Note that the outline affects clickable area for the dismiss listener. In
383 // case of
384 // shapes like circle the area for dismiss might be to small (rectangular
385 // outline
386 // consuming clicks outside of the circle).
387 outlineProvider =
388 object : ViewOutlineProvider() {
389 override fun getOutline(view: View, result: Outline) {
390 result.setRect(0, 0, view.width, view.height)
391 // We set alpha to 0 to hide the view's shadow and let the
392 // composable to draw
393 // its own shadow. This still enables us to get the extra space
394 // needed in the
395 // surface.
396 result.alpha = 0f
397 }
398 }
399 }
400 // Clipping logic removed because we are spanning edge to edge.
401
402 setContentView(dialogLayout)
403 dialogLayout.setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
404 dialogLayout.setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
405 dialogLayout.setViewTreeSavedStateRegistryOwner(
406 composeView.findViewTreeSavedStateRegistryOwner()
407 )
408
409 // Initial setup
410 updateParameters(onDismissRequest, properties, layoutDirection)
411
412 WindowCompat.getInsetsController(window, window.decorView).apply {
413 isAppearanceLightStatusBars = !darkThemeEnabled
414 isAppearanceLightNavigationBars = !darkThemeEnabled
415 }
416 // Due to how the onDismissRequest callback works
417 // (it enforces a just-in-time decision on whether to update the state to hide the dialog)
418 // we need to unconditionally add a callback here that is always enabled,
419 // meaning we'll never get a system UI controlled predictive back animation
420 // for these dialogs
421 onBackPressedDispatcher.addCallback(this) {
422 if (properties.shouldDismissOnBackPress) {
423 onDismissRequest()
424 }
425 }
426 }
427
setLayoutDirectionnull428 private fun setLayoutDirection(layoutDirection: LayoutDirection) {
429 dialogLayout.layoutDirection =
430 when (layoutDirection) {
431 LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
432 LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
433 }
434 }
435
setContentnull436 fun setContent(parentComposition: CompositionContext, children: @Composable () -> Unit) {
437 dialogLayout.setContent(parentComposition, children)
438 }
439
setSecurePolicynull440 private fun setSecurePolicy(securePolicy: SecureFlagPolicy) {
441 val secureFlagEnabled =
442 securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled())
443 window!!.setFlags(
444 if (secureFlagEnabled) {
445 WindowManager.LayoutParams.FLAG_SECURE
446 } else {
447 WindowManager.LayoutParams.FLAG_SECURE.inv()
448 },
449 WindowManager.LayoutParams.FLAG_SECURE
450 )
451 }
452
updateParametersnull453 fun updateParameters(
454 onDismissRequest: () -> Unit,
455 properties: ModalWideNavigationRailProperties,
456 layoutDirection: LayoutDirection
457 ) {
458 this.onDismissRequest = onDismissRequest
459 this.properties = properties
460 setSecurePolicy(properties.securePolicy)
461 setLayoutDirection(layoutDirection)
462
463 // Window flags to span parent window.
464 window?.setLayout(
465 WindowManager.LayoutParams.MATCH_PARENT,
466 WindowManager.LayoutParams.MATCH_PARENT,
467 )
468 window?.setSoftInputMode(
469 if (Build.VERSION.SDK_INT >= 30) {
470 WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
471 } else {
472 @Suppress("DEPRECATION") WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
473 },
474 )
475 }
476
disposeCompositionnull477 fun disposeComposition() {
478 dialogLayout.disposeComposition()
479 }
480
onTouchEventnull481 override fun onTouchEvent(event: MotionEvent): Boolean {
482 val result = super.onTouchEvent(event)
483 if (result) {
484 onDismissRequest()
485 }
486
487 return result
488 }
489
cancelnull490 override fun cancel() {
491 // Prevents the dialog from dismissing itself
492 return
493 }
494 }
495