AndroidDialogCustom.kt (13128B) - raw
1 package me.rhunk.snapenhance.ui.util 2 3 4 import android.annotation.SuppressLint 5 import android.app.Activity 6 import android.content.Context 7 import android.graphics.Outline 8 import android.os.Build 9 import android.provider.Settings 10 import android.view.* 11 import androidx.activity.ComponentDialog 12 import androidx.activity.addCallback 13 import androidx.compose.runtime.* 14 import androidx.compose.runtime.saveable.rememberSaveable 15 import androidx.compose.ui.ExperimentalComposeUiApi 16 import androidx.compose.ui.Modifier 17 import androidx.compose.ui.R 18 import androidx.compose.ui.layout.Layout 19 import androidx.compose.ui.platform.* 20 import androidx.compose.ui.semantics.dialog 21 import androidx.compose.ui.semantics.semantics 22 import androidx.compose.ui.unit.Density 23 import androidx.compose.ui.unit.LayoutDirection 24 import androidx.compose.ui.unit.dp 25 import androidx.compose.ui.window.SecureFlagPolicy 26 import androidx.core.view.WindowCompat 27 import androidx.lifecycle.findViewTreeLifecycleOwner 28 import androidx.lifecycle.findViewTreeViewModelStoreOwner 29 import androidx.lifecycle.setViewTreeLifecycleOwner 30 import androidx.lifecycle.setViewTreeViewModelStoreOwner 31 import androidx.savedstate.findViewTreeSavedStateRegistryOwner 32 import androidx.savedstate.setViewTreeSavedStateRegistryOwner 33 import java.util.UUID 34 import kotlin.math.roundToInt 35 36 class DialogProperties constructor( 37 val dismissOnBackPress: Boolean = true, 38 val dismissOnClickOutside: Boolean = true, 39 val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, 40 val usePlatformDefaultWidth: Boolean = true, 41 val decorFitsSystemWindows: Boolean = true 42 ) { 43 44 constructor( 45 dismissOnBackPress: Boolean = true, 46 dismissOnClickOutside: Boolean = true, 47 securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, 48 ) : this( 49 dismissOnBackPress = dismissOnBackPress, 50 dismissOnClickOutside = dismissOnClickOutside, 51 securePolicy = securePolicy, 52 usePlatformDefaultWidth = true, 53 decorFitsSystemWindows = true 54 ) 55 56 override fun equals(other: Any?): Boolean { 57 if (this === other) return true 58 if (other !is DialogProperties) return false 59 60 if (dismissOnBackPress != other.dismissOnBackPress) return false 61 if (dismissOnClickOutside != other.dismissOnClickOutside) return false 62 if (securePolicy != other.securePolicy) return false 63 if (usePlatformDefaultWidth != other.usePlatformDefaultWidth) return false 64 if (decorFitsSystemWindows != other.decorFitsSystemWindows) return false 65 66 return true 67 } 68 69 override fun hashCode(): Int { 70 var result = dismissOnBackPress.hashCode() 71 result = 31 * result + dismissOnClickOutside.hashCode() 72 result = 31 * result + securePolicy.hashCode() 73 result = 31 * result + usePlatformDefaultWidth.hashCode() 74 result = 31 * result + decorFitsSystemWindows.hashCode() 75 return result 76 } 77 } 78 79 @Composable 80 fun Dialog( 81 onDismissRequest: () -> Unit, 82 properties: DialogProperties = DialogProperties(), 83 content: @Composable () -> Unit 84 ) { 85 val view = LocalView.current 86 val density = LocalDensity.current 87 val layoutDirection = LocalLayoutDirection.current 88 val composition = rememberCompositionContext() 89 val currentContent by rememberUpdatedState(content) 90 val dialogId = rememberSaveable { UUID.randomUUID() } 91 val dialog = remember(view, density) { 92 DialogWrapper( 93 onDismissRequest, 94 properties, 95 view, 96 layoutDirection, 97 density, 98 dialogId 99 ).apply { 100 setContent(composition) { 101 // TODO(b/159900354): draw a scrim and add margins around the Compose Dialog, and 102 // consume clicks so they can't pass through to the underlying UI 103 DialogLayout( 104 Modifier.semantics { dialog() }, 105 ) { 106 currentContent() 107 } 108 } 109 } 110 } 111 112 DisposableEffect(dialog) { 113 // Set the dialog's window type to TYPE_APPLICATION_OVERLAY so it's compatible with compose overlays 114 if (Settings.canDrawOverlays(view.context) && view.context !is Activity) { 115 dialog.window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY) 116 } 117 dialog.show() 118 119 onDispose { 120 dialog.dismiss() 121 dialog.disposeComposition() 122 } 123 } 124 125 SideEffect { 126 dialog.updateParameters( 127 onDismissRequest = onDismissRequest, 128 properties = properties, 129 layoutDirection = layoutDirection 130 ) 131 } 132 } 133 134 interface DialogWindowProvider { 135 val window: Window 136 } 137 138 @Suppress("ViewConstructor") 139 private class DialogLayout( 140 context: Context, 141 override val window: Window 142 ) : AbstractComposeView(context), DialogWindowProvider { 143 144 private var content: @Composable () -> Unit by mutableStateOf({}) 145 146 var usePlatformDefaultWidth = false 147 148 override var shouldCreateCompositionOnAttachedToWindow: Boolean = false 149 private set 150 151 fun setContent(parent: CompositionContext, content: @Composable () -> Unit) { 152 setParentCompositionContext(parent) 153 this.content = content 154 shouldCreateCompositionOnAttachedToWindow = true 155 createComposition() 156 } 157 158 override fun measureChild( 159 child: View?, 160 parentWidthMeasureSpec: Int, 161 parentHeightMeasureSpec: Int 162 ) { 163 164 super.measureChild(child, parentWidthMeasureSpec, parentHeightMeasureSpec) 165 } 166 167 private val displayWidth: Int 168 get() { 169 val density = context.resources.displayMetrics.density 170 return (context.resources.configuration.screenWidthDp * density).roundToInt() 171 } 172 173 private val displayHeight: Int 174 get() { 175 val density = context.resources.displayMetrics.density 176 return (context.resources.configuration.screenHeightDp * density).roundToInt() 177 } 178 179 @Composable 180 override fun Content() { 181 content() 182 } 183 } 184 185 @SuppressLint("PrivateResource") 186 private class DialogWrapper( 187 private var onDismissRequest: () -> Unit, 188 private var properties: DialogProperties, 189 private val composeView: View, 190 layoutDirection: LayoutDirection, 191 density: Density, 192 dialogId: UUID 193 ) : ComponentDialog( 194 ContextThemeWrapper( 195 composeView.context, 196 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S || properties.decorFitsSystemWindows) { 197 R.style.DialogWindowTheme 198 } else { 199 R.style.FloatingDialogWindowTheme 200 } 201 ) 202 ), 203 ViewRootForInspector { 204 205 private val dialogLayout: DialogLayout 206 207 // On systems older than Android S, there is a bug in the surface insets matrix math used by 208 // elevation, so high values of maxSupportedElevation break accessibility services: b/232788477. 209 private val maxSupportedElevation = 8.dp 210 211 override val subCompositionView: AbstractComposeView get() = dialogLayout 212 213 private val defaultSoftInputMode: Int 214 215 init { 216 val window = window ?: error("Dialog has no window") 217 defaultSoftInputMode = 218 window.attributes.softInputMode and WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST 219 window.requestFeature(Window.FEATURE_NO_TITLE) 220 window.setBackgroundDrawableResource(android.R.color.transparent) 221 @OptIn(ExperimentalComposeUiApi::class) 222 WindowCompat.setDecorFitsSystemWindows(window, properties.decorFitsSystemWindows) 223 dialogLayout = DialogLayout(context, window).apply { 224 // Set unique id for AbstractComposeView. This allows state restoration for the state 225 // defined inside the Dialog via rememberSaveable() 226 setTag(R.id.compose_view_saveable_id_tag, "Dialog:$dialogId") 227 // Enable children to draw their shadow by not clipping them 228 clipChildren = false 229 // Allocate space for elevation 230 with(density) { elevation = maxSupportedElevation.toPx() } 231 // Simple outline to force window manager to allocate space for shadow. 232 // Note that the outline affects clickable area for the dismiss listener. In case of 233 // shapes like circle the area for dismiss might be to small (rectangular outline 234 // consuming clicks outside of the circle). 235 outlineProvider = object : ViewOutlineProvider() { 236 override fun getOutline(view: View, result: Outline) { 237 result.setRect(0, 0, view.width, view.height) 238 // We set alpha to 0 to hide the view's shadow and let the composable to draw 239 // its own shadow. This still enables us to get the extra space needed in the 240 // surface. 241 result.alpha = 0f 242 } 243 } 244 } 245 246 /** 247 * Disables clipping for [this] and all its descendant [ViewGroup]s until we reach a 248 * [DialogLayout] (the [ViewGroup] containing the Compose hierarchy). 249 */ 250 fun ViewGroup.disableClipping() { 251 clipChildren = false 252 if (this is DialogLayout) return 253 for (i in 0 until childCount) { 254 (getChildAt(i) as? ViewGroup)?.disableClipping() 255 } 256 } 257 258 // Turn of all clipping so shadows can be drawn outside the window 259 (window.decorView as? ViewGroup)?.disableClipping() 260 setContentView(dialogLayout) 261 dialogLayout.setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner()) 262 dialogLayout.setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner()) 263 dialogLayout.setViewTreeSavedStateRegistryOwner( 264 composeView.findViewTreeSavedStateRegistryOwner() 265 ) 266 267 // Initial setup 268 updateParameters(onDismissRequest, properties, layoutDirection) 269 270 // Due to how the onDismissRequest callback works 271 // (it enforces a just-in-time decision on whether to update the state to hide the dialog) 272 // we need to unconditionally add a callback here that is always enabled, 273 // meaning we'll never get a system UI controlled predictive back animation 274 // for these dialogs 275 onBackPressedDispatcher.addCallback(this) { 276 if (properties.dismissOnBackPress) { 277 onDismissRequest() 278 } 279 } 280 } 281 282 private fun setLayoutDirection(layoutDirection: LayoutDirection) { 283 dialogLayout.layoutDirection = when (layoutDirection) { 284 LayoutDirection.Ltr -> android.util.LayoutDirection.LTR 285 LayoutDirection.Rtl -> android.util.LayoutDirection.RTL 286 } 287 } 288 289 // TODO(b/159900354): Make the Android Dialog full screen and the scrim fully transparent 290 291 fun setContent(parentComposition: CompositionContext, children: @Composable () -> Unit) { 292 dialogLayout.setContent(parentComposition, children) 293 } 294 295 fun updateParameters( 296 onDismissRequest: () -> Unit, 297 properties: DialogProperties, 298 layoutDirection: LayoutDirection 299 ) { 300 this.onDismissRequest = onDismissRequest 301 this.properties = properties 302 setLayoutDirection(layoutDirection) 303 if (properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) { 304 // Undo fixed size in internalOnLayout, which would suppress size changes when 305 // usePlatformDefaultWidth is true. 306 window?.setLayout( 307 WindowManager.LayoutParams.WRAP_CONTENT, 308 WindowManager.LayoutParams.WRAP_CONTENT 309 ) 310 } 311 dialogLayout.usePlatformDefaultWidth = properties.usePlatformDefaultWidth 312 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { 313 @OptIn(ExperimentalComposeUiApi::class) 314 if (properties.decorFitsSystemWindows) { 315 window?.setSoftInputMode(defaultSoftInputMode) 316 } else { 317 @Suppress("DEPRECATION") 318 window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) 319 } 320 } 321 } 322 323 fun disposeComposition() { 324 dialogLayout.disposeComposition() 325 } 326 327 override fun onTouchEvent(event: MotionEvent): Boolean { 328 val result = super.onTouchEvent(event) 329 if (result && properties.dismissOnClickOutside) { 330 onDismissRequest() 331 } 332 333 return result 334 } 335 336 override fun cancel() { 337 // Prevents the dialog from dismissing itself 338 return 339 } 340 } 341 342 @Composable 343 private fun DialogLayout( 344 modifier: Modifier = Modifier, 345 content: @Composable () -> Unit 346 ) { 347 Layout( 348 content = content, 349 modifier = modifier 350 ) { measurables, constraints -> 351 val placeables = measurables.map { it.measure(constraints) } 352 val width = placeables.maxBy { it.width }.width 353 val height = placeables.maxBy { it.height }.height 354 layout(width, height) { 355 placeables.forEach { it.placeRelative(0, 0) } 356 } 357 } 358 }