不再要和產品經理吵架了——Android自定義控件之單選按鈕

這是該系列文章的第一篇,系列目錄以下:android

  1. 不再要和產品經理吵架了——Android自定義控件之單選按鈕
  2. 這樣寫代碼就能和產品經理成爲好朋友——策略模式實戰

業務場景

興高采烈地前去一週一次的需求大會。爲了更加精準的推送,須要採集用戶信息,因而乎產品設計了以下界面: git

屏幕快照 2019-01-20 下午12.53.12.png

沒想到,在發版本的前一天,忽然以爲採集粒度不夠細,但願將4個選項增長爲6個。面對這突如其來,猝不及防的需求變化,設計和研發組都極力反對。github

對於設計來講,不只僅是加兩張圖,若沿用以前的佈局設計,屏幕就放不下6個選項,因此須要從新設計佈局。通過設計小姐姐的加班努力,最終設計圖改爲這樣: 算法

屏幕快照 2019-01-20 下午12.53.29.png
對於開發來講。。。 單選按鈕有兩個標題? 兩個標題仍是不一樣顏色? 選中以後標題竟然要變顏色? 不怕不怕,別說明天就要發版本,就是今天晚上發也能夠。由於我自定義了一個單選控件,此次界面的改動,只須要換2個佈局文件。( 公司鼓勵擁抱變化的價值觀,對於開發來講寫出「擁抱變化」的代碼就是最好的迴應

如何定義單選按鈕這個抽象?

在原生抽象中,單選控件包含兩個概念:bash

  1. 單選組RadioGroup
  2. 單選按鈕RadioButton

原生抽象的侷限性在於RadioGroupRadioButton是父子關係,即RadioGroup必須是一個明確的ViewGroup類型,這樣就約束了RadioButton的佈局方式。app

若是單選組不是一個View,是否是就能夠解放這層約束?框架

對於這個問題的答案留一個懸念,拋開單選組,先來看看單選按鈕是一個怎麼樣的抽象。dom

單選按鈕應該包含以下基本特性:ide

  1. 是一個View,且可點擊
  2. 有兩種狀態(選中、未選中),且對應不一樣的視圖

只須要繼承View,並利用View.isSelected()就能實現這兩個特性。代碼以下:函數

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需提供選項變動的時機。
  • 按鈕包含一些基本的屬性,好比按鈕名稱,按鈕圖標,將這些屬性寫成自定義屬性並傳遞給子類解析,代碼以下:
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) {
        //讀取自定義屬性
        if (attrs != null) {
            TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.Selector);
            int tagResId = typedArray.getResourceId(R.styleable.Selector_tag, 0);
            tag = context.getString(tagResId);
            //將自定義屬性傳遞給子類
            onObtainAttrs(typedArray);
            typedArray.recycle();
        } else {
            tag = 「default tag」;
        }
        //構建按鈕視圖
        View view = onCreateView();
        LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        this.addView(view, params);
        this.setOnClickListener(this);
    }
    
    public void onObtainAttrs(TypedArray typedArray) {}

    //子類實現該函數以定義單選按鈕元素佈局
    protected abstract View onCreateView();

    @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);
}
複製代碼

由於Selector是抽象類,因此必須由子類實現它的抽象,下面的代碼便是demo中年齡單選按鈕的實現:

public class AgeSelector extends Selector {
    //聲明按鈕包含的控件
    private TextView tvTitle;
    private ImageView ivIcon;
    private ImageView ivSelector;
    private ValueAnimator valueAnimator;
    
    @Override
    public void onObtainAttrs(TypedArray typedArray) {
        //解析自定義屬性
        text = typedArray.getString(R.styleable.Selector_text);
        iconResId = typedArray.getResourceId(R.styleable.Selector_img, 0);
        indicatorResId = typedArray.getResourceId(R.styleable.Selector_indicator, 0);
        textColor = typedArray.getColor(R.styleable.Selector_text_color, Color.parseColor("#FF222222"));
        textSize = typedArray.getInteger(R.styleable.Selector_text_size, 15);
    }

