再也不要和产品经理吵架了——Android自定义单选按钮

业务场景 兴高采烈地前去一周一次的需求大会。为了更加精准的推送,需要采集用户信息,于是乎产品设计了如下界面:

再也不要和产品经理吵架了——Android自定义单选按钮
文章图片
屏幕快照 2019-01-20 下午12.53.12.png 没想到,在发版本的前一天,突然觉得采集粒度不够细,希望将4个选项增加为6个。面对这突如其来,猝不及防的需求变化,设计和研发组都极力反对。
对于设计来说,不仅仅是加两张图,若沿用之前的布局设计,屏幕就放不下6个选项,所以需要重新设计布局。经过设计小姐姐的加班努力,最终设计图改成这样:

再也不要和产品经理吵架了——Android自定义单选按钮
文章图片
屏幕快照 2019-01-20 下午12.53.29.png
对于开发来说。。。
单选按钮有两个标题?
两个标题还是不同颜色?
选中之后标题居然要变颜色?
不怕不怕,别说明天就要发版本,就是今天晚上发也可以。因为我自定义了一个单选控件,这次界面的改动,只需要换2个布局文件。( 公司鼓励拥抱变化的价值观,对于开发来说写出“拥抱变化”的代码就是最好的回应)
如何定义单选按钮这个抽象? 在原生抽象中,单选控件包含两个概念:

  1. 单选组RadioGroup
  2. 单选按钮RadioButton
原生抽象的局限性在于:RadioGroupRadioButton是父子关系,即RadioGroup必须是一个明确的ViewGroup类型,这样就约束了RadioButton的布局方式。
如果单选组不是一个View,是不是就可以解放这层约束?
对于这个问题的答案留一个悬念,抛开单选组,先来看看单选按钮是一个怎么样的抽象。
单选按钮应该包含如下基本特性:
  1. 是一个View,且可点击
  2. 有两种状态(选中、未选中),且对应不同的视图
