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