現(xiàn)在大多數(shù)的項目當中都會有一個彈框組件,其目的是為了可以將涉及到彈框場景的邏輯,或者ui統(tǒng)一的進行管理維護,帶來的好處是需要彈框的地方不用重新自己去自定義一個,導致彈框輪子泛濫,而是調(diào)用組件提供的api將一個符合設(shè)計規(guī)范的彈框渲染出來,如果設(shè)計規(guī)范更新了,只要更新一下組件,那么所有彈框都可以一起更新,節(jié)省了逐個修改的時間。從另一個方面來說,由于彈框組件幾乎整個團隊里面每個人都會使用,它的優(yōu)點與缺點將統(tǒng)統(tǒng)暴露出來,所以如何去設(shè)計一個彈框組件是每一個開發(fā)者都要去考慮的問題,而目前我們常見的彈框組件設(shè)計方式有兩種
常見的設(shè)計方式
使用構(gòu)造函數(shù)一鍵生成

這是一種設(shè)計方式,會將彈框標題,彈框內(nèi)容,彈框按鈕文案,彈框按鈕點擊事件一起傳給構(gòu)造函數(shù),再多重載幾個函數(shù)來支持一些特定場景比如沒有標題,單個按鈕,文案顏色等等,我一般如果接手個項目,這個項目是多人開發(fā)的話,我都會主動攬下彈框組件開發(fā)的任務(wù),不是因為寫彈框有癮,主要是擔心別人使用這種方式寫框子,說又不好說,做起來真的是噩夢,這種方式的優(yōu)點缺點總結(jié)如下
- 優(yōu)點:未知
- 缺點:
- 代碼角度來講,可讀性比較差,大量的入?yún)屨{(diào)用者在填寫參數(shù)的時候產(chǎn)生迷惑,不知道具體某一個參數(shù)對應(yīng)的是什么功能。
- 對于維護人員來講,每次組件需要改動一個元素,就需要將每個構(gòu)造函數(shù)的邏輯都修改一遍,工作量大并且容易出錯。
- 對于調(diào)用方來講,每次需要寫大量參數(shù),并且需要嚴格遵守參數(shù)的聲明順序,組件如果更新了函數(shù)簽名,調(diào)用處就會產(chǎn)生編譯報錯
使用建造者模式鏈式調(diào)用

另一種設(shè)計方式是使用建造者模式,這也是我慣用的方式,將彈框中的所有元素都一一對外暴露出一個方法,讓調(diào)用方去設(shè)置,需要用到哪個元素就去設(shè)置設(shè)置哪個元素,組件內(nèi)部默認實現(xiàn)一套樣式,如果有的元素沒有被調(diào)用方設(shè)置,就默認使用組件自帶的實現(xiàn)方式,但這種方式也有優(yōu)缺點,總結(jié)如下
- 優(yōu)點:將功能用函數(shù)區(qū)分開來,職能清晰,調(diào)用方可根據(jù)自己的需求選擇性的調(diào)用對應(yīng)函數(shù)渲染彈框
- 缺點:維護者需要不斷根據(jù)新的需求往組件里面添加新的方法供調(diào)用方使用,比如想要將標題加粗,如果組件沒有提供對應(yīng)的setTitleBold這樣的方法,那么調(diào)用方將無法實現(xiàn)這個功能,多輪迭代下來,可能組件里面已經(jīng)積攢了各種各樣的方法,如果不好好分類管理,那閱讀起來也是很頭疼的一件事情
第三種設(shè)計方式
鑒于上述提到的兩種設(shè)計方式以及總結(jié)出來的優(yōu)缺點,我們不禁有個疑問,這種方式也不行,那個方式也不是很好,那么這么常用的組件難道就沒有更好的設(shè)計方式了嗎,能夠設(shè)計出來以后可以滿足如下幾個要求
- 組件擁有極強的擴展性,調(diào)用方可以隨意定義自己需要的功能
- 維護方不用頻繁的在組件中添加功能,保持組件的穩(wěn)定性
- 結(jié)構(gòu)清晰,每個代碼塊負責一個組件元素的功能
DSL的定義
想要實現(xiàn)以上幾點,我們就要使用這篇文章的重點DSL了,那什么是DSL呢,那就是領(lǐng)域?qū)S谜Z言:專門解決某一特定問題的計算機語言,比如我們常用的正則表達式就是一種DSL,它與我們常用的api不一樣,有著自己獨特的結(jié)構(gòu),也叫做文法,在Kotlin里面這種結(jié)構(gòu)我們使用lambda表達式去完成
帶接收者的lambda
在使用DSL自定義彈框之前,我們先看一個例子,我們剛接觸kotlin的時候,一定接觸過它標準庫里的let跟apply函數(shù),也死記硬背的區(qū)分了一下這倆函數(shù)的區(qū)別,在實際開發(fā)當中也用到過,比如有一個按鈕,我們需要去設(shè)置它的文案,字體大小以及點擊事件,一般會這么做

