| /* |
| * Copyright 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.compose.ui.window |
| |
| import android.app.Dialog |
| import android.content.Context |
| import android.graphics.Outline |
| import android.view.MotionEvent |
| import android.view.View |
| import android.view.ViewGroup |
| import android.view.ViewOutlineProvider |
| import android.view.Window |
| import android.view.WindowManager |
| import androidx.appcompat.view.ContextThemeWrapper |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.CompositionReference |
| import androidx.compose.runtime.DisposableEffect |
| import androidx.compose.runtime.Immutable |
| import androidx.compose.runtime.SideEffect |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.rememberCompositionReference |
| import androidx.compose.runtime.rememberUpdatedState |
| import androidx.compose.runtime.setValue |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.R |
| import androidx.compose.ui.layout.Layout |
| import androidx.compose.ui.platform.AbstractComposeView |
| import androidx.compose.ui.platform.AmbientDensity |
| import androidx.compose.ui.platform.AmbientView |
| import androidx.compose.ui.semantics.dialog |
| import androidx.compose.ui.semantics.semantics |
| import androidx.compose.ui.unit.Density |
| import androidx.compose.ui.unit.dp |
| import androidx.compose.ui.util.fastForEach |
| import androidx.compose.ui.util.fastMap |
| import androidx.compose.ui.util.fastMaxBy |
| import androidx.lifecycle.ViewTreeLifecycleOwner |
| import androidx.lifecycle.ViewTreeViewModelStoreOwner |
| import androidx.savedstate.ViewTreeSavedStateRegistryOwner |
| |
| /** |
| * Android specific properties to configure a dialog. |
| * |
| * @property dismissOnClickOutside whether the dialog can be dismissed by pressing the back button. |
| * If true, pressing the back button will call onDismissRequest. |
| * @property dismissOnClickOutside whether the dialog can be dismissed by clicking outside the |
| * dialog's bounds. If true, clicking outside the dialog will call onDismissRequest. |
| * @property securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the |
| * dialog's window. |
| */ |
| @Immutable |
| class AndroidDialogProperties( |
| val dismissOnBackPress: Boolean = true, |
| val dismissOnClickOutside: Boolean = true, |
| val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit |
| ) : DialogProperties { |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is AndroidDialogProperties) return false |
| |
| if (dismissOnBackPress != other.dismissOnBackPress) return false |
| if (dismissOnClickOutside != other.dismissOnClickOutside) return false |
| if (securePolicy != other.securePolicy) return false |
| |
| return true |
| } |
| |
| override fun hashCode(): Int { |
| var result = dismissOnBackPress.hashCode() |
| result = 31 * result + dismissOnClickOutside.hashCode() |
| result = 31 * result + securePolicy.hashCode() |
| return result |
| } |
| } |
| |
| /** |
| * Opens a dialog with the given content. |
| * |
| * The dialog is visible as long as it is part of the composition hierarchy. |
| * In order to let the user dismiss the Dialog, the implementation of [onDismissRequest] should |
| * contain a way to remove to remove the dialog from the composition hierarchy. |
| * |
| * Example usage: |
| * |
| * @sample androidx.compose.ui.samples.DialogSample |
| * |
| * @param onDismissRequest Executes when the user tries to dismiss the Dialog. |
| * @param properties Typically platform specific properties to further configure the dialog. |
| * @param content The content to be displayed inside the dialog. |
| */ |
| @Composable |
| internal actual fun ActualDialog( |
| onDismissRequest: () -> Unit, |
| properties: DialogProperties?, |
| content: @Composable () -> Unit |
| ) { |
| val view = AmbientView.current |
| val density = AmbientDensity.current |
| val composition = rememberCompositionReference() |
| val currentContent by rememberUpdatedState(content) |
| val dialog = remember(view, density) { |
| DialogWrapper(view, density).apply { |
| this.onDismissRequest = onDismissRequest |
| setProperties(properties) |
| setContent(composition) { |
| // TODO(b/159900354): draw a scrim and add margins around the Compose Dialog, and |
| // consume clicks so they can't pass through to the underlying UI |
| DialogLayout( |
| Modifier.semantics { dialog() }, |
| ) { |
| currentContent() |
| } |
| } |
| } |
| } |
| |
| DisposableEffect(dialog) { |
| dialog.show() |
| |
| onDispose { |
| dialog.dismiss() |
| dialog.disposeComposition() |
| } |
| } |
| |
| SideEffect { |
| dialog.onDismissRequest = onDismissRequest |
| dialog.setProperties(properties) |
| } |
| } |
| |
| /** |
| * Provides the underlying window of a dialog. |
| * |
| * Implemented by dialog's root layout. |
| */ |
| interface DialogWindowProvider { |
| val window: Window |
| } |
| |
| @Suppress("ViewConstructor") |
| private class DialogLayout( |
| context: Context, |
| override val window: Window |
| ) : AbstractComposeView(context), DialogWindowProvider { |
| |
| private var content: @Composable () -> Unit by mutableStateOf({}) |
| |
| protected override var shouldCreateCompositionOnAttachedToWindow: Boolean = false |
| private set |
| |
| fun setContent(parent: CompositionReference, content: @Composable () -> Unit) { |
| setParentCompositionReference(parent) |
| this.content = content |
| shouldCreateCompositionOnAttachedToWindow = true |
| createComposition() |
| } |
| |
| @Composable |
| override fun Content() { |
| content() |
| } |
| } |
| |
| private class DialogWrapper( |
| private val composeView: View, |
| density: Density |
| ) : Dialog( |
| /** |
| * [Window.setClipToOutline] is only available from 22+, but the style attribute exists on 21. |
| * So use a wrapped context that sets this attribute for compatibility back to 21. |
| */ |
| ContextThemeWrapper(composeView.context, R.style.DialogWindowTheme) |
| ) { |
| lateinit var onDismissRequest: () -> Unit |
| |
| private val dialogLayout: DialogLayout |
| private var properties: AndroidDialogProperties = AndroidDialogProperties() |
| |
| private val maxSupportedElevation = 30.dp |
| |
| init { |
| val window = window ?: error("Dialog has no window") |
| window.requestFeature(Window.FEATURE_NO_TITLE) |
| window.setBackgroundDrawableResource(android.R.color.transparent) |
| dialogLayout = DialogLayout(context, window).apply { |
| // Enable children to draw their shadow by not clipping them |
| clipChildren = false |
| // Allocate space for elevation |
| with(density) { elevation = maxSupportedElevation.toPx() } |
| // Simple outline to force window manager to allocate space for shadow. |
| // Note that the outline affects clickable area for the dismiss listener. In case of |
| // shapes like circle the area for dismiss might be to small (rectangular outline |
| // consuming clicks outside of the circle). |
| outlineProvider = object : ViewOutlineProvider() { |
| override fun getOutline(view: View, result: Outline) { |
| result.setRect(0, 0, view.width, view.height) |
| // We set alpha to 0 to hide the view's shadow and let the composable to draw |
| // its own shadow. This still enables us to get the extra space needed in the |
| // surface. |
| result.alpha = 0f |
| } |
| } |
| } |
| |
| /** |
| * Disables clipping for [this] and all its descendant [ViewGroup]s until we reach a |
| * [DialogLayout] (the [ViewGroup] containing the Compose hierarchy). |
| */ |
| fun ViewGroup.disableClipping() { |
| clipChildren = false |
| if (this is DialogLayout) return |
| for (i in 0 until childCount) { |
| (getChildAt(i) as? ViewGroup)?.disableClipping() |
| } |
| } |
| |
| // Turn of all clipping so shadows can be drawn outside the window |
| (window.decorView as? ViewGroup)?.disableClipping() |
| setContentView(dialogLayout) |
| ViewTreeLifecycleOwner.set(dialogLayout, ViewTreeLifecycleOwner.get(composeView)) |
| ViewTreeViewModelStoreOwner.set(dialogLayout, ViewTreeViewModelStoreOwner.get(composeView)) |
| ViewTreeSavedStateRegistryOwner.set( |
| dialogLayout, |
| ViewTreeSavedStateRegistryOwner.get(composeView) |
| ) |
| } |
| |
| // TODO(b/159900354): Make the Android Dialog full screen and the scrim fully transparent |
| |
| fun setContent(parentComposition: CompositionReference, children: @Composable () -> Unit) { |
| dialogLayout.setContent(parentComposition, children) |
| } |
| |
| private fun setSecureFlagEnabled(secureFlagEnabled: Boolean) { |
| window!!.setFlags( |
| if (secureFlagEnabled) { |
| WindowManager.LayoutParams.FLAG_SECURE |
| } else { |
| WindowManager.LayoutParams.FLAG_SECURE.inv() |
| }, |
| WindowManager.LayoutParams.FLAG_SECURE |
| ) |
| } |
| |
| fun setProperties(newProperties: DialogProperties?) { |
| if (newProperties is AndroidDialogProperties) { |
| properties = newProperties |
| } |
| setSecureFlagEnabled( |
| properties.securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled()) |
| ) |
| } |
| |
| fun disposeComposition() { |
| dialogLayout.disposeComposition() |
| } |
| |
| override fun onTouchEvent(event: MotionEvent): Boolean { |
| val result = super.onTouchEvent(event) |
| if (result && properties.dismissOnClickOutside) { |
| onDismissRequest() |
| } |
| |
| return result |
| } |
| |
| override fun cancel() { |
| // Prevents the dialog from dismissing itself |
| return |
| } |
| |
| override fun onBackPressed() { |
| if (properties.dismissOnBackPress) { |
| onDismissRequest() |
| } |
| } |
| } |
| |
| @Composable |
| private fun DialogLayout( |
| modifier: Modifier = Modifier, |
| content: @Composable () -> Unit |
| ) { |
| Layout( |
| content = content, |
| modifier = modifier |
| ) { measurables, constraints -> |
| val placeables = measurables.fastMap { it.measure(constraints) } |
| val width = placeables.fastMaxBy { it.width }?.width ?: constraints.minWidth |
| val height = placeables.fastMaxBy { it.height }?.height ?: constraints.minHeight |
| layout(width, height) { |
| placeables.fastForEach { it.placeRelative(0, 0) } |
| } |
| } |
| } |