注册

使用DSL的方式自定义了一个弹框,代码忽然变的有那么一点点好看


现在大多数的项目当中都会有一个弹框组件,其目的是为了可以将涉及到弹框场景的逻辑,或者ui统一的进行管理维护,带来的好处是需要弹框的地方不用重新自己去自定义一个,导致弹框轮子泛滥,而是调用组件提供的api将一个符合设计规范的弹框渲染出来,如果设计规范更新了,只要更新一下组件,那么所有弹框都可以一起更新,节省了逐个修改的时间。从另一个方面来说,由于弹框组件几乎整个团队里面每个人都会使用,它的优点与缺点将统统暴露出来,所以如何去设计一个弹框组件是每一个开发者都要去考虑的问题,而目前我们常见的弹框组件设计方式有两种


常见的设计方式


使用构造函数一键生成


image.png

这是一种设计方式,会将弹框标题,弹框内容,弹框按钮文案,弹框按钮点击事件一起传给构造函数,再多重载几个函数来支持一些特定场景比如没有标题,单个按钮,文案颜色等等,我一般如果接手个项目,这个项目是多人开发的话,我都会主动揽下弹框组件开发的任务,不是因为写弹框有瘾,主要是担心别人使用这种方式写框子,说又不好说,做起来真的是噩梦,这种方式的优点缺点总结如下



  • 优点:未知
  • 缺点:

    • 代码角度来讲,可读性比较差,大量的入参会让调用者在填写参数的时候产生迷惑,不知道具体某一个参数对应的是什么功能。
    • 对于维护人员来讲,每次组件需要改动一个元素,就需要将每个构造函数的逻辑都修改一遍,工作量大并且容易出错。
    • 对于调用方来讲,每次需要写大量参数,并且需要严格遵守参数的声明顺序,组件如果更新了函数签名,调用处就会产生编译报错



使用建造者模式链式调用


image.png

另一种设计方式是使用建造者模式,这也是我惯用的方式,将弹框中的所有元素都一一对外暴露出一个方法,让调用方去设置,需要用到哪个元素就去设置设置哪个元素,组件内部默认实现一套样式,如果有的元素没有被调用方设置,就默认使用组件自带的实现方式,但这种方式也有优缺点,总结如下



  • 优点:将功能用函数区分开来,职能清晰,调用方可根据自己的需求选择性的调用对应函数渲染弹框
  • 缺点:维护者需要不断根据新的需求往组件里面添加新的方法供调用方使用,比如想要将标题加粗,如果组件没有提供对应的setTitleBold这样的方法,那么调用方将无法实现这个功能,多轮迭代下来,可能组件里面已经积攒了各种各样的方法,如果不好好分类管理,那阅读起来也是很头疼的一件事情

第三种设计方式


鉴于上述提到的两种设计方式以及总结出来的优缺点,我们不禁有个疑问,这种方式也不行,那个方式也不是很好,那么这么常用的组件难道就没有更好的设计方式了吗,能够设计出来以后可以满足如下几个要求



  • 组件拥有极强的扩展性,调用方可以随意定义自己需要的功能
  • 维护方不用频繁的在组件中添加功能,保持组件的稳定性
  • 结构清晰,每个代码块负责一个组件元素的功能

DSL的定义


想要实现以上几点,我们就要使用这篇文章的重点DSL了,那什么是DSL呢,那就是领域专用语言:专门解决某一特定问题的计算机语言,比如我们常用的正则表达式就是一种DSL,它与我们常用的api不一样,有着自己独特的结构,也叫做文法,在Kotlin里面这种结构我们使用lambda表达式去完成


带接收者的lambda


在使用DSL自定义弹框之前,我们先看一个例子,我们刚接触kotlin的时候,一定接触过它标准库里的let跟apply函数,也死记硬背的区分了一下这俩函数的区别,在实际开发当中也用到过,比如有一个按钮,我们需要去设置它的文案,字体大小以及点击事件,一般会这么做


image.png

我们看到每次访问按钮的一个属性就要重复写一下button,如果访问的属性变多了,那代码就会显的特别的啰嗦,所以这个时候,let跟apply函数就派上用场了


image.png

我们看到两者的区别体现在了let后面的lambda表达式里面,使用it显示的代替了button,如果万一button需要改变一下变量名,我们只需要更改let左边的button就好,而apply后面的表达式里面,完全省略了it,整个表达式的作用域就是button,可以直接访问button的属性,我们在牢记这个差异的同时,是不是也想一想,为什么这俩函数会存在这样的差异呢?答案就在这俩函数的源码当中,我们看一下


