Kotlin 实战 | 时隔一年,用 Kotlin 重构一个自定义控件

7,580 阅读9分钟

一年前,用 Java 写了一个高可扩展选择按钮库。单个控件实现单选、多选、菜单选,且选择模式可动态扩展。

一年后,一个新的需求要用到这个库,项目代码已经全 Kotlin 化,强硬地插入一些 Java 代码显得格格不入,Java 冗余的语法也降低了代码的可读性,于是决定用 Kotlin 重构一番,在重构的时候也增加了一些新的功能。这一篇分享下重构的过程。

选择按钮的可扩展性主要体现在 4 个方面:

  1. 选项按钮布局可扩展
  2. 选项按钮样式可扩展
  3. 选中样式可扩展
  4. 选择模式可扩展

扩展布局

原生的单选按钮通过RadioButton+ RadioGroup实现,他们在布局上必须是父子关系,而RadioGroup继承自LinearLayout,遂单选按钮只能是横向或纵向铺开,这限制的单选按钮布局的多样性,比如下面这种三角布局就难以用原生控件实现: selector.gif

为了突破这个限制,单选按钮不再隶属于一个父控件,它们各自独立,可以在布局文件中任意排列,图中 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表示一个具体的按钮,本例中它是一个“上面是图片,下面是文字”的单选按钮。它继承自抽象的Selector

扩展样式

从业务上讲,Selector长什么样是一个频繁的变化点,遂把“构建按钮样式”这个行为设计成Selector的抽象函数onCreateView(),供子类重写以实现扩展。

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);
    }
    
    // 如何构建按钮视图,延迟到子类实现
    protected abstract View onCreateView();
}

Selector继承自FrameLayout,实例化时会构建按钮视图,并把该视图作为孩子添加到自己的布局中。子类通过重写onCreateView()扩展按钮样式:

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 中。

按钮被选中之后的样式,也是一个业务上的变化点,用同样的思路可以将Selector这样设计:

// 抽象按钮实现点击事件
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);
        // 设置点击事件
        this.setOnClickListener(this);
    }
    
    @Override
    public void onClick(View v) {
        // 原有选中状态
        boolean isSelect = this.isSelected();
        // 反转选中状态
        this.setSelected(!isSelect);
        // 展示选中状态切换效果
        onSwitchSelected(!isSelect);
        return !isSelect;
    }
    
    // 按钮选中状态变化时的效果延迟到子类实现
    protected abstract void onSwitchSelected(boolean isSelect);
}

将选中按钮状态变化的效果抽象成一个算法,延迟到子类实现:

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();
        }
    }
    
    // 播放取消选中动画
    private void playUnselectedAnimation() {
        if (ivSelector == null) {
            return;
        }
        if (valueAnimator != null) {
            valueAnimator.reverse();
        }
    }

    // 播放选中动画
    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在选中状态变化时定义了一个背景色渐变动画。

函数类型变量代替继承

在抽象按钮控件中,“按钮样式”和“按钮选中状态变换”被抽象成算法,算法的实现推迟到子类,用这样的方式,扩展按钮的样式和行为。

继承的一个后果就是类数量的膨胀,有没有什么办法不用继承就能扩展按钮样式和行为?

可以把构建按钮样式的成员方法onCreateView()设计成一个View类型的成员变量,通过设值函数就可以改变其值。但按钮选中状态变换是一种行为,在 Java 中行为的表达方式只有方法,所以只能通过继承来改变行为。

Kotlin 中有一种类型叫函数类型,运用这种类型,可以将行为保存在变量中:

class Selector @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    FrameLayout(context, attrs, defStyleAttr) {
    
    // 选中状态变换时的行为,它是一个lambda
    var onSelectChange: ((Selector, Boolean) -> Unit)? = null
    // 按钮是否被选中
    var isSelecting: Boolean = false

    // 按钮样式
     var contentView: View? = null
        set(value) {
            field = value
            value?.let {
                // 当按钮样式被赋值时,将其添加到 Selector,作为子视图
                addView(it, LayoutParams(MATCH_PARENT, MATCH_PARENT))
            }
        }
    
    // 变更按钮选中状态
    fun setSelect(select: Boolean) {
        showSelectEffect(select)
    }
    