    private void onBindView(String text, int iconResId, int indicatorResId, int textColor, int textSize) {
        //將自定義屬性綁定到控件
        if (tvTitle != null) {
            tvTitle.setText(text);
            tvTitle.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize);
            tvTitle.setTextColor(textColor);
        }
        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.age_selector, null);
        tvTitle = view.findViewById(R.id.tv_title);
        ivIcon = view.findViewById(R.id.iv_icon);
        ivSelector = view.findViewById(R.id.iv_selector);
        onBindView(text, iconResId, indicatorResId, textColor, textSize);
        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類型的父親,由於它管的太多,孩子不能隨意佈局,侷限性大。

那就造一個看不見的父親!其實父親作的事情不就是 「在一個孩子選中的時候,通知另外一個孩子取消選中」嗎?

有了思路動手就幹,代碼以下:

public class SelectorGroup {
    //用於保存上次選中按鈕
    private HashMap<String, Selector> selectorMap = new HashMap<>();

    //獲取上次選中按鈕
    public Selector getPreSelector(String groupTag) {
        return selectorMap.get(groupTag);
    }

    //取消上次選中按鈕的選中狀態
    private void cancelPreSelector(Selector selector) {
        String groupTag = selector.getGroupTag();
        Selector preSelector = getPreSelector(groupTag);
        if (preSelector != null) {
            preSelector.setSelected(false);
        }
    }
    
    //當單選組按鈕被點擊時,點擊事件會經過這個方法傳遞給單選組
    void onSelectorClick(Selector selector) {
        selector.setSelected(true);
        cancelPreSelector(selector);
        //將此次選中按鈕保存在map中
        selectorMap.put(selector.getGroupTag(), selector);
    }
}
複製代碼

爲了讓SelectorGroup統一管理按鈕點擊後的選中和取消狀態變動,須要將按鈕的點擊事件傳遞給它,遂修改單選按鈕代碼以下:

public abstract class Selector extends FrameLayout implements View.OnClickListener {
    @Override
    public void onClick(View v) {
        //傳遞點擊事件給SelectorGroup,它會調用setSelected()來統一管理按鈕選中和取消狀態
        if (selectorGroup != null) {
            selectorGroup.onSelectorClick(this);
        }
    }
    
    @Override
    public void setSelected(boolean selected) {
        boolean isPreSelected = isSelected();
        super.setSelected(selected);
        if (isPreSelected != selected) {
            onSwitchSelected(selected);
        }
    }

    public boolean switchSelector() {
        boolean isSelect = this.isSelected();
        this.setSelected(!isSelect);
        //當選項變動時
        onSwitchSelected(!isSelect);
        return !isSelect;
    }

    //選項變動
    protected abstract void onSwitchSelected(boolean isSelect);
}
複製代碼

如今就能夠像這樣使用自定義單選按鈕了:

public class MainActivity extends AppCompatActivity{

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initView();
    }

    private void initView() {
        SelectorGroup singleGroup = new SelectorGroup();
        singleGroup.setChoiceMode(SelectorGroup.MODE_SINGLE_CHOICE);
        singleGroup.setStateListener(new SingleChoiceListener());
        ((Selector) findViewById(R.id.single10)).setGroup("single", singleGroup);
        ((Selector) findViewById(R.id.single20)).setGroup("single", singleGroup);
        ((Selector) findViewById(R.id.single30)).setGroup("single", singleGroup);
    }

    private class SingleChoiceListener implements SelectorGroup.StateListener {

        @Override
        public void onStateChange(String tag, boolean isSelected) {
            Toast.makeText(MainActivity.this, tag + " is selected", Toast.LENGTH_SHORT).show();
        }
    }
}
複製代碼

更多

除了能快速響應需求變化外,Selector還能夠實現更多自定義效果。以下圖是個三選一單選組件,選項分居兩行造成三角形,且帶有漸變選中效果。

selector.gif

  • 相比較而言,原生控件RadioButton有以下的侷限性:

    1. 不能自定義按鈕選中動畫效果
    2. 不能自定義按鈕相對佈局。 RadioGroup繼承自LinearLayout,因此RadioButton的排列方式只能是橫向或縱向一字排開。
  • 用本文中的Selector就能夠垂手可得的實現這個效果。

talk is cheap ,show me the code

推薦閱讀

這樣寫代碼就能和產品經理成爲好朋友——策略模式實戰

相關文章
相關標籤/搜索