一年前,用 Java 寫(xiě)了一個(gè)高可擴(kuò)展選擇按鈕庫(kù)挽铁。單個(gè)控件實(shí)現(xiàn)單選双戳、多選益楼、菜單選氧秘,且選擇模式可動(dòng)態(tài)擴(kuò)展。
一年后省撑,一個(gè)新的需求要用到這個(gè)庫(kù)赌蔑,項(xiàng)目代碼已經(jīng)全 Kotlin 化,強(qiáng)硬地插入一些 Java 代碼顯得格格不入竟秫,Java 冗余的語(yǔ)法也降低了代碼的可讀性娃惯,于是決定用 Kotlin 重構(gòu)一番,在重構(gòu)的時(shí)候也增加了一些新的功能肥败。這一篇分享下重構(gòu)的過(guò)程石景。
選擇按鈕的可擴(kuò)展性主要體現(xiàn)在 4 個(gè)方面:
- 選項(xiàng)按鈕布局可擴(kuò)展
- 選項(xiàng)按鈕樣式可擴(kuò)展
- 選中樣式可擴(kuò)展
- 選擇模式可擴(kuò)展
擴(kuò)展布局
原生的單選按鈕通過(guò)RadioButton
+ RadioGroup
實(shí)現(xiàn),他們?cè)诓季稚媳仨毷歉缸雨P(guān)系拙吉,而RadioGroup
繼承自LinearLayout
潮孽,遂單選按鈕只能是橫向或縱向鋪開(kāi),這限制的單選按鈕布局的多樣性筷黔,比如下面這種三角布局就難以用原生控件實(shí)現(xiàn):
為了突破這個(gè)限制往史,單選按鈕不再隸屬于一個(gè)父控件,它們各自獨(dú)立佛舱,可以在布局文件中任意排列椎例,圖中 Activity 的布局文件如下(偽碼):
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Selector age"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<test.taylor.AgeSelector
android:id="@+id/selector_teenager"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/title"
app:layout_constraintStart_toStartOf="parent"/>
<test.taylor.AgeSelector
android:id="@+id/selector_man"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toStartOf="@id/selector_old_man"
app:layout_constraintTop_toBottomOf="@id/selector_teenager"
app:layout_constraintStart_toStartOf="parent"/>
<test.taylor.AgeSelector
android:id="@+id/selector_old_man"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/selector_teenager"
app:layout_constraintStart_toEndOf="@id/selector_man"/>
</androidx.constraintlayout.widget.ConstraintLayout>
AgeSelector
表示一個(gè)具體的按鈕,本例中它是一個(gè)“上面是圖片请祖,下面是文字”的單選按鈕订歪。它繼承自抽象的Selector
。
擴(kuò)展樣式
從業(yè)務(wù)上講肆捕,Selector
長(zhǎng)什么樣是一個(gè)頻繁的變化點(diǎn)刷晋,遂把“構(gòu)建按鈕樣式”這個(gè)行為設(shè)計(jì)成Selector
的抽象函數(shù)onCreateView()
,供子類(lèi)重寫(xiě)以實(shí)現(xiàn)擴(kuò)展。
public abstract class Selector extends FrameLayout{
public Selector(Context context) {
super(context);
initView(context, null);
}
private void initView(Context context, AttributeSet attrs) {
// 初始化按鈕算法框架
View view = onCreateView();
LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
this.addView(view, params);
}
// 如何構(gòu)建按鈕視圖眼虱,延遲到子類(lèi)實(shí)現(xiàn)
protected abstract View onCreateView();
}
Selector
繼承自FrameLayout
喻奥,實(shí)例化時(shí)會(huì)構(gòu)建按鈕視圖,并把該視圖作為孩子添加到自己的布局中捏悬。子類(lèi)通過(guò)重寫(xiě)onCreateView()
擴(kuò)展按鈕樣式:
public class AgeSelector extends Selector {
@Override
protected View onCreateView() {
View view = LayoutInflater.from(this.getContext()).inflate(R.layout.age_selector, null);
return view;
}
}
AgeSelector
的樣式被定義在 xml 中撞蚕。
按鈕被選中之后的樣式,也是一個(gè)業(yè)務(wù)上的變化點(diǎn)过牙,用同樣的思路可以將Selector
這樣設(shè)計(jì):
// 抽象按鈕實(shí)現(xiàn)點(diǎn)擊事件
public abstract class Selector extends FrameLayout implements View.OnClickListener {
public Selector(Context context) {
super(context);
initView(context, null);
}
private void initView(Context context, AttributeSet attrs) {
View view = onCreateView();
LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
this.addView(view, params);
// 設(shè)置點(diǎn)擊事件
this.setOnClickListener(this);
}
@Override
public void onClick(View v) {
// 原有選中狀態(tài)
boolean isSelect = this.isSelected();
// 反轉(zhuǎn)選中狀態(tài)
this.setSelected(!isSelect);
// 展示選中狀態(tài)切換效果
onSwitchSelected(!isSelect);
return !isSelect;
}
// 按鈕選中狀態(tài)變化時(shí)的效果延遲到子類(lèi)實(shí)現(xiàn)
protected abstract void onSwitchSelected(boolean isSelect);
}
將選中按鈕狀態(tài)變化的效果抽象成一個(gè)算法甥厦,延遲到子類(lèi)實(shí)現(xiàn):
public class AgeSelector extends Selector {
// 單選按鈕選中背景
private ImageView ivSelector;
private ValueAnimator valueAnimator;
@Override
protected View onCreateView() {
View view = LayoutInflater.from(this.getContext()).inflate(R.layout.selector, null);
ivSelector = view.findViewById(R.id.iv_selector);
return view;
}
@Override
protected void onSwitchSelected(boolean isSelect) {
if (isSelect) {
playSelectedAnimation();
} else {
playUnselectedAnimation();
}
}
// 播放取消選中動(dòng)畫(huà)
private void playUnselectedAnimation() {
if (ivSelector == null) {
return;
}
if (valueAnimator != null) {
valueAnimator.reverse();
}
}
// 播放選中動(dòng)畫(huà)
private void playSelectedAnimation() {
if (ivSelector == null) {
return;
}
valueAnimator = ValueAnimator.ofInt(0, 255);
valueAnimator.setDuration(800);
valueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
ivSelector.setAlpha((int) animation.getAnimatedValue());
}
});
valueAnimator.start();
}
}
AgeSelector
在選中狀態(tài)變化時(shí)定義了一個(gè)背景色漸變動(dòng)畫(huà)。
函數(shù)類(lèi)型變量代替繼承
在抽象按鈕控件中寇钉,“按鈕樣式”和“按鈕選中狀態(tài)變換”被抽象成算法刀疙,算法的實(shí)現(xiàn)推遲到子類(lèi),用這樣的方式摧莽,擴(kuò)展按鈕的樣式和行為庙洼。
繼承的一個(gè)后果就是類(lèi)數(shù)量的膨脹顿痪,有沒(méi)有什么辦法不用繼承就能擴(kuò)展按鈕樣式和行為镊辕?
可以把構(gòu)建按鈕樣式的成員方法onCreateView()
設(shè)計(jì)成一個(gè)View
類(lèi)型的成員變量,通過(guò)設(shè)值函數(shù)就可以改變其值蚁袭。但按鈕選中狀態(tài)變換是一種行為征懈,在 Java 中行為的表達(dá)方式只有方法,所以只能通過(guò)繼承來(lái)改變行為揩悄。
Kotlin 中有一種類(lèi)型叫函數(shù)類(lèi)型
卖哎,運(yùn)用這種類(lèi)型,可以將行為保存在變量中:
class Selector @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
// 選中狀態(tài)變換時(shí)的行為删性,它是一個(gè)lambda
var onSelectChange: ((Selector, Boolean) -> Unit)? = null
// 按鈕是否被選中
var isSelecting: Boolean = false
// 按鈕樣式
var contentView: View? = null
set(value) {
field = value
value?.let {
// 當(dāng)按鈕樣式被賦值時(shí)亏娜,將其添加到 Selector,作為子視圖
addView(it, LayoutParams(MATCH_PARENT, MATCH_PARENT))
}
}
// 變更按鈕選中狀態(tài)
fun setSelect(select: Boolean) {
showSelectEffect(select)
}
// 展示選中狀態(tài)變換效果
fun showSelectEffect(select: Boolean) {
// 如果選中狀態(tài)發(fā)生變化蹬挺,則執(zhí)行選中狀態(tài)變換行為
if (isSelecting != select) {
onSelectChange?.invoke(this, select)
}
isSelecting = select
}
}
選中樣式和行為都被抽象為一個(gè)成員變量维贺,只需賦值就可以動(dòng)態(tài)擴(kuò)展,不再需要繼承:
// 構(gòu)建按鈕實(shí)例
val selector = Selector {
layout_width = 90
layout_height = 50
contentView = ageSelectorView
onSelectChange = onAgeSelectStateChange
}
// 構(gòu)建按鈕樣式
private val ageSelectorView: ConstraintLayout
get() = ConstraintLayout {
layout_width = match_parent
layout_height = match_parent
// 按鈕選中背景
ImageView {
layout_id = "ivSelector"
layout_width = 0
layout_height = 30
top_toTopOf = "ivContent"
bottom_toBottomOf = "ivContent"
start_toStartOf = "ivContent"
end_toEndOf = "ivContent"
background_res = R.drawable.age_selctor_shape
alpha = 0f
}
// 按鈕圖片
ImageView {
layout_id = "ivContent"
layout_width = match_parent
layout_height = 30
center_horizontal = true
src = R.drawable.man
top_toTopOf = "ivSelector"
}
// 按鈕文字
TextView {
layout_id = "tvTitle"
layout_width = match_parent
layout_height = wrap_content
bottom_toBottomOf = parent_id
text = "man"
gravity = gravity_center_horizontal
}
}
// 按鈕選中行為
private val onAgeSelectStateChange = { selector: Selector, select: Boolean ->
// 根據(jù)選中狀態(tài)變換按鈕選中背景
selector.find<ImageView>("ivSelector")?.alpha = if (select) 1f else 0f
}
在構(gòu)建Selector
實(shí)例的同時(shí)巴帮,指定了它的樣式和選中變換效果(其中運(yùn)用到 DSL 簡(jiǎn)化構(gòu)建代碼溯泣,詳細(xì)介紹可以點(diǎn)擊這里)
擴(kuò)展選中模式
單個(gè)Selector
已經(jīng)可以很好的工作,但要讓多個(gè)Selector
形成一種單選或多選的模式榕茧,還需要一個(gè)管理器來(lái)同步它們之間的選中狀態(tài)垃沦,Java 版本的管理器如下:
public class SelectorGroup {
// 選中模式
public interface ChoiceAction {
void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener);
}
// 選中狀態(tài)監(jiān)聽(tīng)器
public interface StateListener {
void onStateChange(String groupTag, String tag, boolean isSelected);
}
// 選中模式實(shí)例
private ChoiceAction choiceMode;
// 選中狀態(tài)監(jiān)聽(tīng)器實(shí)例
private StateListener onStateChangeListener;
// 用于上一次選中的按鈕的 Map
private HashMap<String, Selector> selectorMap = new HashMap<>();
// 注入選中模式
public void setChoiceMode(ChoiceAction choiceMode) {
this.choiceMode = choiceMode;
}
// 設(shè)置選中狀態(tài)監(jiān)聽(tīng)器
public void setStateListener(StateListener onStateChangeListener) {
this.onStateChangeListener = onStateChangeListener;
}
// 獲取之前選中的按鈕
public Selector getPreSelector(String groupTag) {
return selectorMap.get(groupTag);
}
// 變更指定按鈕的選中狀態(tài)
public void setSelected(boolean selected, Selector selector) {
if (selector == null) {
return;
}
// 記憶選中的按鈕
if (selected) {
selectorMap.put(selector.getGroupTag(), selector);
}
// 觸發(fā)按鈕選中樣式變更
selector.setSelected(selected);
if (onStateChangeListener != null) {
onStateChangeListener.onStateChange(selector.getGroupTag(), selector.getSelectorTag(), selected);
}
}
// 取消之前選中的按鈕
private void cancelPreSelector(Selector selector) {
// 每個(gè)按鈕有一個(gè)組標(biāo)識(shí),用于標(biāo)識(shí)它屬于哪個(gè)組
String groupTag = selector.getGroupTag();
// 獲取該組中之前選中的按鈕并將其取消選中
Selector preSelector = getPreSelector(groupTag);
if (preSelector != null) {
preSelector.setSelected(false);
}
}
// 當(dāng)按鈕被點(diǎn)擊時(shí),會(huì)將點(diǎn)擊事件通過(guò)該函數(shù)傳遞給 SelectorGroup
void onSelectorClick(Selector selector) {
// 將點(diǎn)擊事件委托給選擇模式來(lái)處理
if (choiceMode != null) {
choiceMode.onChoose(selector, this, onStateChangeListener);
}
// 將選中的按鈕記錄在 Map 中
selectorMap.put(selector.getGroupTag(), selector);
}
// 預(yù)定的單選模式
public class SingleAction implements ChoiceAction {
@Override
public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) {
cancelPreSelector(selector);
setSelected(true, selector);
}
}
// 預(yù)定的多選模式
public class MultipleAction implements ChoiceAction {
@Override
public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) {
boolean isSelected = selector.isSelected();
setSelected(!isSelected, selector);
}
}
}
SelectorGroup
將選中模式抽象成接口ChoiceAction
用押,以便通過(guò)setChoiceMode()
動(dòng)態(tài)地?cái)U(kuò)展肢簿。
SelectorGroup
還預(yù)定了兩種選中模式:?jiǎn)芜x和多選。
- 單選可以理解為:點(diǎn)擊按鈕時(shí),選中當(dāng)前的并取消選中之前的译仗。
- 多選可以理解為:點(diǎn)擊按鈕時(shí)無(wú)條件地反轉(zhuǎn)當(dāng)前選中狀態(tài)抬虽。
Selector
會(huì)持有SelectorGroup
實(shí)例,以便將按鈕點(diǎn)擊事件傳遞給它統(tǒng)一管理:
public abstract class Selector extends FrameLayout implements View.OnClickListener {
// 按鈕組標(biāo)簽
private String groupTag;
// 按鈕管理器
private SelectorGroup selectorGroup;
// 設(shè)置組標(biāo)簽和管理器
public Selector setGroup(String groupTag, SelectorGroup selectorGroup) {
this.selectorGroup = selectorGroup;
this.groupTag = groupTag;
return this;
}
@Override
public void onClick(View v) {
// 將點(diǎn)擊事件傳遞給管理器
if (selectorGroup != null) {
selectorGroup.onSelectorClick(this);
}
}
}
然后就可以像這樣實(shí)現(xiàn)單選:
SelectorGroup singleGroup = new SelectorGroup();
singleGroup.setChoiceMode(SelectorGroup.SingleAction);
selector1.setGroup("single", singleGroup);
selector2.setGroup("single", singleGroup);
selector3.setGroup("single", singleGroup);
也可以像這樣實(shí)現(xiàn)菜單選:
SelectorGroup orderGroup = new SelectorGroup();
orderGroup.setStateListener(new OrderChoiceListener());
orderGroup.setChoiceMode(new OderChoiceMode());
// 前菜組
selector1_1.setGroup("starters", orderGroup);
selector1_2.setGroup("starters", orderGroup);
// 主食組
selector2_1.setGroup("main", orderGroup);
selector2_2.setGroup("main", orderGroup);
// 湯組
selector3_1.setGroup("soup", orderGroup);
selector3_2.setGroup("soup", orderGroup);
// 菜單選:組內(nèi)單選纵菌,跨組多選
private class OderChoiceMode implements SelectorGroup.ChoiceAction {
@Override
public void onChoose(Selector selector, SelectorGroup selectorGroup, SelectorGroup.StateListener stateListener) {
cancelPreSelector(selector, selectorGroup);
selector.setSelected(true);
if (stateListener != null) {
stateListener.onStateChange(selector.getGroupTag(), selector.getSelectorTag(), true);
}
}
// 取消之前選中的同組按鈕
private void cancelPreSelector(Selector selector, SelectorGroup selectorGroup) {
Selector preSelector = selectorGroup.getPreSelector(selector.getGroupTag());
if (preSelector != null) {
preSelector.setSelected(false);
}
}
}
將 Java 中的接口改成lambda
阐污,存儲(chǔ)在函數(shù)類(lèi)型的變量中,這樣可省去注入函數(shù)咱圆,Kotlin 版本的SelectorGroup
如下:
class SelectorGroup {
companion object {
// 單選模式的靜態(tài)實(shí)現(xiàn)
var MODE_SINGLE = { selectorGroup: SelectorGroup, selector: Selector ->
selectorGroup.run {
// 查找同組中之前選中的笛辟,取消其選中狀態(tài)
findLast(selector.groupTag)?.let { setSelected(it, false) }
// 選中當(dāng)前按鈕
setSelected(selector, true)
}
}
// 多選模式的靜態(tài)實(shí)現(xiàn)
var MODE_MULTIPLE = { selectorGroup: SelectorGroup, selector: Selector ->
selectorGroup.setSelected(selector, !selector.isSelecting)
}
}
// 所有當(dāng)前選中按鈕的有序集合(有些場(chǎng)景需要記憶按鈕選中的順序)
private var selectorMap = LinkedHashMap<String, MutableSet<Selector>>()
// 當(dāng)前的選中模式(函數(shù)類(lèi)型)
var choiceMode: ((SelectorGroup, Selector) -> Unit)? = null
// 選中狀態(tài)變更監(jiān)聽(tīng)器, 將所有選中按鈕回調(diào)出去(函數(shù)類(lèi)型)
var selectChangeListener: ((List<Selector>/*selected set*/) -> Unit)? = null
// Selector 將點(diǎn)擊事件通過(guò)這個(gè)方法傳遞給 SelectorGroup
fun onSelectorClick(selector: Selector) {
// 將點(diǎn)擊事件委托給選中模式
choiceMode?.invoke(this, selector)
}
// 查找指定組的所有選中按鈕
fun find(groupTag: String) = selectorMap[groupTag]
// 根據(jù)組標(biāo)簽查找該組中上一次被選中的按鈕
fun findLast(groupTag: String) = find(groupTag)?.takeUnless { it.isNullOrEmpty() }?.last()
// 變更指定按鈕的選中狀態(tài)
fun setSelected(selector: Selector, select: Boolean) {
// 或新建,或刪除,或追加選中的按鈕到Map中
if (select) {
selectorMap[selector.groupTag]?.also { it.add(selector) } ?: also { selectorMap[selector.groupTag] = mutableSetOf(selector) }
} else {
selectorMap[selector.groupTag]?.also { it.remove(selector) }
}
// 展示選中效果
selector.showSelectEffect(select)
// 觸發(fā)選中狀態(tài)監(jiān)聽(tīng)器
if (select) {
selectChangeListener?.invoke(selectorMap.flatMap { it.value })
}
}
// 釋放持有的選中控件
fun clear() {
selectorMap.clear()
}
}
然后就可以像這樣使用SelectorGroup
:
// 構(gòu)建管理器
val singleGroup = SelectorGroup().apply {
choiceMode = SelectorGroup.MODE_SINGLE
selectChangeListener = { selectors: List<Selector>->
// 在這里可以拿到選中的所有按鈕
}
}
// 構(gòu)建單選按鈕1
Selector {
tag = "old-man"
group = singleGroup
groupTag = "age"
layout_width = 90
layout_height = 50
contentView = ageSelectorView
}
// 構(gòu)建單選按鈕2
Selector {
tag = "young-man"
group = singleGroup
groupTag = "age"
layout_width = 90
layout_height = 50
contentView = ageSelectorView
}
構(gòu)建的兩個(gè)按鈕擁有相同的groupTag
和SelectorGroup
,所以他們屬于同一組并且是單選模式序苏。
動(dòng)態(tài)綁定數(shù)據(jù)
項(xiàng)目中一個(gè)按鈕通常對(duì)應(yīng)于一個(gè)“數(shù)據(jù)”手幢,比如下圖這種場(chǎng)景:
圖中的分組數(shù)據(jù)和按鈕數(shù)據(jù)都由服務(wù)器返回。點(diǎn)擊創(chuàng)建組隊(duì)時(shí)忱详,希望在selectChangeListener
中拿到每個(gè)選項(xiàng)的 ID围来。那如何為Selector
綁定數(shù)據(jù)?
當(dāng)然可以通過(guò)繼承匈睁,在Selector
子類(lèi)中添加一個(gè)具體的業(yè)務(wù)數(shù)據(jù)類(lèi)型來(lái)實(shí)現(xiàn)监透。但有沒(méi)有更通用的方案?
ViewModel
中設(shè)計(jì)了一種為其動(dòng)態(tài)擴(kuò)展屬性的方法航唆,將它應(yīng)用在Selector
中(詳情可移步讀源碼長(zhǎng)知識(shí) | 動(dòng)態(tài)擴(kuò)展類(lèi)并綁定生命周期的新方式)
class Selector @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
// 存放業(yè)務(wù)數(shù)據(jù)的容器
private var tags = HashMap<Any?, Closeable?>()
// 獲取業(yè)務(wù)數(shù)據(jù)(重載取值運(yùn)算符)
operator fun <T : Closeable> get(key: Key<T>): T? = (tags.getOrElse(key, { null })) as T
// 添加業(yè)務(wù)數(shù)據(jù)(重載設(shè)值運(yùn)算符)
operator fun <T : Closeable> set(key: Key<T>, closeable: Closeable) {
tags[key] = closeable
}
// 清除所有業(yè)務(wù)數(shù)據(jù)
private fun clear() {
group?.clear()
tags.forEach { entry ->
closeWithException(entry.value)
}
}
// 當(dāng)控件與窗口脫鉤時(shí)胀蛮,清理業(yè)務(wù)數(shù)據(jù)
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
clear()
}
// 清除單個(gè)業(yè)務(wù)數(shù)據(jù)
private fun closeWithException(closable: Closeable?) {
try {
closable?.close()
} catch (e: Exception) {
}
}
// 業(yè)務(wù)數(shù)據(jù)的鍵
interface Key<E : Closeable>
}
為Selector
新增一個(gè)Map
類(lèi)型的成員用于存放業(yè)務(wù)數(shù)據(jù),業(yè)務(wù)數(shù)據(jù)被聲明為Closeable
的子類(lèi)型糯钙,目的是將各式各樣清理資源的行為抽象為close()
方法粪狼,Selector
重寫(xiě)了onDetachedFromWindow()
且會(huì)遍歷每個(gè)業(yè)務(wù)數(shù)據(jù)并調(diào)用它們的close()
,即當(dāng)它生命周期結(jié)束時(shí)任岸,釋放業(yè)務(wù)數(shù)據(jù)資源再榄。
Selector
也重載了設(shè)值和取值這兩個(gè)運(yùn)算符,以簡(jiǎn)化業(yè)訪(fǎng)問(wèn)業(yè)務(wù)數(shù)據(jù)的代碼:
// 游戲?qū)傩詫?shí)體類(lèi)
data class GameAttr( var name: String, var id: String ): Closeable {
override fun close() {
name = null
id = null
}
}
// 構(gòu)建游戲?qū)傩詫?shí)例
val attr = GameAttr("黃金", "id-298")
// 和游戲?qū)傩詫?shí)體配對(duì)的鍵
val key = object : Selector.Key<GameAttr> {}
// 構(gòu)建選項(xiàng)組
val gameSelectorGroup by lazy {
SelectorGroup().apply {
// 選擇模式(省略)
choiceMode = { selectorGroup, selector -> ... }
// 選中回調(diào)
selectChangeListener = { selecteds ->
// 遍歷所有選中的選項(xiàng)
selecteds.forEach { s ->
// 訪(fǎng)問(wèn)與每個(gè)選項(xiàng)綁定的游戲?qū)傩裕ㄓ玫饺≈颠\(yùn)算符)
Log.v("test","${s[key].name} is selected")
}
}
}
}
// 構(gòu)建選項(xiàng)
Selector {
tag = attr.name
groupTag = "匹配段位"
group = gameSelectorGroup
layout_width = 70
layout_height = 32
// 綁定游戲?qū)傩裕ㄓ玫皆O(shè)值運(yùn)算符)
this[key] = attr
}
因?yàn)橹剌d了運(yùn)算符享潜,所以綁定和獲取游戲?qū)傩缘拇a都更加簡(jiǎn)短困鸥。
用泛型就一定要強(qiáng)轉(zhuǎn)?
綁定給 Selector
的數(shù)據(jù)被設(shè)計(jì)為泛型米碰,業(yè)務(wù)層只有強(qiáng)轉(zhuǎn)成具體類(lèi)型才能使用窝革,有什么辦法可以不要在業(yè)務(wù)層強(qiáng)轉(zhuǎn)?
CoroutineContext
的鍵就攜帶了類(lèi)型信息:
public interface CoroutineContext {
public interface Key<E : Element>
public operator fun <E : Element> get(key: Key<E>): E?
}
而且每一個(gè)CoroutineContext
的具體子類(lèi)型都對(duì)應(yīng)一個(gè)靜態(tài)的鍵實(shí)例:
public interface Job : CoroutineContext.Element {
public companion object Key : CoroutineContext.Key<Job> {}
}
這樣吕座,不需要強(qiáng)轉(zhuǎn)就能獲得具體子類(lèi)型:
coroutineContext[Job]//返回值為 Job 而不是 CoroutineContext
模仿CoroutineContext
虐译,業(yè)務(wù)Selector
的鍵設(shè)計(jì)了一個(gè)帶泛型的接口:
interface Key<E : Closeable>
在為Selector
綁定數(shù)據(jù)時(shí)需要先構(gòu)建“鍵實(shí)例”:
val key = object : Selector.Key<GameAttr> {}
傳入的鍵帶有類(lèi)型信息,可以在取值方法中提前完成強(qiáng)轉(zhuǎn)再返回給業(yè)務(wù)層使用:
// 值的具體類(lèi)型被參數(shù) key 指定吴趴,強(qiáng)轉(zhuǎn)之后再返回給業(yè)務(wù)層
operator fun <T : Closeable> get(key: Key<T>): T? = (tags.getOrElse(key, { null })) as T
借助于 DSL 根據(jù)數(shù)據(jù)動(dòng)態(tài)地構(gòu)建選擇按鈕就變得很輕松漆诽,上一幅 Gif 展示的界面代碼如下:
// 游戲?qū)傩约蠈?shí)體類(lèi)
data class GameAttrs(
var title: String?,// 選項(xiàng)組標(biāo)題
var attrs: List<GameAttrName>? // 選項(xiàng)組內(nèi)容
)
// 簡(jiǎn)化的單個(gè)游戲?qū)傩詫?shí)體類(lèi)(它會(huì)被綁定到Selector)
data class GameAttrName(
var name: String?
) : Closeable {
override fun close() {
name = null
}
}
這是兩個(gè) Demo 中用到的數(shù)據(jù)實(shí)體類(lèi),真實(shí)項(xiàng)目中他們應(yīng)該是服務(wù)器返回的,簡(jiǎn)單起見(jiàn)厢拭,本地模擬一些數(shù)據(jù):
val gameAttrs = listOf(
GameAttrs(
"大區(qū)", listOf(
GameAttrName("微信"),
GameAttrName("QQ")
)
),
GameAttrs(
"模式", listOf(
GameAttrName("排位賽"),
GameAttrName("普通模式"),
GameAttrName("娛樂(lè)模式"),
GameAttrName("游戲交流")
)
),
GameAttrs(
"匹配段位", listOf(
GameAttrName("青銅白銀"),
GameAttrName("黃金"),
GameAttrName("鉑金"),
GameAttrName("鉆石"),
GameAttrName("星耀"),
GameAttrName("王者")
)
),
GameAttrs(
"組隊(duì)人數(shù)", listOf(
GameAttrName("三排"),
GameAttrName("五排")
)
)
)
最后用 DSL 動(dòng)態(tài)構(gòu)建選擇按鈕:
// 縱向布局
LinearLayout {
layout_width = match_parent
layout_height = 573
orientation = vertical
// 遍歷游戲集合兰英,動(dòng)態(tài)添加選項(xiàng)組
gameAttrs?.forEach { gameAttr ->
// 添加選項(xiàng)組標(biāo)題
TextView {
layout_width = wrap_content
layout_height = wrap_content
textSize = 14f
textColor = "#ff3f4658"
textStyle = bold
text = gameAttr.title
}
// 自動(dòng)換行容器控件
LineFeedLayout {
layout_width = match_parent
layout_height = wrap_content
// 遍歷游戲?qū)傩裕瑒?dòng)態(tài)添加選項(xiàng)按鈕
gameAttr.attrs?.forEachIndexed { index, attr ->
Selector {
layout_id = attr.name
tag = attr.name
groupTag = gameAttr.title
// 為按鈕設(shè)置控制器
group = gameSelectorGroup
// 為按鈕指定視圖
contentView = gameAttrView
// 為按鈕設(shè)置選中效果變換器
onSelectChange = onGameAttrChange
layout_width = 70
layout_height = 32
// 為按鈕綁定數(shù)據(jù)并更新視圖
bind = Binder(attr) { _, _ ->
this[gameAttrKey] = attr
find<TextView>("tvGameAttrName")?.text = attr.name
}
}
}
}
}
}
其中的按鈕視圖供鸠、按鈕控制器畦贸、按鈕效果變換器定義如下:
// 與游戲?qū)傩詫?duì)應(yīng)的鍵
val gameAttrKey = object : Selector.Key<GameAttrName> {}
// 構(gòu)建游戲?qū)傩砸晥D
val gameAttrView: TextView?
get() = TextView {
layout_id = "tvGameAttrName"
layout_width = 70
layout_height = 32
textSize = 12f
textColor = "#ff3f4658"
background_res = R.drawable.bg_game_attr
gravity = gravity_center
padding_top = 7
padding_bottom = 7
}
// 按鈕選中狀態(tài)變化時(shí),變更背景色及按鈕字體顏色
private val onGameAttrChange = { selector: Selector, select: Boolean ->
selector.find<TextView>("tvGameAttrName")?.apply {
background_res = if (select) R.drawable.bg_game_attr_select else R.drawable.bg_game_attr
textColor = if (select) "#FFFFFF" else "#3F4658"
}
Unit
}
// 構(gòu)建按鈕控制器
private val gameSelectorGroup by lazy {
SelectorGroup().apply {
choiceMode = { selectorGroup, selector ->
// 設(shè)置除“匹配段位選項(xiàng)組”之外的其他組為單選
if (selector.groupTag != "匹配段位") {
selectorGroup.apply {
findLast(selector.groupTag)?.let { setSelected(it, false) }
}
selectorGroup.setSelected(selector, true)
}
// 設(shè)置“匹配段位選項(xiàng)組”為多選
else {
selectorGroup.setSelected(selector, !selector.isSelecting)
}
}
// 選中按鈕發(fā)生變化時(shí)楞捂,都會(huì)在這里回調(diào)
selectChangeListener = { selecteds ->
selecteds.forEach { s->
Log.v("test","${s[gameAttrKey]?.name} is selected")
}
}
}
}
talk is cheap, show me the code
完整代碼可以點(diǎn)擊這里
推薦閱讀
文中有一些未展開(kāi)的細(xì)節(jié)薄坏,比如“構(gòu)建布局的 DSL”、“ViewModel 動(dòng)態(tài)擴(kuò)展屬性原理”寨闹、“在 DSL 中運(yùn)用數(shù)據(jù)綁定”胶坠,“重載運(yùn)算符”。它們的詳細(xì)講解可以點(diǎn)擊如下鏈接: