优雅地实现 Dialog 弹窗

本文探讨了对话框组件设计的两种常见方式,并引入KotlinDSL提升组件的可维护性和易用性。通过建造者模式和DSL,作者展示了如何创建可定制且结构清晰的对话框,实现自定义样式和功能,类似于声明式UI的编程范式。
摘要由CSDN通过智能技术生成

前言

现在大多数的项目当中都会有一个对话框组件,其目的是为了可以将涉及到对话框场景的逻辑,或者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 实例,顶层函数如下

图片

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

图片

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

图片

这样的一个毛坯弹框在视觉上还需打磨。按照设计师给的视觉图,在现有基础上去自定义弹框是我们接下去要做的事情,撇开一些特定的业务场景,一个对话框组件需要具备如下功能

  1. 弹框布局可自定义样式,比如圆角,背景颜色

  2. 弹框标题可自定义,比如文案,字体颜色,大小

  3. 弹框内容可自定义,比如文案,字体颜色,大小

  4. 弹框按钮数量可配置一个或两个

对话框布局

第一步我们先做弹框的布局,对于一个对话框组件来讲,设计师会事先给一个所有弹框样式遵守的布局,我们以一个简单的 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。

转自:手把手教你优雅地实现 Dialog 弹窗

从底部弹出的dialog。位置你可以在base里自己改。使用方法都有。public class BaseDialog extends Dialog { private View mContentView; public Context mContext; public LayoutInflater mInflater; public BaseDialog(Context context) { this(context, R.style.BaseDialogStyle); mContext = context; } public BaseDialog(Context context, int themeResId) { super(context, themeResId); mContext = context; } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); super.setContentView(R.layout.dialog_base); Window dialogWindow = getWindow(); WindowManager.LayoutParams lp1 = dialogWindow.getAttributes(); WindowManager wm = (WindowManager) getContext() .getSystemService(Context.WINDOW_SERVICE); DisplayMetrics outMetrics = new DisplayMetrics(); wm.getDefaultDisplay().getMetrics(outMetrics); int width = outMetrics.widthPixels; lp1.width = width; dialogWindow.setGravity(Gravity.BOTTOM); dialogWindow.setAttributes(lp1); mInflater = LayoutInflater.from(mContext); } public void setContentView(int layoutResID) { mContentView = View.inflate(mContext, layoutResID, null); LinearLayout view = (LinearLayout) findViewById(R.id.base_container); view.addView(mContentView); } public View getContextView() { return mContentView; } } public class CustomDialog extends BaseDialog implements View.OnClickListener { /** * 标题 */ private TextView mTitleTv; /** * 确定按钮 */ private Button mPositiveBt; /** * 取消按钮 */ private Button mNegativeBt; /** * 确定取消中间分割线 */ private View mDiverView; /** * 内容容器 */ private LinearLayout mContentLin; private View.OnClickListener mPositiveClickListener; private View.OnClickListener mNegativeClickListener; private List<String> mContents; private String mContent; private String mTilte; private String mPositiveText; private String mNegativeText; private CustomDialog(Context context) { super(context); } private CustomDialog(Context context, int themeResId) { super(context, themeResId); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.dialog_one_button); mTitleTv = (TextView) findViewById(R.id.title); mContentLin = (LinearLayout) findViewById(R.id.container); mPositiveBt = (Button) findViewById(R.id.positiveButton); mNegativeBt = (Button) findViewById(R.id.negativeButton); mDiverView = findViewById(R.id.diver); mPositiveBt.setOnClickListener(this); mNegativeBt.setOnClickListener(this); setContent(mContent); setContents(); setTitle(); setButtonClickListener(); setNegativeBtText(); setPositiveBtText(); } private void setContents() { if (mContents != null && mContents.size() != 0) { if (mContents.size() == 1) { setContent(mContents.get(0)); } else { for (int i = 0; i < mContents.size(); i++) { View view = mInflater.inflate(R.layout.diaolog_content_item_left, null); TextView tv = (TextView) view.findViewById(R.id.tv); tv.setText(mContents.get(i)); mContentLin.addView(view); } } } } private void setContent(String content) { if (!TextUtils.isEmpty(content)) { View view = mInflater.inflate(R.layout.diaolog_content_item_center, null); TextView tv = (TextView) view.findViewById(R.id.tv); tv.setText(content); mContentLin.addView(view); } } /** * 设置标题 */ private void setTitle() { if (!TextUtils.isEmpty(mTilte)) { mTitleTv.setVisibility(View.VISIBLE); mTitleTv.setText(mTilte); } } /** * 设置右侧按钮的显示文字,默认为确定 */ private void setPositiveBtText() { if (!TextUtils.isEmpty(mPositiveText)) { mPositiveBt.setText(mPositiveText); } } /** * 设置左侧按钮的显示文字,默认为取消 */ private void setNegativeBtText() { if (!TextUtils.isEmpty(mNegativeText)) { mNegativeBt.setText(mNegativeText); } } /** * 设置监听 可不设置 */ private void setButtonClickListener() { if (mPositiveClickListener != null) { mPositiveBt.setVisibility(View.VISIBLE); } if (mNegativeClickListener != null) { mNegativeBt.setVisibility(View.VISIBLE); } if (mNegativeClickListener != null && mPositiveClickListener != null) { mDiverView.setVisibility(View.VISIBLE); } } public static class Builder { private CustomDialog dialog; public Builder(Context context) { dialog = new CustomDialog(context); } public Builder(Context context, int themeResId) { dialog = new CustomDialog(context, themeResId); } /** * 通过Builder类创建dialog */ public CustomDialog create() { return dialog; } /** * 多条提示 */ public Builder setContent(List<String> contents) { dialog.mContents = contents; return this; } /** * 单条提示 */ public Builder setContent(String content) { dialog.mContent = content; return this; } /** * 设置标题 */ public Builder setTitle(String tilte) { dialog.mTilte = tilte; return this; } /** * 设置右侧按钮的显示文字,默认为确定 * * @param positiveText 按钮上的显示内容 */ public Builder setPositiveBtText(String positiveText) { dialog.mPositiveText = positiveText; return this; } /** * 设置左侧按钮的显示文字,默认为取消 * * @param negativeText 按钮上的显示内容 */ public Builder setNegativeBtText(String negativeText) { dialog.mNegativeText = negativeText; return this; } ; /** * 设置监听 可不设置 * * @param positiveClickListener 确定按钮 * @param negativeClickListener 确定取消按钮 */ public Builder setButtonClickListener(View.OnClickListener positiveClickListener, View.OnClickListener negativeClickListener) { dialog.mPositiveClickListener = positiveClickListener; dialog.mNegativeClickListener = negativeClickListener; return this; } } @Override public void onClick(View v) { switch (v.getId()) { case R.id.positiveButton: if (mPositiveClickListener != null) { mPositiveClickListener.onClick(v); dismiss(); } break; case R.id.negativeButton: if (mNegativeClickListener != null) { mNegativeClickListener.onClick(v); dismiss(); } break; } } }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值