前言
现在大多数的项目当中都会有一个对话框组件,其目的是为了可以将涉及到对话框场景的逻辑,或者ui统一的进行管理维护。调用组件提供的 api 将一个符合设计规范的对话框渲染出来,如果设计规范更新了,只要更新一下组件,那么所有对话框都可以一起更新,节省了逐个修改的时间。
也正是因为对话框组件几乎整个团队里面每个人都会使用,它的优点与缺点将统统暴露出来,所以如何去设计一个对话框组件是每一个开发者都要去考虑的问题,本文通过对比常见的设计方式,展示如何使用 DSL 更优雅地弹出我们的对话框。
常见的两个设计方式
使用构造函数一键生成
这是最传统生成对话框的方式,弹框内容,弹框按钮文案,弹框按钮点击事件一起传给构造函数,再多重载几个函数来支持一些特定场景比如没有标题,单个按钮,文案颜色等等。
这种方式由于缺少设计,缺点比较多:
-
代码角度来讲,可读性比较差,大量的入参会让调用者在填写参数的时候产生迷惑,不知道具体某一个参数对应的是什么功能。
-
对于维护人员来讲,每次组件需要改动一个元素,就需要将每个构造函数的逻辑都修改一遍,工作量大并且容易出错。
-
对于调用方来讲,每次需要写大量参数,并且需要严格遵守参数的声明顺序,组件如果更新了函数签名,调用处就会产生编译报错
使用建造者模式链式调用
建造者模式是另一种大家惯用的方式,将弹框中的所有元素都一一对外暴露出一个方法,让调用方去设置,需要用到哪个元素就去设置设置哪个元素,组件内部默认实现一套样式,如果有的元素没有被调用方设置,就默认使用组件自带的实现方式。
但这种方式也有优缺点,总结如下
-
优点: 将功能用函数区分开来,职能清晰,调用方可根据自己的需求选择性的调用对应函数渲染弹框
-
缺点: 维护者需要不断根据新的需求往组件里面添加新的方法供调用方使用,比如想要将标题加粗,如果组件没有提供对应的
setTitleBold
这样的方法,那么调用方将无法实现这个功能,多轮迭代下来,可能组件里面已经积攒了各种各样的方法,如果不好好分类管理,那阅读起来也是很头疼的一件事情
更优雅的设计方式:DSL
上述提到的两种设计方式都有不少缺点,有了 Kotlin 之后,我们可以引入 DSL 规避缺点、提升易用性:
-
组件拥有极强的扩展性,调用方可以随意定义自己需要的功能
-
维护方不用频繁的在组件中添加功能,保持组件的稳定性
-
结构清晰,每个代码块负责一个组件元素的功能
在使用 DSL 自定义弹框之前,我们先看一个例子。
比如有一个按钮,我们需要去设置它的文案,字体大小以及点击事件,一般 Kotlin 下的传统写法是:
我们看到每次访问按钮的一个属性就要重复写一下button,如果访问的属性变多了,那代码就会显的特别的啰嗦。
Kotlin 标准库提供了 let
跟 apply
等作用域函数,这是好东西,我们可以借助作用域函数改善写法。
我们看到两者的区别体现在了 let
后面的lambda表达式里面,使用 it
显示的代替了button,如果万一 button需要改变一下变量名,我们只需要更改 let
左边的button就好。
而 apply
后面的表达式里面,完全省略了 it
, 整个表达式的作用域就是 button,可以直接访问 button 的属性,更加清爽。
看一下这俩函数的源码:
我们看到两个函数源码最大的区别在于 let
的入参是一个参数为 T
的函数类型的参数, 所以在lambda表达式中我们可以用 it
显示的代替T;而 apply
的入参稍显不同,它的入参也是个函数类型,但是 T
被挪到了括号的前面,当作一个接收者来接受 lambda 表达式中返回的结果,所以才会导致 apply
函数后面只有它的属性以及值,结构及其精简。
kotlin 中实现 DSL 的的精髓就是 带接收者的lambda,接下来我们就带着这个语法点开始一步步去自定义我们的弹框。
基础对话框
首先我们先从简单的实现一个 AlertDialog
弹框开始
AlertDialog
的一个特点就是使用了建造者模式,每一个设置函数结束后都会返回给 AlertDialog.Builder
,这一点我们可以仿照 apply
函数那样,将生成 Dialog 的这个过程转换成带有接收者的 lambda 表达式,给 AlertDialog.Builder
增加一个扩展函数,内部接收一个带有接收者的lambda表达式的参数
现在我们可以使用新增的 createDialog
函数来改变下刚刚生成 AlertDialog
的代码
createDialog
作用类似于函数 apply
,lambda 代码块的作用域就是 AlertDialog.Builder
,可以访问任何 AlertDialog.Builder
中的函数。
上述代码我们可以再简化一下,将 createDialog
作为一个顶层函数,在函数内部生成 AlertDialog.Builder
实例,顶层函数如下
而调用弹框的地方代码也一同更改成了
运行一下代码我们就得到了一个系统自带的弹框
这样的一个毛坯弹框在视觉上还需打磨。按照设计师给的视觉图,在现有基础上去自定义弹框是我们接下去要做的事情,撇开一些特定的业务场景,一个对话框组件需要具备如下功能
-
弹框布局可自定义样式,比如圆角,背景颜色
-
弹框标题可自定义,比如文案,字体颜色,大小
-
弹框内容可自定义,比如文案,字体颜色,大小
-
弹框按钮数量可配置一个或两个
对话框布局
第一步我们先做弹框的布局,对于一个对话框组件来讲,设计师会事先给一个所有弹框样式遵守的布局,我们以一个简单的 dialog_layout
布局文件作为弹框的布局样式:
整个布局结构很简单,从上到下分别是标题,内容,按钮区。
接下来我们就在顶层函数 createDialog
的 lambda表达式中把布局设置到弹框里去,并且让弹框的宽度与屏幕宽度成比例自适应,毕竟不同app里面弹框的宽度都不一定相同
效果如下
一个纯白色弹框就出来了。接下来我们简化一下代码,由于每次调用弹框,dialog.show
以及下面的设置宽度以及弹框位置的代码都会去调用,所以为了避免重复,反复造轮子,我们可以给 AlertDialog
增加一个扩展函数,将这些代码都放在扩展函数里面,上层只需要调用这个扩展函数就行,扩展函数我们就命名为 showDialog
,代码如下
上层调用弹框的地方就变成了
代码是不是精简了很多。但是目前我们这个框子还只是普通的样式,我们如果想要给它设置个圆角,然后捎带一些渐变色效果的背景,该怎么做呢?
我们第一个想到的就是做一个 drawable
文件,在里面写上这些样式,再设置给布局根视图的 background
不就可以了吗,这的确是一个办法,但是如果有一天设计师突发奇想,觉得在某些场景下弹框使用样式A,某些场景下使用样式B,难道在生成一个新的 drawable
文件吗,这样一来单单一个对话框组件就要维护两种样式文件,给项目维护又带来了一定的成本,所以我们得想个更好的办法,就是使用 GradientDrawable
动态给布局设置样式,作法如下
看到在代码中用红框子以及绿框子区分了两部分代码,我们先看红框子里面,都能看明白主要是做渲染的工作,生成了一个 GradientDrawable
实例,然后分别对它设置了背景色,渐变方向,圆角大小。我们同样可以用带接收者的lambda表达式替换,GradientDrawable
就是接收者。
在看绿框子里面,虽然现在代码不多,但是 setView
之前肯定还得对 view 里面的元素做初始化等一系列操作,所以 view 也是一个接收者,初始化等操作可以放在lambda表达式中进行。
理清了这些以后,我们新增一个 AlertDialog.Builder
的扩展函数 rootLayout
rootLayout
函数一共接收三个参数,root
就是我们的弹框视图,render
就是渲染操作,job
是初始化view的操作。
对于渲染操作来讲,rootLayout
内部已经实现了一套默认的样式,如果调用方不使用 render
函数,那弹框就使用默认样式,如果使用了 render
函数,那么 render
里面有同样属性的就覆盖,有新增属性就累加,这个时候,上层调用方代码就更改为
我们运行一下看看效果
跟我们想要设置的效果一模一样,现在我们试试看不使用默认的样式,想要让弹框上面的圆角为12dp,下面没有圆角,背景渐变色变为从左到右方向由灰变白。
我们在 render
函数里面加上这些设置
运行以后效果就变成了
对话框标题
有了弹框布局的开发经验,标题就容易多了,既然 job
函数的接收者是 View, 那么我们就给 View 先定一个扩展函数 title
这个函数专门用来做标题相关部分的操作,而title的参数则是一个接收者为TextView的lambda表达式,用来在调用方额外给标题添加设置,那现在我们就可以给弹框添加个标题了,顺便把框的四个角都变成圆角,好看些
加了一个深色加粗标题,其中 textColor
属性是我添加的扩展属性,为的是让代码看上去整洁一些,效果等同于 setTextColor(getColor(R.color.color_303F9F))
再次运行一下,标题就出来了
好像标题有点太靠上了,我们给弹框整体加个10dp的内边距在看下效果
效果出来了,我们再进行下一步
对话框内容
有了标题的例子,弹框内容基本都一样,不多说直接上代码
然后在弹框上添加一段文案
效果如下
对话框按钮
通常对话框组件都会有单个按钮弹框(提示型)和两个按钮弹框(交互型)两种类型,我们的 dialog_layout
布局中有两个 TextView
分别用来作为按钮,默认左边的 negativeBtn
是隐藏的,右边 positiveBtn
是展示出来的。
这里我是仿照着 AlertDialog
里面设置按钮的逻辑来做,当只调用 setPositiveButton
的时候,表示此时为单个按钮弹框,当同时又调用了 setNegativeButton
的时候,就表示两个按钮的弹框,我们这边也借用这个思想,定义两个函数来控制这俩个按钮
代码很简单,当然也可以在函数里面加入一些默认样式,比如positiveBtn
一般为高亮色值,negativeBtn
为灰色色值,现在我们去调用下这俩函数,首先展示只有一个按钮的弹框
像 Alertdialog
一样只调用了 positiveBtn
函数就可以了,效果图如下
当我们要在弹框上显示两个按钮的时候,只需要再增加一个 negativeBtn
就可以了,就像这样
接下来就是给按钮设置监听事件了,非常容易,只需要调用 setOnClickListener
就可以了
这样其实可以完事了,弹框可以正常点击完以后做一些业务逻辑并且让弹框消失,但是仅仅这样的话我们这代码里还是存在着一些设计不合理的地方
-
每一次
createDialog
以后,都必须showDialog
以后弹框才能出来,这个可以让组件自己完成而不用调用方自己每次去showDialog
-
rootLayout
返回的是AlertDialog.Builder
对象,必须调用create
以后才能得到AlertDialog
对象去操作弹框展示与隐藏,这些也应该放在组件里面进行 -
弹框按钮点击的默认操作基本都是关闭弹框,所以也没有必要每次在点击事件中显示的调用
dismiss
函数,也可以将关闭的动作放在组件中进行
那么我们就要更改下 rootLayout
函数,让它的返回值从 AlertDialog.Builder
变成 Unit
,而上述说的c reate
以及 showDialog
操作,就要在 rootLayout
中进行,更改完的代码如下
mDialog
是组件中维护的一个顶层属性,这也是为了在点击弹框按钮时候,在组件内部关闭弹框,接下去我们开始处理弹框按钮的点击事件,由于点击事件是作用在 TextView
上的,所以先给 TextView
增加一个扩展函数 clickEvent
,用来处理关闭弹框和其他点击事件的逻辑
现在我们可以回到调用方那边,将弹框的代码更新一下,并给 positiveBtn
和 negativeBtn
分别加上新增的 clickEvent
函数作为点击事件,而 positiveBtn
点击后还会弹出一个 Toast 作为响应事件
createDialog(this) {
rootLayout(
root = layoutInflater.inflate(R.layout.dialog_layout, null),
render = {
orientation = GradientDrawable.Orientation.LEFT_RIGHT
colors = intArrayOf(
getColor(R.color.color_BBBBBB),
getColor(R.color.white)
)
cornerRadius = DensityUtil.dp2px(12f).toFloat()
}
) {
title {
text = "DSL弹框"
typeface = Typeface.DEFAULT_BOLD
textColor = getColor(R.color.color_303F9F)
}
message {
text = "用DSL方式自定义的弹框用DSL方式自定义的弹框用DSL方式自定义的弹框用DSL方式自定义的弹框"
gravity = Gravity.CENTER
textColor = getColor(R.color.black)
}
positiveBtn {
text = "知道了"
textColor = getColor(R.color.color_FF4081)
clickEvent {
Toast.makeText(this@MainActivity, "开始处理响应事件", Toast.LENGTH_SHORT).show()
}
}
negativeBtn {
text = "取消"
textColor = getColor(R.color.color_303F9F)
clickEvent { }
}
}
}
运行一下看看效果如何
对话框组件源码:
lateinit var mDialog: AlertDialog
var TextView.textColor: Int
get() {
return this.textColors.defaultColor
}
set(value) {
this.setTextColor(value)
}
fun createDialog(ctx: Context, body: AlertDialog.Builder.() -> Unit) {
val dialog = AlertDialog.Builder(ctx)
dialog.body()
}
@RequiresApi(Build.VERSION_CODES.M)
inline fun AlertDialog.Builder.rootLayout(
root: View,
render: GradientDrawable.() -> Unit = {},
job: View.() -> Unit
) {
with(GradientDrawable()){
//默认样式
render()
root.background = this
}
root.setPadding(DensityUtil.dp2px(10f))
root.job()
mDialog = setView(root).create()
mDialog.showDialog()
}
inline fun View.title(titleJob: TextView.() -> Unit) {
val title = findViewById<TextView>(R.id.dialog_title)
//可以加一些标题的默认操作,比如字体颜色,字体大小
title.titleJob()
}
inline fun View.message(messageJob: TextView.() -> Unit) {
val message = findViewById<TextView>(R.id.dialog_message)
//可以加一些内容的默认操作,比如字体颜色,字体大小,居左对齐还是居中对齐
message.messageJob()
}
inline fun View.negativeBtn(negativeJob: TextView.() -> Unit) {
val negativeBtn = findViewById<TextView>(R.id.dialog_negative_btn_text)
negativeBtn.visibility = View.VISIBLE
negativeBtn.negativeJob()
}
inline fun View.positiveBtn(positiveJob: TextView.() -> Unit) {
val positiveBtn = findViewById<TextView>(R.id.dialog_positive_btn_text)
positiveBtn.positiveJob()
}
inline fun TextView.clickEvent(crossinline event: () -> Unit) {
setOnClickListener {
mDialog.dismiss()
event()
}
}
fun AlertDialog.showDialog() {
show()
val mWindow = window
mWindow?.setBackgroundDrawableResource(R.color.transparent)
val group: ViewGroup = mWindow?.decorView as ViewGroup
val child: ViewGroup = group.getChildAt(0) as ViewGroup
child.post {
val param: WindowManager.LayoutParams? = mWindow.attributes
param?.width = (DensityUtil.getScreenWidth() * 0.8).toInt()
param?.gravity = Gravity.CENTER
mWindow.setGravity(Gravity.CENTER)
mWindow.attributes = param
}
}
总结
可能早就有人已经发现了,我们现在对话框的调用方式跟 Compose,React 很相似,也就是最近很流行的声明式 UI,为什么说它流行,比我们传统的命令式UI好用,主要的差别就在于声明式UI调用方只需要在乎视图的描述就可以,而真正视图如何渲染,如何测量,调用方不需要关心。
在我们的对话框的例子中,调用方全程需要做的就是对着视觉稿子,将对话框中的元素以及需要的属性样式一个个写上去就好了,就算弹框后期需求变化再频繁,对于调用方来说只是增减几个元素属性的事情,而像对话框如何设置自定义的视图,如何测量与屏幕之间的宽度比例等,不需要调用方去关心,所以这种方式在我们以后的开发当中可以逐步学习,适应,使用起来了,并不是说只有在写 React, Flutter 或者 Compose 之类的项目中才用到这种声明式UI。