再也不要和產品經理吵架了——Android自定義單選按鈕
興高采烈地前去一週一次的需求大會。為了更加精準的推送,需要採集使用者資訊,於是乎產品設計瞭如下介面:

沒想到,在發版本的前一天,突然覺得采集粒度不夠細,希望將4個選項增加為6個。面對這突如其來,猝不及防的需求變化,設計和研發組都極力反對。
對於設計來說,不僅僅是加兩張圖,若沿用之前的佈局設計,螢幕就放不下6個選項,所以需要重新設計佈局。經過設計小姐姐的加班努力,最終設計圖改成這樣:

)
#如何定義單選按鈕這個抽象?
在原生抽象中,單選控制元件包含兩個概念:
- 單選組
RadioGroup
- 單選按鈕
RadioButton
原生抽象的侷限性在於: RadioGroup
和 RadioButton
是父子關係,即RadioGroup必須是一個明確的 ViewGroup
型別,這樣就約束了RadioButton的佈局方式。
如果單選組不是一個 View
,是不是就可以解放這層約束?
對於這個問題的答案留一個懸念,拋開單選組,先來看看單選按鈕是一個怎麼樣的抽象。
單選按鈕應該包含如下基本特性:
- 是一個View,且可點選
- 有兩種狀態(選中、未選中),且對應不同的檢視
只需要繼承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; } } 複製程式碼
為滿足業務場景,需要新增一些附加特性:
- 可自定義按鈕內元素相對佈局
附加特性會隨著業務需求變化而變化,所以應該由 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
如下:
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="Selector"> <!--單選按鈕標題--> <attr name="text" format="string" /> <!--單選按鈕圖片--> <attr name="img" format="reference" /> <!--單選按鈕選中效果--> <attr name="indicator" format="reference" /> <!--單選按鈕標題字型大小--> <attr name="text_size" format="integer" /> <!--單選按鈕字型顏色--> <attr name="text_color" format="color" /> <!--單選按鈕標籤--> <attr name="tag" format="string" /> </declare-styleable> </resources> 複製程式碼
因為 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(); } } 複製程式碼
其中單選按鈕的佈局檔案如下:
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:id="@+id/iv_selector" android:layout_width="match_parent" android:layout_height="0dp" app:layout_constraintBottom_toTopOf="@id/tv_title" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_chainStyle="spread" app:layout_constraintVertical_weight="122" /> <ImageView android:id="@+id/iv_icon" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="1:1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.026" app:layout_constraintWidth_percent=".81" /> <TextView android:id="@+id/tv_title" android:layout_width="match_parent" android:layout_height="0dp" android:gravity="center_horizontal|bottom" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintTop_toBottomOf="@id/iv_selector" app:layout_constraintVertical_chainStyle="spread" app:layout_constraintVertical_weight="28" /> </android.support.constraint.ConstraintLayout> 複製程式碼
#如何定義單選組這個抽象?
等等,好像有點不太對勁!如果執行上述程式碼,你會發現每個 Selector
都執行良好(選中狀態發生變化時有漸變動畫),但多個 Selector
可以同時被選中,他們並沒有實現互斥選中。。。
定神一想,發現原因是 Selector
這個抽象只關心自己的選中狀態,它並不知道其他 Selector
的狀態。
所以原生控制元件需要 RadioGroup
這個角色,它作為父親,瞭解每個孩子的動向!
但我們不想要一個 ViewGroup
型別的父親,因為它管的太多,孩子不能隨意佈局,侷限性大。
那就造一個看不見的父親!其實父親做的事情不就是 “在一個孩子選中的時候,通知另一個孩子取消選中”嗎?
有了思路動手就幹,程式碼如下:
import java.util.HashSet; import java.util.Set; public class SelectorGroup { //處於同一組的單選按鈕都被儲存在這個Set中 private Set<Selector> 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(); } } } 複製程式碼
其中佈局檔案如下,你可以任佈局多個單選按鈕:
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <android.support.constraint.Guideline android:id="@+id/gl_center" android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal" app:layout_constraintGuide_percent=".5" /> <TextView android:id="@+id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="30dp" android:text="Selector age" android:textSize="30sp" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> <taylor.com.selector.AgeSelector android:id="@+id/selector_10" android:layout_width="0dp" android:layout_height="0dp" app:img="@mipmap/teenage" app:indicator="@drawable/age_selctor_shape" app:layout_constraintBottom_toTopOf="@id/gl_center" app:layout_constraintDimensionRatio="122:150" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintWidth_percent=".338" app:tag="teenage" app:text="teenage" app:text_color="#FF222222" app:text_size="16" /> <taylor.com.selector.AgeSelector android:id="@+id/selector_20" android:layout_width="0dp" android:layout_height="0dp" app:img="@mipmap/man" app:indicator="@drawable/age_selctor_shape" app:layout_constraintDimensionRatio="122:150" app:layout_constraintEnd_toStartOf="@id/selector_30" app:layout_constraintHorizontal_chainStyle="spread" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/gl_center" app:layout_constraintWidth_percent=".338" app:tag="man" app:text="man" app:text_color="#FF222222" app:text_size="16" /> <taylor.com.selector.AgeSelector android:id="@+id/selector_30" android:layout_width="0dp" android:layout_height="0dp" app:img="@mipmap/old_man" app:indicator="@drawable/age_selctor_shape" app:layout_constraintDimensionRatio="122:150" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_chainStyle="spread" app:layout_constraintStart_toEndOf="@id/selector_20" app:layout_constraintTop_toBottomOf="@id/gl_center" app:layout_constraintWidth_percent=".338" app:tag="old man" app:text="old man" app:text_color="#FF222222" app:text_size="16" /> </android.support.constraint.ConstraintLayout> 複製程式碼
#更多
除了能快速響應需求變化外, Selector
還可以實現更多自定義效果。如下圖是個三選一單選元件,選項分居兩行形成三角形,且帶有漸變選中效果。

-
相比較而言,原生控制元件
RadioButton
有如下的侷限性:- 不能自定義按鈕選中動畫效果
- 不能自定義按鈕相對佈局
RadioGroup
繼承自LinearLayout
,所以RadioButton
的排列方式只能是橫向或縱向一字排開。
-
用本文中的
Selector
就可以輕而易舉的實現這個效果。