image.png

我们看到两个函数源码最大的区别在于let的入参是一个参数为T的函数类型的参数,所以在lambda表达式中我们可以用it显示的代替T,而apply的入参稍显不同,它的入参也是个函数类型,但是T被挪到了括号的前面,当作一个接收者来接受lambda表达式中返回的结果,所以才会导致apply函数后面只有它的属性以及值,结构及其精简,而kotlin中的DSL的主要语法点就是带接收者的lambda,现在我们就带着这个语法点开始一步步去自定义我们的弹框吧


开始开发


首先我们先从简单的实现一个AlertDialog弹框开始


image.png

AlertDialog的一个特点就是使用了建造者模式,每一个设置函数结束后都会返回给AlertDialog.Builder,那么从这一点上我们就可以仿照apply函数那样,将生成Dialog的这个过程转换成带有接收者的lambda表达式,那么先要做的就是给AlertDialog.Builder增加一个扩展函数,内部接收一个带有接收者的lambda表达式的参数


image.png

现在我们可以使用新增的createDialog函数来改变下刚刚生成AlertDialog的代码


image.png

createDialog作用类似于函数apply,lambda代码块的作用域就是AlertDialog.Builder,可以访问任何AlertDialog.Builder中的函数,上述代码我们可以再简化一下,将createDialog作为一个顶层函数,在函数内部生成AlertDialog.Builder实例,顶层函数如下


image.png

而调用弹框的地方代码也一同更改成了


image.png

运行一下代码我们就得到了一个系统自带的弹框


image.png

但是这样的一个弹框,我想国内应该没几个设计师会喜欢,所以按照设计师给的视觉图,在现有基础上去自定义弹框是我们接下去要做的事情,撇开一些特定的业务场景,一个弹框组件需要具备如下功能



  1. 弹框布局可自定义样式,比如圆角,背景颜色
  2. 弹框标题可自定义,比如文案,字体颜色,大小
  3. 弹框内容可自定义,比如文案,字体颜色,大小
  4. 弹框按钮数量可配置一个或两个

弹框布局


第一步我们先做弹框的布局,对于一个弹框组件来讲,设计师会事先将所有弹框样式都设计出来,所以整体布局的大体样式是固定的,我们以一个简单的dialog_layout布局文件作为弹框的样式


image.png

整个布局结构很简单,从上到下分别是标题,内容,按钮区,接下来我们就在顶层函数createDialog的lambda表达式中把布局设置到弹框里去,并且让弹框的宽度与屏幕宽度成比例自适应,毕竟不同app里面弹框的宽度都不一定相同


image.png

效果如下


image.png

一个纯白色弹框就出来了,接下来我们简化一下代码,由于每次调用弹框,dialog.show以及下面的设置宽度以及弹框位置的代码都会去调用,所以为了避免重复,反复造轮子,我们可以给AlertDialog增加一个扩展函数,将这些代码都放在扩展函数里面,上层只需要调用这个扩展函数就行,扩展函数我们就命名为showDialog,代码如下


image.png

上层调用弹框的地方就变成了


image.png

是不是精简了很多呢,代码运行的效果是一样的,就不展示了,但是目前我们这个框子还只是普通的样式,我们如果想要给它设置个圆角,然后捎带一些渐变色效果的背景,该怎么做呢?我们第一个想到的就是做一个drawable文件,在里面写上这些样式,再设置给布局根视图的background不就可以了吗,这的确是一个办法,但是如果有一天设计师突发奇想,觉得在某些场景下弹框使用样式A,某些场景下使用样式B,难道在生成一个新的drawable文件吗,这样一来单单一个弹框组件就要维护两种样式文件,给项目维护又带来了一定的成本,所以我们得想个更好的办法,就是使用GradientDrawable动态给布局设置样式,作法如下


image.png


看到在代码中用红框子以及绿框子区分了两部分代码,我们先看红框子里面,都能看明白主要是做渲染的工作,生成了一个GradientDrawable实例,然后分别对它设置了背景色,渐变方向,圆角大小,而这个我们就可以用带接收者的lambda表达式替换,GradientDrawable就是接收者,在看绿框子里面,虽然现在代码不多,但是setView之前肯定还得对view里面的元素做初始化等一系列操作,所以view也是一个接收者,初始化等操作可以放在lambda表达式中进行,理清了这些以后,我们新增一个AlertDialog.Builder的扩展函数rootLayout


image.png