    // 展示选中状态变换效果
    fun showSelectEffect(select: Boolean) {
        // 如果选中状态发生变化,则执行选中状态变换行为
        if (isSelecting != select) {
            onSelectChange?.invoke(this, select)
        }
        isSelecting = select
    }
}

选中样式和行为都被抽象为一个成员变量,只需赋值就可以动态扩展,不再需要继承:

// 构建按钮实例
val selector = Selector {
    layout_width = 90
    layout_height = 50
    contentView = ageSelectorView
    onSelectChange = onAgeSelectStateChange
}

// 构建按钮样式
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 ->
    // 根据选中状态变换按钮选中背景
    selector.find<ImageView>("ivSelector")?.alpha = if (select) 1f else 0f
}

在构建Selector实例的同时,指定了它的样式和选中变换效果(其中运用到 DSL 简化构建代码,详细介绍可以点击这里

扩展选中模式

单个Selector已经可以很好的工作,但要让多个Selector形成一种单选或多选的模式,还需要一个管理器来同步它们之间的选中状态,Java 版本的管理器如下:

public class SelectorGroup {
    // 选中模式
    public interface ChoiceAction {
        void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener);
    }
    
    // 选中状态监听器
    public interface StateListener {
        void onStateChange(String groupTag, String tag, boolean isSelected);
    }
    
    // 选中模式实例
    private ChoiceAction choiceMode;
    // 选中状态监听器实例
    private StateListener onStateChangeListener;
    // 用于上一次选中的按钮的 Map
    private HashMap<String, Selector> selectorMap = new HashMap<>();

    // 注入选中模式
    public void setChoiceMode(ChoiceAction choiceMode) {
        this.choiceMode = choiceMode;
    }

    // 设置选中状态监听器
    public void setStateListener(StateListener onStateChangeListener) {
        this.onStateChangeListener = onStateChangeListener;
    }

    // 获取之前选中的按钮
    public Selector getPreSelector(String groupTag) {
        return selectorMap.get(groupTag);
    }
    
    // 变更指定按钮的选中状态
    public void setSelected(boolean selected, Selector selector) {
        if (selector == null) {
            return;
        }
        // 记忆选中的按钮
        if (selected) {
            selectorMap.put(selector.getGroupTag(), selector);
        }
        // 触发按钮选中样式变更
        selector.setSelected(selected);
        if (onStateChangeListener != null) {
            onStateChangeListener.onStateChange(selector.getGroupTag(), selector.getSelectorTag(), selected);
        }
    }

    // 取消之前选中的按钮
    private void cancelPreSelector(Selector selector) {
        // 每个按钮有一个组标识,用于标识它属于哪个组
        String groupTag = selector.getGroupTag();
        // 获取该组中之前选中的按钮并将其取消选中
        Selector preSelector = getPreSelector(groupTag);
        if (preSelector != null) {
            preSelector.setSelected(false);
        }
    }

    // 当按钮被点击时,会将点击事件通过该函数传递给 SelectorGroup
    void onSelectorClick(Selector selector) {
        // 将点击事件委托给选择模式来处理
        if (choiceMode != null) {
            choiceMode.onChoose(selector, this, onStateChangeListener);
        }
        // 将选中的按钮记录在 Map 中
        selectorMap.put(selector.getGroupTag(), selector);
    }
    
    // 预定的单选模式
    public class SingleAction implements ChoiceAction {
        @Override
        public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) {
            cancelPreSelector(selector);
            setSelected(true, selector);
        }
    }

    // 预定的多选模式
    public class MultipleAction implements ChoiceAction {
        @Override
        public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) {
            boolean isSelected = selector.isSelected();
            setSelected(!isSelected, selector);
        }
    }
}

SelectorGroup将选中模式抽象成接口ChoiceAction,以便通过setChoiceMode()动态地扩展。

SelectorGroup还预定了两种选中模式:单选和多选。

  1. 单选可以理解为:点击按钮时,选中当前的并取消选中之前的。
  2. 多选可以理解为:点击按钮时无条件地反转当前选中状态。

Selector会持有SelectorGroup实例,以便将按钮点击事件传递给它统一管理:

