• Jetpack Compose干货,如何让Compose Dialog从屏幕任意方向进入


    一、前言

    来个效果图,基于Compose Dialog,最终要实现的库能力如下:

    底部/顶部/左侧/右侧.gif

    这里使用的是这个包下面的:
    androidx.compose.ui.window.Dialog

    androidx.compose.material3.AlertDialog它内部调用的也是androidx.compose.ui.window.Dialog

    不想阅读文章的,可以直接滑到文章末尾,我提供了源码和集成指南。

    谷歌提供给我们的compose-ui-dialog,并没有看到能够控制从屏幕底部进入的方法,都是最基础的属性和参数。

    // androidx.compose.ui.window.Dialog
    @Composable
    fun Dialog(
        onDismissRequest: () -> Unit,
        properties: DialogProperties = DialogProperties(),
        content: @Composable () -> Unit
    ) {
      ....
    }
    
    // androidx.compose.material3.AlertDialog
    @Composable
    fun AlertDialog(
        onDismissRequest: () -> Unit,
        confirmButton: @Composable () -> Unit,
        modifier: Modifier = Modifier,
        dismissButton: @Composable (() -> Unit)? = null,
        icon: @Composable (() -> Unit)? = null,
        title: @Composable (() -> Unit)? = null,
        text: @Composable (() -> Unit)? = null,
        shape: Shape = AlertDialogDefaults.shape,
        containerColor: Color = AlertDialogDefaults.containerColor,
        iconContentColor: Color = AlertDialogDefaults.iconContentColor,
        titleContentColor: Color = AlertDialogDefaults.titleContentColor,
        textContentColor: Color = AlertDialogDefaults.textContentColor,
        tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
        properties: DialogProperties = DialogProperties()
    ) {
     ....
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    扫一眼源码,并没有看到能够控制Dialog从哪个方向弹出来,那么我们应该如何解决这个问题呢,毕竟不能直接使用Dialog我就很难受😁,你们是不是一样,请在评论区留言讨论🖋。

    二、默认Dialog能怎么玩

    不要认为我在凑字数划水,不过说实话确实有点像,不感兴趣的,可以直接跳到目录三

    既然用默认Dialog,那就中规中矩的玩😁,来个守规矩的Dialog示例

    @Composable
    fun MinimalDialog(onDismissRequest: () -> Unit) {
        Dialog(onDismissRequest = { onDismissRequest() }) {
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
                    .padding(16.dp),
                shape = RoundedCornerShape(16.dp),
            ) {
                Text(
                    text = "This is a minimal dialog",
                    modifier = Modifier
                        .fillMaxSize()
                        .wrapContentSize(Alignment.Center),
                    textAlign = TextAlign.Center,
                )
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    AlertDialog(
            onDismissRequest = onDismissRequest,
            icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
            title = {
                Text(text = "Title")
            },
            text = {
                Text(
                    "This area typically contains the supportive text " +
                            "which presents the details regarding the Dialog's purpose."
                )
            },
            confirmButton = {
                TextButton(
                    onClick = {
                       // 自己实现点击事件
                    }
                ) {
                    Text("Confirm")
                }
            },
            dismissButton = {
                TextButton(
                    onClick = {
                       // 自己实现点击事件
                    }
                ) {
                    Text("Dismiss")
                }
            }
        )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    我们可以看到都是基础到不能再基础的参数设置了,改改参数试试?

    试试就试试,谁怕谁啊,把第一个Dialog示例的Modifier修饰符修改一下使用fillMaxSize试试咯。

    我们看到宽度、高度没有真正的全屏,宽度这个好解决,只需要配置一下DialogProperties(usePlatformDefaultWidth = false) 即可(这里读者自己试试吧),那么高度怎么解决呢?

    我们需要延伸到系统栏,众所周知Android目前除了有小横条之外,还保留了虚拟键的三大金刚,如果无法把内容延伸到系统栏,这就不是真的全屏,而是伪全屏啊,看着就难受啊。

    有同学又会说:我直接用BottomSheetScaffold不也行吗?自己封装一个BottomSheetContent放在Scaffold组件里面。

    这些都要根据不同业务重度封装弹出组件了,有些业务你可能需要xml+composeView的形式,用Dialog具有更通用性。

    下面我们来研究一下,如何继续下面的目录内容。

    三、Dialog实现分析

    打开我们的AndroidDialog.android.kt

    @Composable
    fun Dialog(
        onDismissRequest: () -> Unit,
        properties: DialogProperties = DialogProperties(),
        content: @Composable () -> Unit
    ) {
        ...
        val dialog = remember(view, density) {
            DialogWrapper(view, ...).apply {
                setContent(composition) {
                    DialogLayout(Modifier.semantics { dialog() }) {
                        currentContent()
                    }
                }
            }
        }
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    我们打开DialogWrapper方法,看到setContentView它内部仍然是window.setContentView(View)

    @OptIn(ExperimentalComposeUiApi::class)
    private class DialogWrapper(
        private val composeView: View,
        ...
    ) : ComponentDialog(...){
        init {
          ...
          dialogLayout = DialogLayout(context, window)
          ...
          setContentView(dialogLayout)
         ...
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    然后呢,我们打开DialogLayout看看里面实现了什么:

    @Suppress("ViewConstructor")
    private class DialogLayout(
        context: Context,
        override val window: Window
    ) : AbstractComposeView(context), DialogWindowProvider {
        ...
        override fun internalOnMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
            if (usePlatformDefaultWidth) {
                super.internalOnMeasure(widthMeasureSpec, heightMeasureSpec)
            } else {
                val displayWidthMeasureSpec =
                    MeasureSpec.makeMeasureSpec(displayWidth, MeasureSpec.AT_MOST)
                val displayHeightMeasureSpec =
                    MeasureSpec.makeMeasureSpec(displayHeight, MeasureSpec.AT_MOST)
                super.internalOnMeasure(displayWidthMeasureSpec, displayHeightMeasureSpec)
            }
        }
    
        override fun internalOnLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
            super.internalOnLayout(changed, left, top, right, bottom)
            val child = getChildAt(0) ?: return
            // 设置WindowManager.LayoutParams
            window.setLayout(child.measuredWidth, child.measuredHeight)
        }
    
        private val displayWidth: Int
            get() {
                val density = context.resources.displayMetrics.density
                return (context.resources.configuration.screenWidthDp * density).roundToInt()
            }
    
        private val displayHeight: Int
            get() {
                val density = context.resources.displayMetrics.density
                return (context.resources.configuration.screenHeightDp * density).roundToInt()
            }
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    我们从上面代码可以看到 usePlatformDefaultWidth = false,不使用平台默认的宽度,可以实现全屏宽度。
    代码中我们又看到了内部有2个变量:displayWidth和displayHeight,他们两个返回的值分别是什么意思,建议大家去看源码注释非常详细,注释内容比较多,这里精简一下,大概的意思就是:

    即使你调用:Window#setDecorFitsSystemWindows(boolean)

    screenWidthDp、screenHeightDp 它返回的width、height
    不包含WindowInsets衬区的大小在内的。

    点击查看什么是WindowInsets

    e2e-intro.gif

    我们是不是可以通过decoreView获取真实的宽度和高度,答案是肯定的,那么我们如何通过Compose实现呢?

    四、Dialog从屏幕底部进入

    继续上面的内容,我们应该如何获取Window,这里我们需要分别获取2个Window:

    一个是Dialog的Window,一个是Activity的Window。

    在埋着头写无穷无尽的业务代码的时候😁,任何人都讨厌问十万个为什么问题的同学,因为这个时候人的怒气值是101%的,但是一但闲下来没事做的时候,就喜欢十万个为什么。

    理由一:我们不能直接修改dialog.window = activityWindow,即使可以也不能直接使用type不一样晓得不,有人又会问什么type,请读者自己查看源码:window.attributes.type,不可能全部都介绍一遍。

    理由二:默认的dialog的window里面的属性无法适应WindowInsets衬区

    理由N:你自己想一想…

    回到正题,那么如何获取Dialog的Window呢?

    细心的同学可能这里已经发现了,因为他是真的在参考源码,阅读文章的,我们上面的代码刚介绍了DialogLayout,不知道你们注意了没,它实现了DialogWindowProvider 接口,而这个接口只有一个变量就是Window,这很重要

    interface DialogWindowProvider {
        val window: Window
    }
    
    • 1
    • 2
    • 3

    同时我们还看到 DialogLayout 继承 AbstractComposeView,说到这里,我猜应该有同学知道怎么实现了,有点懵的😳,继续往下看。

    1、获取Dialog的Window

    我们使用Compose的Dialog一般是这样填充视图内容:

    Dialog(
        ...
        content = {
           // 这里放可组合项视图
        }
    )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    如果你不知道Compose UI创建布局绘制的流程,你可以点击查看我这一篇文章,我们可以通过LocalView.current获取当前的AndroidComposeView,刚刚上面讲的内容,忘记的请动动鼠标往上翻一下。

    我们定义一个方法返回Dialog的Window的方法:

    @Composable
    private fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window
    
    • 1
    • 2

    2、获取Activity的Window

    我们可以通过Context下手,通过递归的方式,判断是不是Activity,如果是则可以获取window了,那么可以定义如下的方法:

    @Composable
    private fun getActivityWindow(): Window? = LocalView.current.context.getActivityWindow()
    
    private tailrec fun Context.getActivityWindow(): Window? = when (this) {
        is Activity -> window
        is ContextWrapper -> baseContext.getActivityWindow()
        else -> null
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    3、实现底部动画弹出Dialog

    有了上面的内容支撑之后,我们可以往下实现了,首先需要定义一个全屏的Dialog可组合项:

    @Composable
    private fun DialogFullScreen(
        onDismissRequest: () -> Unit,
        content: @Composable () -> Unit
    ) {
        Dialog(
            onDismissRequest = onDismissRequest,
            properties = ...,
            content = {
                // 这里放Dialog的窗口内容...
            }
        )
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    一点点加入代码,把获取Window的代码放在content中,然后在SideEffect去更新DialogWindow:

    @Composable
    private fun DialogFullScreen(...) {
       Dialog(
           ...
           properties = DialogProperties(
               usePlatformDefaultWidth = true,
               decorFitsSystemWindows = false
           ),
           content = {
               val activityWindow = getActivityWindow()
               val dialogWindow = getDialogWindow()  
                SideEffect {
                    if (activityWindow != null && dialogWindow != null) {
                        val attributes = WindowManager.LayoutParams()
                        // 复制Activity窗口属性
                        attributes.copyFrom(activityWindow.attributes)
                        // 这个一定要设置
                        attributes.type = dialogWindow.attributes.type
                        // 更新窗口属性
                        dialogWindow.attributes = attributes
    
                        // 设置窗口的宽度和高度,这段代码Dialog源码中就有哦,可以自己去查看
                        dialogWindow.setLayout(
                            activityWindow.decorView.width,
                            activityWindow.decorView.height
                        )
                    }
                }    
           }
        )
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    可能这个时候又有同学疑问了,不对啊,怎么上面你说宽度铺满,告诉我们usePlatformDefaultWidth设置false,这里怎么是设置为true了?

    问的好,请看源码,设置false,会走源码内部的新测量分支,会使用displayWidth、displayHeight,这个是不含WindowInsets衬区的,因为它的 mode = MeasureSpec.AT_MOST,所以这里我们不用,不然竖屏高度,或者横屏宽度,你懂得,还需要再说的更详细嘛,大家都懂的。

    这个时候我们如果设置完Dialog内容视图之后,你会发现,Dialog自带的变暗背景色和点击空白区关闭Dialog失效了,并且是没有动画的,我们这个时候可以使用AnimatedVisibility来让内容通过动画的形式进入屏幕。

    什么时候执行动画呢,我们只需要在dialogWindow.setLayout代码后面更新visible状态变量就行了。

    AnimatedVisibility(
        modifier = Modifier.pointerInput(Unit) {},
        visible = isAnimateLayout,
        enter = slideInVertically(initialOffsetY = { it }),
        exit = fadeOut() + slideOutVertically(targetOffsetY = { it }),
    ) {
       content()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    然后暗色背景渐入渐出,我们加个蒙层背景视图即可,使用Animatable更新暗色背景渐入渐出。

    大家自行润色一下代码细节就可以使用了,源码在文章目录五查看:

        DialogFullScreen(
            onDismissRequest = onDismiss,
            properties = properties
        ) {
            Column(
                modifier = modifier.navigationBarsPadding(),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                content()
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    我们再更新一下代码,增加控制方向的属性,这样就可以控制它是从:底部/顶部/左侧/右侧 弹出。

    如果是屏幕中间弹出,建议你直接使用默认的Dialog可组合项即可。

    同样的,增加控制方向的属性之后,只需要更新AnimatedVisibility即可。

    底部/顶部/左侧/右侧.gif

    五、如何集成

    1、源码

    AnyPopDialog-Compose

    2、集成

    点击👉🏻查看依赖库最新版本号👈🏻

    implementation("io.github.TheMelody:any_pop_dialog_compose:<最新版本号>")
    
    • 1

    3、用法

    @Composable
    fun TestXXXX() {
        var showDialog by remember { mutableStateOf(false) }
        if (showDialog) {
            var isActiveClose by remember { mutableStateOf(false) }
            AnyPopDialog(
                modifier = Modifier.fillMaxWidth().background(...),
                isActiveClose = isActiveClose,
                // 根据你自己的功能,调整进入方向即可,支持:TOP/LEFT/RIGHT/BOTTOM
                // 也可以配置修改"状态栏"和"导航栏"背景色哦,自己查看方法注释即可
                properties = AnyPopDialogProperties(direction = DirectionState.BOTTOM),
                content = {
                    // 这里放你自己的Dialog内容
                    // 如果你需要在你自己的组件中想动画关闭Dialog,请更新isActiveClose
                },
                onDismiss = { showDialog = false }
            )
        }
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
  • 相关阅读:
    C 风格文件输入/输出 (std::fflush)(std::fwide)(std::setbuf)(std::setvbuf)
    Excelize 开源基础发布 2.8.1 版本,2024 年首个更新
    Redis面试题集
    国家药品不良反应监测中心 ADR 电子传输EDI解决方案
    Eolink征文活动---Eolink API文档服务的天才产品
    算法与数据结构 - 散列表
    周赛368 合法分组的最少组数(灵神笔记)
    cgal + sfcgal
    天书夜读笔记——C++写的内核驱动程序
    学习一下Java的ArrayList和contains函数和扩容机制
  • 原文地址:https://blog.csdn.net/logicsboy/article/details/133199010