只需要继承View,并利用View.isSelected()就能实现这两个特性。代码如下:
import android.content.Context; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; public abstract class Selector extends FrameLayout implements View.OnClickListener {public Selector(Context context) { super(context); initView(context, null); }public Selector(Context context, AttributeSet attrs) { super(context, attrs); initView(context, attrs); }public Selector(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initView(context, attrs); }private void initView(Context context, AttributeSet attrs) { //实现特性1:可点击 this.setOnClickListener(this); }@Override public void onClick(View v) { //实现特性2:点击后改变选中状态 boolean isSelect = switchSelector(); }//反转选中状态 public boolean switchSelector() { boolean isSelect = this.isSelected(); this.setSelected(!isSelect); return !isSelect; } }

为满足业务场景,需要新增一些附加特性:
  1. 可自定义按钮内元素相对布局
附加特性会随着业务需求变化而变化,所以应该由Selector提供能力,而让其子类来实现。
  • 虽然这次业务场景中,单选按钮元素的布局是:图片在上,文字在下。下次换了咋办?所以定义元素布局应该作为一个抽象函数交给Selector子类实现。
  • 为了实现选中的渐变效果,Selector需提供选中的时机。
  • 虽然Selector的子类可以定义不同的元素布局,但都必须包含一些基本元素,比如标题、图片、标签名。将这些元素及其属性抽象成控件自定义属性。代码如下:
import android.content.Context; import android.content.res.TypedArray; import android.graphics.Color; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; public abstract class Selector extends FrameLayout implements View.OnClickListener { //单选按钮唯一标示符 private String tag; public Selector(Context context) { super(context); initView(context, null); }public Selector(Context context, AttributeSet attrs) { super(context, attrs); initView(context, attrs); }public Selector(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initView(context, attrs); }private void initView(Context context, AttributeSet attrs) { //将子类自定义View作为孩子添加进来 View view = onCreateView(); LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); this.addView(view, params); this.setOnClickListener(this); //读取自定义属性 if (attrs != null) { TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.Selector); String text = typedArray.getString(R.styleable.Selector_text); int iconResId = typedArray.getResourceId(R.styleable.Selector_img, 0); int selectorResId = typedArray.getResourceId(R.styleable.Selector_indicator, 0); int textColor = typedArray.getColor(R.styleable.Selector_text_color, Color.parseColor("#FF222222")); int textSize = typedArray.getInteger(R.styleable.Selector_text_size, 15); tag = typedArray.getString(R.styleable.Selector_tag); //将属性传递给孩子 onBindView(text, iconResId, selectorResId, textColor, textSize); typedArray.recycle(); } }//父类读取自定义属性后通过该函数传递给子类 protected abstract void onBindView(String text, int iconResId, int indicatorResId, int textColorResId, int textSize); //子类实现该函数以定义单选按钮元素布局 protected abstract View onCreateView(); public String getTag() { return tag; }@Override public void onClick(View v) { boolean isSelect = switchSelector(); }public boolean switchSelector() { boolean isSelect = this.isSelected(); this.setSelected(!isSelect); onSwitchSelected(!isSelect); return !isSelect; }//选中时机 protected abstract void onSwitchSelected(boolean isSelect); }

自定义属性src/main/res/values/attrs.xml如下:

因为Selector是抽象类,所以必须由子类实现它的抽象,下面的代码即是demo中年龄单选按钮的实现:
import android.animation.ValueAnimator; import android.content.Context; import android.util.AttributeSet; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.animation.AccelerateDecelerateInterpolator; import android.widget.ImageView; import android.widget.TextView; import taylor.com.selector2.Selector; public class AgeSelector extends Selector { private TextView tvTitle; private ImageView ivIcon; private ImageView ivSelector; private ValueAnimator valueAnimator; public AgeSelector(Context context) { super(context); }public AgeSelector(Context context, AttributeSet attrs) { super(context, attrs); }public AgeSelector(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); }@Override protected void onBindView(String text, int iconResId, int indicatorResId, int textColorResId, int textSize) { //在这里将自定义布局中的控件和自定义属性值绑定 if (tvTitle != null) { tvTitle.setText(text); tvTitle.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize); tvTitle.setTextColor(textColorResId); } if (ivIcon != null) { ivIcon.setImageResource(iconResId); } if (ivSelector != null) { ivSelector.setImageResource(indicatorResId); ivSelector.setAlpha(0); } }@Override protected View onCreateView() { //在这里定义你想要的布局 View view = LayoutInflater.from(this.getContext()).inflate(R.layout.selector, null); tvTitle = view.findViewById(R.id.tv_title); ivIcon = view.findViewById(R.id.iv_icon); 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(); } }

其中单选按钮的布局文件如下:

如何定义单选组这个抽象? 等等,好像有点不太对劲!如果运行上述代码,你会发现每个Selector都运行良好(选中状态发生变化时有渐变动画),但多个Selector可以同时被选中,他们并没有实现互斥选中。。。
定神一想,发现原因是Selector这个抽象只关心自己的选中状态,它并不知道其他Selector的状态。
所以原生控件需要RadioGroup这个角色,它作为父亲,了解每个孩子的动向!
但我们不想要一个ViewGroup类型的父亲,因为它管的太多,孩子不能随意布局,局限性大。
那就造一个看不见的父亲!其实父亲做的事情不就是“在一个孩子选中的时候,通知另一个孩子取消选中”吗?
有了思路动手就干,代码如下:
import java.util.HashSet; import java.util.Set; public class SelectorGroup { //处于同一组的单选按钮都被保存在这个Set中 private Set selectors = new HashSet<>(); public void addSelector(Selector selector) { selectors.add(selector); }public void setSelected(String tag) { for (Selector s : selectors) { if (s.getTag().equals(tag)) { s.switchSelector(); } } }//当一个按钮选中时,遍历其他按钮并取消他们的选中状态 public void setSelected(Selector selector) { cancelPreSelector(selector); }private void cancelPreSelector(Selector selector) { for (Selector s : selectors) { if (!s.equals(selector) && s.isSelected()) { s.switchSelector(); } } }public Selector getSelected() { for (Selector s : selectors) { if (s.isSelected()) { return s; } } return null; }public void clear() { if (selectors != null) { selectors.clear(); } } }

为了保证单选组中单选按钮的唯一性,用Set作为容器,单选按钮需要实现equals()hashCode以供Set进行散列定位。完整版的单选按钮代码如下:
import android.content.Context; import android.content.res.TypedArray; import android.graphics.Color; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; public abstract class Selector extends FrameLayout implements View.OnClickListener { private OnSelectorStateListener stateListener; private String tag; private SelectorGroup selectorGroup; public Selector(Context context) { super(context); initView(context, null); }public Selector(Context context, AttributeSet attrs) { super(context, attrs); initView(context, attrs); }public Selector(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initView(context, attrs); }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); if (attrs != null) { TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.Selector); String text = typedArray.getString(R.styleable.Selector_text); int iconResId = typedArray.getResourceId(R.styleable.Selector_img, 0); int selectorResId = typedArray.getResourceId(R.styleable.Selector_indicator, 0); int textColor = typedArray.getColor(R.styleable.Selector_text_color, Color.parseColor("#FF222222")); int textSize = typedArray.getInteger(R.styleable.Selector_text_size, 15); tag = typedArray.getString(R.styleable.Selector_tag); onBindView(text, iconResId, selectorResId, textColor, textSize); typedArray.recycle(); } }public Selector setSelectorGroup(SelectorGroup selectorGroup) { this.selectorGroup = selectorGroup; selectorGroup.addSelector(this); return this; }protected abstract void onBindView(String text, int iconResId, int indicatorResId, int textColorResId, int textSize); protected abstract View onCreateView(); public String getTag() { return tag; }public Selector setOnSelectorStateListener(OnSelectorStateListener stateListener) { this.stateListener = stateListener; return this; }@Override public void onClick(View v) { boolean isSelect = switchSelector(); //单选按钮将选中状态告诉单选组 if (selectorGroup != null) { selectorGroup.setSelected(this); } if (stateListener != null) { stateListener.onStateChange(this, isSelect); } }public boolean switchSelector() { boolean isSelect = this.isSelected(); this.setSelected(!isSelect); onSwitchSelected(!isSelect); return !isSelect; }protected abstract void onSwitchSelected(boolean isSelect); //利用tag生成哈希码,遂每个单选按钮的tag需保证唯一 @Override public int hashCode() { return this.tag.hashCode(); }@Override public boolean equals(Object obj) { if (obj instanceof Selector) { return ((Selector) obj).tag.equals(this.tag); } return false; }public interface OnSelectorStateListener { void onStateChange(Selector selector, boolean isSelect); } }

现在就可以像这样使用自定义单选按钮了:
import android.support.v7.app.AppCompatActivity; import android.os.Bundle; import android.widget.Toast; import taylor.com.selector2.Selector; import taylor.com.selector2.SelectorGroup; public class MainActivity extends AppCompatActivity implements Selector.OnSelectorStateListener { private SelectorGroup selectorGroup = new SelectorGroup(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); initView(); }private void initView() { Selector teenageSelector = findViewById(R.id.selector_10); Selector manSelector = findViewById(R.id.selector_20); Selector oldManSelector = findViewById(R.id.selector_30); teenageSelector.setOnSelectorStateListener(this).setSelectorGroup(selectorGroup); manSelector.setOnSelectorStateListener(this).setSelectorGroup(selectorGroup); oldManSelector.setOnSelectorStateListener(this).setSelectorGroup(selectorGroup); }@Override public void onStateChange(Selector selector, boolean isSelect) { String tag = selector.getTag(); if (isSelect) { Toast.makeText(this, tag + " is selected", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(this, tag + " is unselected", Toast.LENGTH_SHORT).show(); } } }

其中布局文件如下,你可以任布局多个单选按钮:

更多 除了能快速响应需求变化外,Selector还可以实现更多自定义效果。如下图是个三选一单选组件,选项分居两行形成三角形,且带有渐变选中效果。

selector.gif
  • 原生控件RadioButton的局限
    1. 不能自定义按钮选中动画效果
    2. 不能自定义按钮相对布局
      RadioGroup继承自LinearLayout,所以RadioButton的排列方式只能是横向或纵向一字排开。
  • 【再也不要和产品经理吵架了——Android自定义单选按钮】用本文中的Selector就可以轻而易举的实现这个效果。
talk is cheap ,show me the code

    推荐阅读