public abstract class Selector extends FrameLayout implements View.OnClickListener {
    // 按钮组标签
    private String groupTag;
    // 按钮管理器
    private SelectorGroup selectorGroup;
    
    // 设置组标签和管理器
    public Selector setGroup(String groupTag, SelectorGroup selectorGroup) {
        this.selectorGroup = selectorGroup;
        this.groupTag = groupTag;
        return this;
    }
    
    @Override
    public void onClick(View v) {
        // 将点击事件传递给管理器
        if (selectorGroup != null) {
            selectorGroup.onSelectorClick(this);
        }
    }
}

然后就可以像这样实现单选:

SelectorGroup singleGroup = new SelectorGroup();
singleGroup.setChoiceMode(SelectorGroup.SingleAction);
selector1.setGroup("single", singleGroup);
selector2.setGroup("single", singleGroup);
selector3.setGroup("single", singleGroup);

也可以像这样实现菜单选:

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);

// 菜单选:组内单选,跨组多选
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,存储在函数类型的变量中,这样可省去注入函数,Kotlin 版本的SelectorGroup如下:

class SelectorGroup {
    companion object {
        // 单选模式的静态实现
        var MODE_SINGLE = { selectorGroup: SelectorGroup, selector: Selector ->
            selectorGroup.run {
                // 查找同组中之前选中的,取消其选中状态
                findLast(selector.groupTag)?.let { setSelected(it, false) }
                // 选中当前按钮
                setSelected(selector, true)
            }
        }

        // 多选模式的静态实现
        var MODE_MULTIPLE = { selectorGroup: SelectorGroup, selector: Selector ->
            selectorGroup.setSelected(selector, !selector.isSelecting)
        }
    }

    // 所有当前选中按钮的有序集合(有些场景需要记忆按钮选中的顺序)
    private var selectorMap = LinkedHashMap<String, MutableSet<Selector>>()

    // 当前的选中模式(函数类型)
    var choiceMode: ((SelectorGroup, Selector) -> Unit)? = null

    // 选中状态变更监听器, 将所有选中按钮回调出去(函数类型)
    var selectChangeListener: ((List<Selector>/*selected set*/) -> Unit)? = null

    // Selector 将点击事件通过这个方法传递给 SelectorGroup 
    fun onSelectorClick(selector: Selector) {
        // 将点击事件委托给选中模式
        choiceMode?.invoke(this, selector)
    }

    // 查找指定组的所有选中按钮
    fun find(groupTag: String) = selectorMap[groupTag]

    // 根据组标签查找该组中上一次被选中的按钮
    fun findLast(groupTag: String) = find(groupTag)?.takeUnless { it.isNullOrEmpty() }?.last()

    // 变更指定按钮的选中状态
    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)
        // 触发选中状态监听器
        if (select) {
            selectChangeListener?.invoke(selectorMap.flatMap { it.value })
        }
    }

    // 释放持有的选中控件
    fun clear() {
        selectorMap.clear()
    }
}

然后就可以像这样使用SelectorGroup

// 构建管理器
val singleGroup = SelectorGroup().apply {
    choiceMode = SelectorGroup.MODE_SINGLE
    selectChangeListener = { selectors: List<Selector>->
        // 在这里可以拿到选中的所有按钮
    }
}

// 构建单选按钮1
Selector {
    tag = "old-man"
    group = singleGroup
    groupTag = "age"
    layout_width = 90
    layout_height = 50
    contentView = ageSelectorView
}

// 构建单选按钮2
Selector {
    tag = "young-man"
    group = singleGroup
    groupTag = "age"
    layout_width = 90
    layout_height = 50
    contentView = ageSelectorView
}

构建的两个按钮拥有相同的groupTagSelectorGroup,所以他们属于同一组并且是单选模式。

动态绑定数据

项目中一个按钮通常对应于一个“数据”,比如下图这种场景:

图中的分组数据和按钮数据都由服务器返回。点击创建组队时,希望在selectChangeListener中拿到每个选项的 ID。那如何为Selector绑定数据?

当然可以通过继承,在Selector子类中添加一个具体的业务数据类型来实现。但有没有更通用的方案?