我們看到每次訪問按鈕的一個屬性就要重復寫一下button,如果訪問的屬性變多了,那代碼就會顯的特別的啰嗦,所以這個時候,let跟apply函數(shù)就派上用場了

我們看到兩者的區(qū)別體現(xiàn)在了let后面的lambda表達式里面,使用it顯示的代替了button,如果萬一button需要改變一下變量名,我們只需要更改let左邊的button就好,而apply后面的表達式里面,完全省略了it,整個表達式的作用域就是button,可以直接訪問button的屬性,我們在牢記這個差異的同時,是不是也想一想,為什么這倆函數(shù)會存在這樣的差異呢?答案就在這倆函數(shù)的源碼當中,我們看一下

我們看到兩個函數(shù)源碼最大的區(qū)別在于let的入?yún)⑹且粋€參數(shù)為T的函數(shù)類型的參數(shù),所以在lambda表達式中我們可以用it顯示的代替T,而apply的入?yún)⑸燥@不同,它的入?yún)⒁彩莻€函數(shù)類型,但是T被挪到了括號的前面,當作一個接收者來接受lambda表達式中返回的結(jié)果,所以才會導致apply函數(shù)后面只有它的屬性以及值,結(jié)構(gòu)及其精簡,而kotlin中的DSL的主要語法點就是帶接收者的lambda,現(xiàn)在我們就帶著這個語法點開始一步步去自定義我們的彈框吧
開始開發(fā)
首先我們先從簡單的實現(xiàn)一個AlertDialog彈框開始

AlertDialog的一個特點就是使用了建造者模式,每一個設(shè)置函數(shù)結(jié)束后都會返回給AlertDialog.Builder,那么從這一點上我們就可以仿照apply函數(shù)那樣,將生成Dialog的這個過程轉(zhuǎn)換成帶有接收者的lambda表達式,那么先要做的就是給AlertDialog.Builder增加一個擴展函數(shù),內(nèi)部接收一個帶有接收者的lambda表達式的參數(shù)

現(xiàn)在我們可以使用新增的createDialog函數(shù)來改變下剛剛生成AlertDialog的代碼

createDialog作用類似于函數(shù)apply,lambda代碼塊的作用域就是AlertDialog.Builder,可以訪問任何AlertDialog.Builder中的函數(shù),上述代碼我們可以再簡化一下,將createDialog作為一個頂層函數(shù),在函數(shù)內(nèi)部生成AlertDialog.Builder實例,頂層函數(shù)如下

而調(diào)用彈框的地方代碼也一同更改成了

運行一下代碼我們就得到了一個系統(tǒng)自帶的彈框

但是這樣的一個彈框,我想國內(nèi)應(yīng)該沒幾個設(shè)計師會喜歡,所以按照設(shè)計師給的視覺圖,在現(xiàn)有基礎(chǔ)上去自定義彈框是我們接下去要做的事情,撇開一些特定的業(yè)務(wù)場景,一個彈框組件需要具備如下功能
- 彈框布局可自定義樣式,比如圓角,背景顏色
- 彈框標題可自定義,比如文案,字體顏色,大小
- 彈框內(nèi)容可自定義,比如文案,字體顏色,大小
- 彈框按鈕數(shù)量可配置一個或兩個
彈框布局
第一步我們先做彈框的布局,對于一個彈框組件來講,設(shè)計師會事先將所有彈框樣式都設(shè)計出來,所以整體布局的大體樣式是固定的,我們以一個簡單的dialog_layout布局文件作為彈框的樣式

整個布局結(jié)構(gòu)很簡單,從上到下分別是標題,內(nèi)容,按鈕區(qū),接下來我們就在頂層函數(shù)createDialog的lambda表達式中把布局設(shè)置到彈框里去,并且讓彈框的寬度與屏幕寬度成比例自適應(yīng),畢竟不同app里面彈框的寬度都不一定相同

效果如下

一個純白色彈框就出來了,接下來我們簡化一下代碼,由于每次調(diào)用彈框,dialog.show以及下面的設(shè)置寬度以及彈框位置的代碼都會去調(diào)用,所以為了避免重復,反復造輪子,我們可以給AlertDialog增加一個擴展函數(shù),將這些代碼都放在擴展函數(shù)里面,上層只需要調(diào)用這個擴展函數(shù)就行,擴展函數(shù)我們就命名為showDialog,代碼如下

上層調(diào)用彈框的地方就變成了

是不是精簡了很多呢,代碼運行的效果是一樣的,就不展示了,但是目前我們這個框子還只是普通的樣式,我們?nèi)绻胍o它設(shè)置個圓角,然后捎帶一些漸變色效果的背景,該怎么做呢?我們第一個想到的就是做一個drawable文件,在里面寫上這些樣式,再設(shè)置給布局根視圖的background不就可以了嗎,這的確是一個辦法,但是如果有一天設(shè)計師突發(fā)奇想,覺得在某些場景下彈框使用樣式A,某些場景下使用樣式B,難道在生成一個新的drawable文件嗎,這樣一來單單一個彈框組件就要維護兩種樣式文件,給項目維護又帶來了一定的成本,所以我們得想個更好的辦法,就是使用GradientDrawable動態(tài)給布局設(shè)置樣式,作法如下

看到在代碼中用紅框子以及綠框子區(qū)分了兩部分代碼,我們先看紅框子里面,都能看明白主要是做渲染的工作,生成了一個GradientDrawable實例,然后分別對它設(shè)置了背景色,漸變方向,圓角大小,而這個我們就可以用帶接收者的lambda表達式替換,GradientDrawable就是接收者,在看綠框子里面,雖然現(xiàn)在代碼不多,但是setView之前肯定還得對view里面的元素做初始化等一系列操作,所以view也是一個接收者,初始化等操作可以放在lambda表達式中進行,理清了這些以后,我們新增一個AlertDialog.Builder的擴展函數(shù)rootLayout

rootLayout函數(shù)一共接收三個參數(shù),root就是我們的彈框視圖,render就是渲染操作,job是初始化view的操作,對于渲染操作來講,rootLayout內(nèi)部已經(jīng)實現(xiàn)了一套默認的樣式,如果調(diào)用方不使用render函數(shù),那彈框就使用默認樣式,如果使用了render函數(shù),那么render里面有同樣屬性的就覆蓋,有新增屬性就累加,這個時候,上層調(diào)用方代碼就更改為

我們運行一下看看效果

跟我們想要設(shè)置的效果一模一樣,現(xiàn)在我們試試看不使用默認的樣式,想要讓彈框上面的圓角為12dp,下面沒有圓角,背景漸變色變?yōu)閺淖蟮接曳较蛴苫易儼?,我們在render函數(shù)里面加上這些設(shè)置

運行以后效果就變成了

彈框標題
有了彈框布局的開發(fā)經(jīng)驗,標題就容易多了,既然job函數(shù)的接收者是View,那么我們就給View先定一個擴展函數(shù)title

這個函數(shù)專門用來做標題相關(guān)部分的操作,而title的參數(shù)則是一個接收者為TextView的lambda表達式,用來在調(diào)用方額外給標題添加設(shè)置,那現(xiàn)在我們就可以給彈框添加個標題了,順便把框的四個角都變成圓角,好看些

加了一個深色加粗標題,其中textColor屬性是我添加的擴展屬性,為的是讓代碼看上去整潔一些,效果等同于setTextColor(getColor(R.color.color_303F9F))

再次運行一下,標題就出來了

好像標題有點太靠上了,我們給彈框整體加個10dp的內(nèi)邊距在看下效果


效果出來了,我們再進行下一步
彈框內(nèi)容
有了標題的例子,彈框內(nèi)容基本都一樣,不多說直接上代碼

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

效果如下

