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 }