ViewModel中设计了一种为其动态扩展属性的方法,将它应用在Selector中(详情可移步读源码长知识 | 动态扩展类并绑定生命周期的新方式

class Selector @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    FrameLayout(context, attrs, defStyleAttr) {

    // 存放业务数据的容器
    private var tags = HashMap<Any?, Closeable?>()
    
    // 获取业务数据(重载取值运算符)
    operator fun <T : Closeable> get(key: Key<T>): T? = (tags.getOrElse(key, { null })) as T

    // 添加业务数据(重载设值运算符)
    operator fun <T : Closeable> set(key: Key<T>, closeable: Closeable) {
        tags[key] = closeable
    }
    
    // 清除所有业务数据
    private fun clear() {
        group?.clear()
        tags.forEach { entry ->
            closeWithException(entry.value)
        }
    }
    
    // 当控件与窗口脱钩时,清理业务数据
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        clear()
    }

    // 清除单个业务数据
    private fun closeWithException(closable: Closeable?) {
        try {
            closable?.close()
        } catch (e: Exception) {
        }
    }
    
    // 业务数据的键
    interface Key<E : Closeable>
}

Selector新增一个Map类型的成员用于存放业务数据,业务数据被声明为Closeable的子类型,目的是将各式各样清理资源的行为抽象为close()方法,Selector重写了onDetachedFromWindow()且会遍历每个业务数据并调用它们的close(),即当它生命周期结束时,释放业务数据资源。

Selector也重载了设值和取值这两个运算符,以简化业访问业务数据的代码:

// 游戏属性实体类
data class GameAttr( var name: String, var id: String ): Closeable {
    override fun close() {
        name = null
        id = null
    }
}

// 构建游戏属性实例
val attr = GameAttr("黄金", "id-298")

// 和游戏属性实体配对的键
val key = object : Selector.Key<GameAttr> {}

// 构建选项组
val gameSelectorGroup by lazy {
    SelectorGroup().apply {
        // 选择模式(省略)
        choiceMode = { selectorGroup, selector -> ... }
        // 选中回调
        selectChangeListener = { selecteds ->
            // 遍历所有选中的选项
            selecteds.forEach { s ->
                // 访问与每个选项绑定的游戏属性(用到取值运算符)
                Log.v("test","${s[key].name} is selected")
            }
        }
    }
}

// 构建选项
Selector {
    tag = attr.name
    groupTag = "匹配段位"
    group = gameSelectorGroup
    layout_width = 70
    layout_height = 32
    // 绑定游戏属性(用到设值运算符)
    this[key] = attr
}

因为重载了运算符,所以绑定和获取游戏属性的代码都更加简短。

用泛型就一定要强转?

绑定给 Selector 的数据被设计为泛型,业务层只有强转成具体类型才能使用,有什么办法可以不要在业务层强转?

CoroutineContext的键就携带了类型信息:

public interface CoroutineContext {
    public interface Key<E : Element>
    public operator fun <E : Element> get(key: Key<E>): E?
}

而且每一个CoroutineContext的具体子类型都对应一个静态的键实例:

public interface Job : CoroutineContext.Element {
    public companion object Key : CoroutineContext.Key<Job> {}
}

这样,不需要强转就能获得具体子类型:

coroutineContext[Job]//返回值为 Job 而不是 CoroutineContext

模仿CoroutineContext,业务Selector的键设计了一个带泛型的接口:

interface Key<E : Closeable>

在为Selector绑定数据时需要先构建“键实例”:

val key = object : Selector.Key<GameAttr> {}

传入的键带有类型信息,可以在取值方法中提前完成强转再返回给业务层使用:

// 值的具体类型被参数 key 指定,强转之后再返回给业务层
operator fun <T : Closeable> get(key: Key<T>): T? = (tags.getOrElse(key, { null })) as T

借助于 DSL 根据数据动态地构建选择按钮就变得很轻松,上一幅 Gif 展示的界面代码如下:

// 游戏属性集合实体类
data class GameAttrs(
    var title: String?,// 选项组标题
    var attrs: List<GameAttrName>? // 选项组内容
)

// 简化的单个游戏属性实体类(它会被绑定到Selector)
data class GameAttrName(
    var name: String?
) : Closeable {
    override fun close() {
        name = null
    }
}

