|
| 1 | +package com.holix.android.bottomsheetdialog.compose |
| 2 | + |
| 3 | +import android.app.Dialog |
| 4 | +import android.content.Context |
| 5 | +import android.graphics.Outline |
| 6 | +import android.view.* |
| 7 | +import androidx.compose.runtime.* |
| 8 | +import androidx.compose.runtime.saveable.rememberSaveable |
| 9 | +import androidx.compose.ui.Modifier |
| 10 | +import androidx.compose.ui.graphics.Color |
| 11 | +import androidx.compose.ui.graphics.toArgb |
| 12 | +import androidx.compose.ui.layout.Layout |
| 13 | +import androidx.compose.ui.platform.* |
| 14 | +import androidx.compose.ui.semantics.dialog |
| 15 | +import androidx.compose.ui.semantics.semantics |
| 16 | +import androidx.compose.ui.unit.Density |
| 17 | +import androidx.compose.ui.unit.LayoutDirection |
| 18 | +import androidx.compose.ui.unit.dp |
| 19 | +import androidx.compose.ui.window.Dialog |
| 20 | +import androidx.compose.ui.window.DialogProperties |
| 21 | +import androidx.compose.ui.window.SecureFlagPolicy |
| 22 | +import androidx.lifecycle.ViewTreeLifecycleOwner |
| 23 | +import androidx.lifecycle.ViewTreeViewModelStoreOwner |
| 24 | +import androidx.savedstate.findViewTreeSavedStateRegistryOwner |
| 25 | +import androidx.savedstate.setViewTreeSavedStateRegistryOwner |
| 26 | +import com.google.android.material.bottomsheet.BottomSheetBehavior |
| 27 | +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN |
| 28 | +import com.google.android.material.bottomsheet.BottomSheetDialog |
| 29 | +import java.util.* |
| 30 | + |
| 31 | + |
| 32 | +/** |
| 33 | + * Properties used to customize the behavior of a [Dialog]. |
| 34 | + * |
| 35 | + * @property dismissOnBackPress whether the dialog can be dismissed by pressing the back button. |
| 36 | + * If true, pressing the back button will call onDismissRequest. |
| 37 | + * @property dismissOnClickOutside whether the dialog can be dismissed by clicking outside the |
| 38 | + * dialog's bounds. If true, clicking outside the dialog will call onDismissRequest. |
| 39 | + * @property securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the |
| 40 | + * dialog's window. |
| 41 | + * @property usePlatformDefaultWidth Whether the width of the dialog's content should be limited to |
| 42 | + * the platform default, which is smaller than the screen width. |
| 43 | + */ |
| 44 | +@Immutable |
| 45 | +class BottomSheetDialogProperties constructor( |
| 46 | + val dismissOnBackPress: Boolean = true, |
| 47 | + val dismissOnClickOutside: Boolean = true, |
| 48 | + val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, |
| 49 | + val navigationBarColor: Color = Color.Unspecified |
| 50 | +) { |
| 51 | + |
| 52 | + override fun equals(other: Any?): Boolean { |
| 53 | + if (this === other) return true |
| 54 | + if (other !is DialogProperties) return false |
| 55 | + |
| 56 | + if (dismissOnBackPress != other.dismissOnBackPress) return false |
| 57 | + if (dismissOnClickOutside != other.dismissOnClickOutside) return false |
| 58 | + if (securePolicy != other.securePolicy) return false |
| 59 | + |
| 60 | + return true |
| 61 | + } |
| 62 | + |
| 63 | + override fun hashCode(): Int { |
| 64 | + var result = dismissOnBackPress.hashCode() |
| 65 | + result = 31 * result + dismissOnClickOutside.hashCode() |
| 66 | + result = 31 * result + securePolicy.hashCode() |
| 67 | + return result |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +/** |
| 72 | + * Opens a dialog with the given content. |
| 73 | + * |
| 74 | + * The dialog is visible as long as it is part of the composition hierarchy. |
| 75 | + * In order to let the user dismiss the Dialog, the implementation of [onDismissRequest] should |
| 76 | + * contain a way to remove to remove the dialog from the composition hierarchy. |
| 77 | + * |
| 78 | + * Example usage: |
| 79 | + * |
| 80 | + * @sample androidx.compose.ui.samples.DialogSample |
| 81 | + * |
| 82 | + * @param onDismissRequest Executes when the user tries to dismiss the dialog. |
| 83 | + * @param properties [DialogProperties] for further customization of this dialog's behavior. |
| 84 | + * @param content The content to be displayed inside the dialog. |
| 85 | + */ |
| 86 | +@Composable |
| 87 | +fun BottomSheetDialog( |
| 88 | + onDismissRequest: () -> Unit, |
| 89 | + properties: BottomSheetDialogProperties = BottomSheetDialogProperties(), |
| 90 | + content: @Composable () -> Unit |
| 91 | +) { |
| 92 | + val view = LocalView.current |
| 93 | + val density = LocalDensity.current |
| 94 | + val layoutDirection = LocalLayoutDirection.current |
| 95 | + val composition = rememberCompositionContext() |
| 96 | + val currentContent by rememberUpdatedState(content) |
| 97 | + val dialogId = rememberSaveable { UUID.randomUUID() } |
| 98 | + val dialog = remember(view, density) { |
| 99 | + BottomSheetDialogWrapper( |
| 100 | + onDismissRequest, |
| 101 | + properties, |
| 102 | + view, |
| 103 | + layoutDirection, |
| 104 | + density, |
| 105 | + dialogId |
| 106 | + ).apply { |
| 107 | + setContent(composition) { |
| 108 | + // TODO(b/159900354): draw a scrim and add margins around the Compose Dialog, and |
| 109 | + // consume clicks so they can't pass through to the underlying UI |
| 110 | + BottomSheetDialogLayout( |
| 111 | + Modifier.semantics { dialog() }, |
| 112 | + ) { |
| 113 | + currentContent() |
| 114 | + } |
| 115 | + } |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + DisposableEffect(dialog) { |
| 120 | + dialog.show() |
| 121 | + |
| 122 | + onDispose { |
| 123 | + dialog.dismiss() |
| 124 | + dialog.disposeComposition() |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + SideEffect { |
| 129 | + dialog.updateParameters( |
| 130 | + onDismissRequest = onDismissRequest, |
| 131 | + properties = properties, |
| 132 | + layoutDirection = layoutDirection |
| 133 | + ) |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +/** |
| 138 | + * Provides the underlying window of a dialog. |
| 139 | + * |
| 140 | + * Implemented by dialog's root layout. |
| 141 | + */ |
| 142 | +interface DialogWindowProvider { |
| 143 | + val window: Window |
| 144 | +} |
| 145 | + |
| 146 | +@Suppress("ViewConstructor") |
| 147 | +private class BottomSheetDialogLayout( |
| 148 | + context: Context, |
| 149 | + override val window: Window |
| 150 | +) : AbstractComposeView(context), DialogWindowProvider { |
| 151 | + private var content: @Composable () -> Unit by mutableStateOf({}) |
| 152 | + |
| 153 | + override var shouldCreateCompositionOnAttachedToWindow: Boolean = false |
| 154 | + private set |
| 155 | + |
| 156 | + fun setContent(parent: CompositionContext, content: @Composable () -> Unit) { |
| 157 | + setParentCompositionContext(parent) |
| 158 | + this.content = content |
| 159 | + shouldCreateCompositionOnAttachedToWindow = true |
| 160 | + createComposition() |
| 161 | + } |
| 162 | + |
| 163 | + @Composable |
| 164 | + override fun Content() { |
| 165 | + content() |
| 166 | + } |
| 167 | +} |
| 168 | + |
| 169 | +private class BottomSheetDialogWrapper( |
| 170 | + private var onDismissRequest: () -> Unit, |
| 171 | + private var properties: BottomSheetDialogProperties, |
| 172 | + private val composeView: View, |
| 173 | + layoutDirection: LayoutDirection, |
| 174 | + density: Density, |
| 175 | + dialogId: UUID |
| 176 | +) : BottomSheetDialog( |
| 177 | + /** |
| 178 | + * [Window.setClipToOutline] is only available from 22+, but the style attribute exists on 21. |
| 179 | + * So use a wrapped context that sets this attribute for compatibility back to 21. |
| 180 | + */ |
| 181 | + ContextThemeWrapper(composeView.context, R.style.TransparentBottomSheetTheme) |
| 182 | +), |
| 183 | + ViewRootForInspector { |
| 184 | + private val bottomSheetDialogLayout: BottomSheetDialogLayout |
| 185 | + |
| 186 | + private val maxSupportedElevation = 30.dp |
| 187 | + |
| 188 | + override val subCompositionView: AbstractComposeView get() = bottomSheetDialogLayout |
| 189 | + |
| 190 | + init { |
| 191 | + val window = window ?: error("Dialog has no window") |
| 192 | + window.requestFeature(Window.FEATURE_NO_TITLE) |
| 193 | + window.setBackgroundDrawableResource(android.R.color.transparent) |
| 194 | + bottomSheetDialogLayout = BottomSheetDialogLayout(context, window).apply { |
| 195 | + // Set unique id for AbstractComposeView. This allows state restoration for the state |
| 196 | + // defined inside the Dialog via rememberSaveable() |
| 197 | + setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Dialog:$dialogId") |
| 198 | + // Enable children to draw their shadow by not clipping them |
| 199 | + clipChildren = false |
| 200 | + // Allocate space for elevation |
| 201 | + with(density) { elevation = maxSupportedElevation.toPx() } |
| 202 | + // Simple outline to force window manager to allocate space for shadow. |
| 203 | + // Note that the outline affects clickable area for the dismiss listener. In case of |
| 204 | + // shapes like circle the area for dismiss might be to small (rectangular outline |
| 205 | + // consuming clicks outside of the circle). |
| 206 | + outlineProvider = object : ViewOutlineProvider() { |
| 207 | + override fun getOutline(view: View, result: Outline) { |
| 208 | + result.setRect(0, 0, view.width, view.height) |
| 209 | + // We set alpha to 0 to hide the view's shadow and let the composable to draw |
| 210 | + // its own shadow. This still enables us to get the extra space needed in the |
| 211 | + // surface. |
| 212 | + result.alpha = 0f |
| 213 | + } |
| 214 | + } |
| 215 | + } |
| 216 | + this.behavior.addBottomSheetCallback( |
| 217 | + object : BottomSheetBehavior.BottomSheetCallback() { |
| 218 | + override fun onSlide(bottomSheet: View, slideOffset: Float) { |
| 219 | + } |
| 220 | + |
| 221 | + override fun onStateChanged(bottomSheet: View, newState: Int) { |
| 222 | + if (newState == STATE_HIDDEN) { |
| 223 | + onDismissRequest() |
| 224 | + } |
| 225 | + } |
| 226 | + } |
| 227 | + ) |
| 228 | + |
| 229 | + /** |
| 230 | + * Disables clipping for [this] and all its descendant [ViewGroup]s until we reach a |
| 231 | + * [BottomSheetDialogLayout] (the [ViewGroup] containing the Compose hierarchy). |
| 232 | + */ |
| 233 | + fun ViewGroup.disableClipping() { |
| 234 | + clipChildren = false |
| 235 | + if (this is BottomSheetDialogLayout) return |
| 236 | + for (i in 0 until childCount) { |
| 237 | + (getChildAt(i) as? ViewGroup)?.disableClipping() |
| 238 | + } |
| 239 | + } |
| 240 | + |
| 241 | + // Turn of all clipping so shadows can be drawn outside the window |
| 242 | + (window.decorView as? ViewGroup)?.disableClipping() |
| 243 | + setContentView(bottomSheetDialogLayout) |
| 244 | + ViewTreeLifecycleOwner.set(bottomSheetDialogLayout, ViewTreeLifecycleOwner.get(composeView)) |
| 245 | + ViewTreeViewModelStoreOwner.set(bottomSheetDialogLayout, ViewTreeViewModelStoreOwner.get(composeView)) |
| 246 | + bottomSheetDialogLayout.setViewTreeSavedStateRegistryOwner( |
| 247 | + composeView.findViewTreeSavedStateRegistryOwner() |
| 248 | + ) |
| 249 | + |
| 250 | + // Initial setup |
| 251 | + updateParameters(onDismissRequest, properties, layoutDirection) |
| 252 | + } |
| 253 | + |
| 254 | + private fun setLayoutDirection(layoutDirection: LayoutDirection) { |
| 255 | + bottomSheetDialogLayout.layoutDirection = when (layoutDirection) { |
| 256 | + LayoutDirection.Ltr -> android.util.LayoutDirection.LTR |
| 257 | + LayoutDirection.Rtl -> android.util.LayoutDirection.RTL |
| 258 | + } |
| 259 | + } |
| 260 | + |
| 261 | + // TODO(b/159900354): Make the Android Dialog full screen and the scrim fully transparent |
| 262 | + |
| 263 | + fun setContent(parentComposition: CompositionContext, children: @Composable () -> Unit) { |
| 264 | + bottomSheetDialogLayout.setContent(parentComposition, children) |
| 265 | + } |
| 266 | + |
| 267 | + private fun setSecurePolicy(securePolicy: SecureFlagPolicy) { |
| 268 | + val secureFlagEnabled = |
| 269 | + securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled()) |
| 270 | + window!!.setFlags( |
| 271 | + if (secureFlagEnabled) { |
| 272 | + WindowManager.LayoutParams.FLAG_SECURE |
| 273 | + } else { |
| 274 | + WindowManager.LayoutParams.FLAG_SECURE.inv() |
| 275 | + }, |
| 276 | + WindowManager.LayoutParams.FLAG_SECURE |
| 277 | + ) |
| 278 | + } |
| 279 | + |
| 280 | + private fun setNavigationBarColor(color: Color) { |
| 281 | + if (color != Color.Unspecified) { |
| 282 | + window!!.navigationBarColor = color.toArgb() |
| 283 | + } |
| 284 | + } |
| 285 | + |
| 286 | + fun updateParameters( |
| 287 | + onDismissRequest: () -> Unit, |
| 288 | + properties: BottomSheetDialogProperties, |
| 289 | + layoutDirection: LayoutDirection |
| 290 | + ) { |
| 291 | + this.onDismissRequest = onDismissRequest |
| 292 | + this.properties = properties |
| 293 | + setSecurePolicy(properties.securePolicy) |
| 294 | + setLayoutDirection(layoutDirection) |
| 295 | + setCanceledOnTouchOutside(properties.dismissOnClickOutside) |
| 296 | + setNavigationBarColor(properties.navigationBarColor) |
| 297 | + } |
| 298 | + |
| 299 | + fun disposeComposition() { |
| 300 | + bottomSheetDialogLayout.disposeComposition() |
| 301 | + } |
| 302 | + |
| 303 | + override fun cancel() { |
| 304 | + onDismissRequest() |
| 305 | + } |
| 306 | + |
| 307 | + override fun onBackPressed() { |
| 308 | + if (properties.dismissOnBackPress) { |
| 309 | + onDismissRequest() |
| 310 | + } |
| 311 | + } |
| 312 | +} |
| 313 | + |
| 314 | +@Composable |
| 315 | +private fun BottomSheetDialogLayout( |
| 316 | + modifier: Modifier = Modifier, |
| 317 | + content: @Composable () -> Unit |
| 318 | +) { |
| 319 | + Layout( |
| 320 | + content = content, |
| 321 | + modifier = modifier |
| 322 | + ) { measurables, constraints -> |
| 323 | + val placeables = measurables.map { it.measure(constraints) } |
| 324 | + val width = placeables.maxByOrNull { it.width }?.width ?: constraints.minWidth |
| 325 | + val height = placeables.maxByOrNull { it.height }?.height ?: constraints.minHeight |
| 326 | + layout(width, height) { |
| 327 | + placeables.forEach { it.placeRelative(0, 0) } |
| 328 | + } |
| 329 | + } |
| 330 | +} |
| 331 | + |
| 332 | +fun View.isFlagSecureEnabled(): Boolean { |
| 333 | + val windowParams = rootView.layoutParams as? WindowManager.LayoutParams |
| 334 | + if (windowParams != null) { |
| 335 | + return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0 |
| 336 | + } |
| 337 | + return false |
| 338 | +} |
| 339 | + |
| 340 | +fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean { |
| 341 | + return when (this) { |
| 342 | + SecureFlagPolicy.SecureOff -> false |
| 343 | + SecureFlagPolicy.SecureOn -> true |
| 344 | + SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent |
| 345 | + } |
| 346 | +} |
0 commit comments