彈框按鈕
通常彈框組件都會有單個按鈕彈框(提示型)和兩個按鈕彈框(交互型)兩種類型,我們的dialog_layout布局中有兩個TextView分別用來作為按鈕,默認左邊的negativeBtn是隱藏的,右邊positiveBtn是展示出來的,這里我是仿照著AlertDialog里面設(shè)置按鈕的邏輯來做,當只調(diào)用setPositiveButton的時候,表示此時為單個按鈕彈框,當同時又調(diào)用了setNegativeButton的時候,就表示兩個按鈕的彈框,我們這邊也借用這個思想,定義兩個函數(shù)來控制這倆個按鈕

代碼很簡單,當然也可以在函數(shù)里面加入一些默認樣式,比如positiveBtn一般為高亮色值,negativeBtn為灰色色值,現(xiàn)在我們?nèi)フ{(diào)用下這倆函數(shù),首先展示只有一個按鈕的彈框

像Alertdialog一樣只調(diào)用了positiveBtn函數(shù)就可以了,效果圖如下

當我們要在彈框上顯示兩個按鈕的時候,只需要再增加一個negativeBtn就可以了,就像這樣


接下來就是給按鈕設(shè)置監(jiān)聽事件了,非常容易,只需要調(diào)用setOnClickListener就可以了

這樣其實可以完事了,彈框可以正常點擊完以后做一些業(yè)務(wù)邏輯并且讓彈框消失,但是僅僅這樣的話我們這代碼里還是存在著一些設(shè)計不合理的地方
- 每一次createDialog以后,都必須showDialog以后彈框才能出來,這個可以讓組件自己完成而不用調(diào)用方自己每次去showDialog
- rootLayout返回的是AlertDialog.Builder對象,必須調(diào)用create以后才能得到AlertDialog對象去操作彈框展示與隱藏,這些也應(yīng)該放在組件里面進行
- 彈框按鈕點擊的默認操作基本都是關(guān)閉彈框,所以也沒有必要每次在點擊事件中顯示的調(diào)用dismiss函數(shù),也可以將關(guān)閉的動作放在組件中進行
那么我們就要更改下rootLayout函數(shù),讓它的返回值從AlertDialog.Builder變成Unit,而上述說的create以及showDialog操作,就要在rootLayout中進行,更改完的代碼如下

mDialog是組件中維護的一個頂層屬性,這也是為了在點擊彈框按鈕時候,在組件內(nèi)部關(guān)閉彈框,接下去我們開始處理彈框按鈕的點擊事件,由于點擊事件是作用在TextView上的,所以先給TextView增加一個擴展函數(shù)clickEvent,用來處理關(guān)閉彈框和其他點擊事件的邏輯

現(xiàn)在我們可以回到調(diào)用方那邊,將彈框的代碼更新一下,并給positiveBtn和negativeBtn分別加上新增的clickEvent函數(shù)作為點擊事件,而positiveBtn點擊后還會彈出一個Toast作為響應(yīng)事件
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, "開始處理響應(yīng)事件", Toast.LENGTH_SHORT).show()
}
}
negativeBtn {
text = "取消"
textColor = getColor(R.color.color_303F9F)
clickEvent { }
}
}
}
到這里我們的彈框組件就大功告成了,順帶貼上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)
//可以加一些內(nèi)容的默認操作,比如字體顏色,字體大小,居左對齊還是居中對齊
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
}
}
總結(jié)
可能早就有人已經(jīng)發(fā)現(xiàn)了,我們現(xiàn)在彈框的調(diào)用方式跟Compose,React很相似,也就是最近很流行的聲明式UI,為什么說它流行,比我們傳統(tǒng)的命令式UI好用,主要的差別就在于聲明式UI調(diào)用方只需要在乎視圖的描述就可以,而真正視圖如何渲染,如何測量,調(diào)用方不需要關(guān)心,在我們的彈框的例子中,調(diào)用方全程需要做的就是對著視覺稿子,將彈框中的元素以及需要的屬性樣式一個個寫上去就好了,就算彈框后期需求變化再頻繁,對于調(diào)用方來說只是增減幾個元素屬性的事情,而像彈框如何設(shè)置自定義的視圖,如何測量與屏幕之間的寬度比例等,不需要調(diào)用方去關(guān)心,所以這種方式在我們以后的開發(fā)當中可以逐步學習,適應(yīng),使用起來了,并不是說只有在寫React,Flutter或者Compose之類的項目中才用到這種聲明式UI
作者:Coffeeee
鏈接:https://juejin.cn/post/7204601386607706172