这是两个 Demo 中用到的数据实体类,真实项目中他们应该是服务器返回的,简单起见,本地模拟一些数据:

val gameAttrs = listOf(
    GameAttrs(
        "大区", listOf(
            GameAttrName("微信"),
            GameAttrName("QQ")
        )
    ),
    GameAttrs(
        "模式", listOf(
            GameAttrName("排位赛"),
            GameAttrName("普通模式"),
            GameAttrName("娱乐模式"),
            GameAttrName("游戏交流")
        )
    ),
    GameAttrs(
        "匹配段位", listOf(
            GameAttrName("青铜白银"),
            GameAttrName("黄金"),
            GameAttrName("铂金"),
            GameAttrName("钻石"),
            GameAttrName("星耀"),
            GameAttrName("王者")
        )
    ),
    GameAttrs(
        "组队人数", listOf(
            GameAttrName("三排"),
            GameAttrName("五排")
        )
    )
)

最后用 DSL 动态构建选择按钮:

// 纵向布局
LinearLayout {
    layout_width = match_parent
    layout_height = 573
    orientation = vertical

    // 遍历游戏集合,动态添加选项组
    gameAttrs?.forEach { gameAttr ->
        // 添加选项组标题
        TextView {
            layout_width = wrap_content
            layout_height = wrap_content
            textSize = 14f
            textColor = "#ff3f4658"
            textStyle = bold
            text = gameAttr.title
        }

        // 自动换行容器控件
        LineFeedLayout {
            layout_width = match_parent
            layout_height = wrap_content
            
            // 遍历游戏属性,动态添加选项按钮
            gameAttr.attrs?.forEachIndexed { index, attr ->
                Selector {
                    layout_id = attr.name
                    tag = attr.name
                    groupTag = gameAttr.title
                    // 为按钮设置控制器
                    group = gameSelectorGroup
                    // 为按钮指定视图
                    contentView = gameAttrView
                    // 为按钮设置选中效果变换器
                    onSelectChange = onGameAttrChange
                    layout_width = 70
                    layout_height = 32
                    // 为按钮绑定数据并更新视图
                    bind = Binder(attr) { _, _ ->
                        this[gameAttrKey] = attr
                        find<TextView>("tvGameAttrName")?.text = attr.name
                    }
                }
            }
        }
    }
}

其中的按钮视图、按钮控制器、按钮效果变换器定义如下:

// 与游戏属性对应的键
val gameAttrKey = object : Selector.Key<GameAttrName> {}

// 构建游戏属性视图
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
        }

// 按钮选中状态变化时,变更背景色及按钮字体颜色
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
}

// 构建按钮控制器
private val gameSelectorGroup by lazy {
    SelectorGroup().apply {
        choiceMode = { selectorGroup, selector ->
            // 设置除“匹配段位选项组”之外的其他组为单选
            if (selector.groupTag != "匹配段位") {
                selectorGroup.apply {
                    findLast(selector.groupTag)?.let { setSelected(it, false) }
                }
                selectorGroup.setSelected(selector, true)
            }
            // 设置“匹配段位选项组”为多选
            else {
                selectorGroup.setSelected(selector, !selector.isSelecting)
            }
        }
        
        // 选中按钮发生变化时,都会在这里回调
        selectChangeListener = { selecteds ->
            selecteds.forEach { s->
                Log.v("test","${s[gameAttrKey]?.name} is selected")
            }
        }
    }
}

talk is cheap, show me the code

完整代码可以点击这里

推荐阅读

文中有一些未展开的细节,比如“构建布局的 DSL”、“ViewModel 动态扩展属性原理”、“在 DSL 中运用数据绑定”,“重载运算符”。它们的详细讲解可以点击如下链接:

  1. Android自定义控件 | 高可扩展单选按钮(再也不和产品经理吵架了)
  2. Android自定义控件 | 运用策略模式扩展单选按钮和产品经理成为好朋友
  3. Android自定义控件 | 源码里有宝藏之自动换行控件
  4. Android性能优化 | 把构建布局用时缩短 20 倍(下)
  5. 读源码长知识 | 动态扩展类并绑定生命周期的新方式