rootLayout函数一共接收三个参数,root就是我们的弹框视图,render就是渲染操作,job是初始化view的操作,对于渲染操作来讲,rootLayout内部已经实现了一套默认的样式,如果调用方不使用render函数,那弹框就使用默认样式,如果使用了render函数,那么render里面有同样属性的就覆盖,有新增属性就累加,这个时候,上层调用方代码就更改为


image.png

我们运行一下看看效果


image.png

跟我们想要设置的效果一模一样,现在我们试试看不使用默认的样式,想要让弹框上面的圆角为12dp,下面没有圆角,背景渐变色变为从左到右方向由灰变白,我们在render函数里面加上这些设置


image.png

运行以后效果就变成了


image.png

弹框标题


有了弹框布局的开发经验,标题就容易多了,既然job函数的接收者是View,那么我们就给View先定一个扩展函数title


image.png

这个函数专门用来做标题相关部分的操作,而title的参数则是一个接收者为TextView的lambda表达式,用来在调用方额外给标题添加设置,那现在我们就可以给弹框添加个标题了,顺便把框的四个角都变成圆角,好看些


image.png

加了一个深色加粗标题,其中textColor属性是我添加的扩展属性,为的是让代码看上去整洁一些,效果等同于setTextColor(getColor(R.color.color_303F9F))


image.png

再次运行一下,标题就出来了


image.png

好像标题有点太靠上了,我们给弹框整体加个10dp的内边距在看下效果


image.png
image.png

效果出来了,我们再进行下一步


弹框内容


有了标题的例子,弹框内容基本都一样,不多说直接上代码


image.png

然后在弹框上添加一段文案


image.png

效果如下


image.png

弹框按钮


通常弹框组件都会有单个按钮弹框(提示型)和两个按钮弹框(交互型)两种类型,我们的dialog_layout布局中有两个TextView分别用来作为按钮,默认左边的negativeBtn是隐藏的,右边positiveBtn是展示出来的,这里我是仿照着AlertDialog里面设置按钮的逻辑来做,当只调用setPositiveButton的时候,表示此时为单个按钮弹框,当同时又调用了setNegativeButton的时候,就表示两个按钮的弹框,我们这边也借用这个思想,定义两个函数来控制这俩个按钮


image.png

代码很简单,当然也可以在函数里面加入一些默认样式,比如positiveBtn一般为高亮色值,negativeBtn为灰色色值,现在我们去调用下这俩函数,首先展示只有一个按钮的弹框


image.png

像Alertdialog一样只调用了positiveBtn函数就可以了,效果图如下


image.png

当我们要在弹框上显示两个按钮的时候,只需要再增加一个negativeBtn就可以了,就像这样


image.png
image.png

接下来就是给按钮设置监听事件了,非常容易,只需要调用setOnClickListener就可以了


image.png

这样其实可以完事了,弹框可以正常点击完以后做一些业务逻辑并且让弹框消失,但是仅仅这样的话我们这代码里还是存在着一些设计不合理的地方



  • 每一次createDialog以后,都必须showDialog以后弹框才能出来,这个可以让组件自己完成而不用调用方自己每次去showDialog
  • rootLayout返回的是AlertDialog.Builder对象,必须调用create以后才能得到AlertDialog对象去操作弹框展示与隐藏,这些也应该放在组件里面进行
  • 弹框按钮点击的默认操作基本都是关闭弹框,所以也没有必要每次在点击事件中显示的调用dismiss函数,也可以将关闭的动作放在组件中进行

那么我们就要更改下rootLayout函数,让它的返回值从AlertDialog.Builder变成Unit,而上述说的create以及showDialog操作,就要在rootLayout中进行,更改完的代码如下


image.png

mDialog是组件中维护的一个顶层属性,这也是为了在点击弹框按钮时候,在组件内部关闭弹框,接下去我们开始处理弹框按钮的点击事件,由于点击事件是作用在TextView上的,所以先给TextView增加一个扩展函数clickEvent,用来处理关闭弹框和其他点击事件的逻辑


image.png

现在我们可以回到调用方那边,将弹框的代码更新一下,并给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 { }
}
}
}

运行一下看看效果如何


aaa.gif


到这里我们的弹框组件就大功告成了,顺带贴上AlertDialog.kt的源码


弹框组件源码


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: ViewGr0up = mWindow?.decorView as ViewGr0up
val child: ViewGr0up = group.getChildAt(0) as ViewGr0up
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


作者:Coffeeee
来源:juejin.cn/post/7204601386607706172

0 个评论

要回复文章请先登录注册