Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> Adapter模式實戰-重構鴻洋的Android建行圓形菜單

Adapter模式實戰-重構鴻洋的Android建行圓形菜單

編輯:關於Android編程

對於很多開發人員來說,炫酷的UI效果是最吸引他們注意力的,很多人也因為這些炫酷的效果而去學習一些比較知名的UI庫。而做出炫酷效果的前提是你必須對自定義View有所理解,作為90的小民自然也不例外。特別對於剛處在開發初期的小民,對於自定義View這件事覺得又神秘又帥氣,於是小民決定深入研究自定義View以及相關的知識點。

在此之前我們先來看看洋神的原版效果圖:

\

記得那是2014年的第一場雪,比以往時候來得稍晚一些。小民的同事洋叔是一位資深的研發人員,擅長寫UI特效,在開發領域知名度頗高。最近洋叔剛發布了一個效果不錯的圓形菜單,這個菜單的每個Item環形排布,並且可以轉動。小民決定仿照洋叔的效果實現一遍,但是對於小民這個階段來說只要實現環形布局就不錯了,轉動部分作為下個版本功能,就當作自定義View的練習了。<喎?/kf/ware/vc/" target="_blank" class="keylink">vcD4NCjxwPtTaZ29vZ2xlwcvX1Lao0uVWaWV3z+C52LXE1qrKtrXj1q6686Os0KHD8b7N0LS6w8HL1eK49tSy0M6yy7Wlsry+1srTzbyjrM7Sw8fSu7K90ruyvcC0vbK94qOstPrC68jnz8I6PC9wPg0KPHByZSBjbGFzcz0="brush:java;"> // 圓形菜單 public class CircleMenuLayout extends ViewGroup { // 圓形直徑 private int mRadius; // 該容器內child item的默認尺寸 private static final float RADIO_DEFAULT_CHILD_DIMENSION = 1 / 4f; // 該容器的內邊距,無視padding屬性,如需邊距請用該變量 private static final float RADIO_PADDING_LAYOUT = 1 / 12f; // 該容器的內邊距,無視padding屬性,如需邊距請用該變量 private float mPadding; // 布局時的開始角度 private double mStartAngle = 0; // 菜單項的文本 private String[] mItemTexts; // 菜單項的圖標 private int[] mItemImgs; // 菜單的個數 private int mMenuItemCount; // 菜單布局資源id private int mMenuItemLayoutId = R.layout.circle_menu_item; // MenuItem的點擊事件接口 private OnItemClickListener mOnMenuItemClickListener; public CircleMenuLayout(Context context, AttributeSet attrs) { super(context, attrs); // 無視padding setPadding(0, 0, 0, 0); } // 設置菜單條目的圖標和文本 public void setMenuItemIconsAndTexts(int[] images, String[] texts) { if (images == null && texts == null) { throw new IllegalArgumentException("菜單項文本和圖片至少設置其一"); } mItemImgs = images; mItemTexts = texts; // 初始化mMenuCount mMenuItemCount = images == null ? texts.length : images.length; if (images != null && texts != null) { mMenuItemCount = Math.min(images.length, texts.length); } // 構建菜單項 buildMenuItems(); } // 構建菜單項 private void buildMenuItems() { // 根據用戶設置的參數,初始化menu item for (int i = 0; i < mMenuItemCount; i++) { View itemView = inflateMenuView(i); // 初始化菜單項 initMenuItem(itemView, i); // 添加view到容器中 addView(itemView); } } private View inflateMenuView(final int childIndex) { LayoutInflater mInflater = LayoutInflater.from(getContext()); View itemView = mInflater.inflate(mMenuItemLayoutId, this, false); itemView.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if (mOnMenuItemClickListener != null) { mOnMenuItemClickListener.onClick(v, childIndex); } } }); return itemView; } private void initMenuItem(View itemView, int childIndex) { ImageView iv = (ImageView) itemView .findViewById(R.id.id_circle_menu_item_image); TextView tv = (TextView) itemView .findViewById(R.id.id_circle_menu_item_text); iv.setVisibility(View.VISIBLE); iv.setImageResource(mItemImgs[childIndex]); tv.setVisibility(View.VISIBLE); tv.setText(mItemTexts[childIndex]); } // 設置MenuItem的布局文件,必須在setMenuItemIconsAndTexts之前調用 public void setMenuItemLayoutId(int mMenuItemLayoutId) { this.mMenuItemLayoutId = mMenuItemLayoutId; } // 設置MenuItem的點擊事件接口 public void setOnItemClickListener(OnItemClickListener listener) { this.mOnMenuItemClickListener = listener; } // 代碼省略 }

小民的思路大致是這樣的,首先讓用戶通過setMenuItemIconsAndTexts函數將菜單項的圖標和文本傳遞進來,根據這些圖標和文本構建菜單項,菜單項的布局視圖由mMenuItemLayoutId存儲起來,這個mMenuItemLayoutId默認為circle_menu_item.xml,這個xml布局為一個ImageView顯示在一個文本控件的上面。為了菜單項的可定制型,小民還添加了一個setMenuItemLayoutId函數讓用戶可以設置菜單項的布局,希望用戶可以定制各種各樣的菜單樣式。在用戶設置了菜單項的相關數據之後,小民會根據用戶設置進來的圖標和文本數量來構建、初始化相等數量的菜單項,並且將這些菜單項添加到圓形菜單CircleMenuLayout中。然後添加了一個可以設置用戶點擊菜單項的處理接口的setOnItemClickListener函數,使得菜單的點擊事件可以被用戶自定義處理。

在將菜單項添加到CircleMenuLayout之後就是要對這些菜單項進行尺寸丈量和布局了,我們先來看丈量尺寸的代碼,如下 :

    //設置布局的寬高,並策略menu item寬高
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 丈量自身尺寸
        measureMyself(widthMeasureSpec, heightMeasureSpec);
        // 丈量菜單項尺寸
        measureChildViews();
    }

    private void measureMyself(int widthMeasureSpec, int heightMeasureSpec) {
        int resWidth = 0;
        int resHeight = 0;
        // 根據傳入的參數,分別獲取測量模式和測量值
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        // 如果寬或者高的測量模式非精確值
        if (widthMode != MeasureSpec.EXACTLY
                || heightMode != MeasureSpec.EXACTLY) {
            // 主要設置為背景圖的高度
            resWidth = getSuggestedMinimumWidth();
            // 如果未設置背景圖片,則設置為屏幕寬高的默認值
            resWidth = resWidth == 0 ? getDefaultWidth() : resWidth;
            resHeight = getSuggestedMinimumHeight();
            // 如果未設置背景圖片,則設置為屏幕寬高的默認值
            resHeight = resHeight == 0 ? getDefaultWidth() : resHeight;
        } else {
            // 如果都設置為精確值,則直接取小值;
            resWidth = resHeight = Math.min(width, height);
        }
        setMeasuredDimension(resWidth, resHeight);
    }

    private void measureChildViews() {
        // 獲得半徑
        mRadius = Math.max(getMeasuredWidth(), getMeasuredHeight());
        // menu item數量
        final int count = getChildCount();
        // menu item尺寸
        int childSize = (int) (mRadius * RADIO_DEFAULT_CHILD_DIMENSION);
        // menu item測量模式
        int childMode = MeasureSpec.EXACTLY;
        // 迭代測量
        for (int i = 0; i < count; i++) {
            final View child = getChildAt(i);
            if (child.getVisibility() == GONE) {
                continue;
            }
            // 計算menu item的尺寸;以及和設置好的模式,去對item進行測量
            int makeMeasureSpec = -1;
            makeMeasureSpec = MeasureSpec.makeMeasureSpec(childSize,
                    childMode);
            child.measure(makeMeasureSpec, makeMeasureSpec);
        }
        mPadding = RADIO_PADDING_LAYOUT * mRadius;
    }

代碼比較簡單,就是先測量CircleMenuLayout的尺寸,然後測量每個菜單項的尺寸。尺寸獲取了之後就到了布局這一步,這也是整個圓形菜單的核心所在。代碼如下 :

    // 布局menu item的位置
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        final int childCount = getChildCount();
        int left, top;
        // menu item 的尺寸
        int itemWidth = (int) (mRadius * RADIO_DEFAULT_CHILD_DIMENSION);
        // 根據menu item的個數,計算item的布局占用的角度
        float angleDelay = 360 / childCount;
        // 遍歷所有菜單項設置它們的位置
        for (int i = 0; i < childCount; i++) {
            final View child = getChildAt(i);
            if (child.getVisibility() == GONE) {
                continue;
            }
            // 菜單項的起始角度
            mStartAngle %= 360;
            // 計算,中心點到menu item中心的距離
            float distanceFromCenter = mRadius / 2f 
                                - itemWidth / 2 - mPadding;
            // distanceFromCenter cosa 即menu item中心點的left坐標
            left = mRadius / 2 + (int)Math.round(distanceFromCenter
                      * Math.cos(Math.toRadians(mStartAngle)) 
                      * - 1 / 2f * itemWidth);
            // distanceFromCenter sina 即menu item的縱坐標
            top = mRadius / 2 
                        + (int) Math.round(distanceFromCenter
                    * Math.sin( Math.toRadians(mStartAngle) ) 
                    * - 1 / 2f * itemWidth);
            // 布局child view
            child.layout(left, top, 
                    left + itemWidth, top + itemWidth);
            // 疊加尺寸
            mStartAngle += angleDelay;
        }

    }

onLayout函數看起來稍顯復雜,但它的含義就是將所有菜單項按照圓弧的形式布局。整個圓為360度,如果每個菜單項占用的角度為60度,那麼第一個菜單項的角度為0~60,那麼第二個菜單項的角度就是60~120,以此類推將所有菜單項按照圓形布局。首先要去計算每個菜單項的left 和 top位置 ,計算公式的圖形化表示如圖所示。

上圖右下角那個小圓就是我們的菜單項,那麼他的left坐標就是mRadius / 2 + tmp * coas , top坐標則是mRadius / 2 + tmp * sina 。這裡的tmp就是我們代碼中的distanceFromCenter變量。到了這一步之後小民的第一版圓形菜單算是完成了。
下面我們就來集成一下這個圓形菜單。
創建一個工程之後,首先在布局xml中添加圓形菜單控件,代碼如下 :





為了更好的顯示效果,在布局xml中我們為圓形菜單的上一層以及圓形菜單本書都添加了一個背景圖。然後在MainActivity中設置菜單項數據以及點擊事件等。代碼如下所示 :

public class  MainActivity extends Activity {
    private CircleMenuLayout mCircleMenuLayout;
    // 菜單標題
    private String[] mItemTexts = new String[] {
            "安全中心 ", "特色服務", "投資理財",
            "轉賬匯款", "我的賬戶", "信用卡"
    };
    // 菜單圖標
    Private int[] mItemImgs = new int[] {
            R.drawable.home_mbank_1_normal,
            R.drawable.home_mbank_2_normal,               R.drawable.home_mbank_3_normal,
            R.drawable.home_mbank_4_normal,               R.drawable.home_mbank_5_normal,
            R.drawable.home_mbank_6_normal
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
            // 初始化圓形菜單
             mCircleMenuLayout = (CircleMenuLayout) 
                findViewById(R.id.id_menulayout);
            // 設置菜單數據項
            mCircleMenuLayout.setMenuItemIconsAndTexts(mItemImgs, 
                        mItemTexts);
            // 設置菜單項點擊事件
            mCircleMenuLayout.setOnItemClickListener(new            OnItemClickListener() {
                @Override
                public void onClick(View view, int pos) {
                    Toast.makeText(MainActivity.this, 
                            mItemTexts[pos],
                        Toast.LENGTH_SHORT).show();
            }
        });
    }
}

運行效果如前文的動圖所示。

小民得意洋洋的蹦出了一個字:真酷!同時也為自己的學習能力感到驕傲,臉上寫滿了滿足與自豪,感覺自己又朝高級工程師邁近了一步。

“這不是洋叔寫的圓形菜單嘛,小民也下載了?”整准備下班的主管看到這個UI效果問道。小民只好把其中的緣由、實現方式一一說給主管聽,小民還特地強調了CircleMenuLayout的可定制型,通過setMenuItemLayoutId函數設置菜單項的布局id,這樣菜單項的UI效果就可以被用戶定制化了。主管掃視了小民的代碼,似乎察覺出了什麼。於是轉身找來還在埋頭研究代碼的洋叔,並且把小民的實現簡單介紹了一遍,洋叔老師在掃視了一遍代碼之後就發現了其中的問題所在。

“小民吶,你剛才說用戶通過setMenuItemLayoutId函數可以設定菜單項的UI效果。那麼問題來了,在你的CircleMenuLayout中默認實現的是circle_menu_item.xml的邏輯,比如加載菜單項布局之後會通過findViewById找到布局中的各個子視圖,並且進行數據綁定。例如設置圖標和文字,但這是針對circle_menu_item.xml這個布局的具體實現。如果用戶設置菜單項布局為other_menu_item.xml,並且每個菜單項修改為就是一個Button,那麼此時他必須修改CircleMenuLayout中初始化菜單項的代碼。因為布局變了,菜單項裡面的子View類型也變化了,菜單需要的數據也發生了變化。例如菜單項不再需要圖標,只需要文字。這樣一來,用戶每換一種菜單樣式就需要修改一次CircleMenuLayout類一次,並且設置菜單數據的接口也需要改變。這樣就沒有定制型可言了嘛,而且明顯違反了開閉原則。反復對CircleMenuLayout進行修改不免會引入各種各樣的問題……”洋叔老師果然一針見血,深刻啊!小民這才發現了問題所在,於是請教洋叔老師應該如何處理比較合適。

“這種情況你應該使用Adapter,就像ListView中的Adapter一樣,讓用戶來自定義菜單項的布局、解析、數據綁定等工作,你需要知道的僅僅是每個菜單項都是一個View。這樣一來就將變化通過Adapter層隔離出去,你依賴的只是Adapter這個抽象。每個用戶可以有不同的實現,你只需要實現圓形菜單的丈量、布局工作即可。這樣就可以擁抱變化,可定制性就得到了保證。當然,你可以提供一個默認的Adapter,也就是使用你的 circle_menu_item.xml布局實現的菜單,這樣沒有定制需求的用戶就可以使用這個默認的實現了。”小民頻頻點頭,屢屢稱是。“這確實是我之前沒有考慮好,也是經驗確實不足,我再好好重構一下。”小民發現問題之後也承認了自己的不足,兩位前輩看小民這麼好學就陪著小民一塊重構代碼。

在兩位前輩的指點下,經過不到五分鐘重構,小民的CircleMenuLayout成了下面這樣。

// 圓形菜單
public class CircleMenuLayout extends ViewGroup {
    // 字段省略
    // 設置Adapter
    public void setAdapter(ListAdapter mAdapter) {
        this.mAdapter = mAdapter;
    }

    // 構建菜單項
    private void buildMenuItems() {
        // 根據用戶設置的參數,初始化menu item
        for (int i = 0; i < mAdapter.getCount(); i++) {
            final View itemView = mAdapter.getView(i, null, this);
            final int position = i;
            itemView.setOnClickListener(new OnClickListener() {

                @Override
                public void onClick(View v) {
                    if (mOnMenuItemClickListener != null) {
                        mOnMenuItemClickListener.onClick(itemView, position);
                    }
                }
            });
            // 添加view到容器中
            addView(itemView);
        }
    }

    @Override
    protected void onAttachedToWindow() {
        if (mAdapter != null) {
            buildMenuItems();
        }
        super.onAttachedToWindow();
    }
    // 丈量、布局代碼省略
}

現在的CircleMenuLayout把解析xml、初始化菜單項的具體工作移除,添加了一個Adapter,在用戶設置了Adapter之後,在onAttachedToWindow函數中調用Adapter的getCount函數獲取菜單項的數量,然後通過getView函數獲取每個View,最後將這些菜單項的View添加到圓形菜單中,圓形菜單布局再將他們布局到特定的位置即可。

我們看現在使用CircleMenuLayout是怎樣的形式。首先定義了一個實體類MenuItem來存儲菜單項圖標和文本的信息,代碼如下 :

static class MenuItem {
    public int imageId;
    public String title;
    public MenuItem(String title, int resId) {
            this.title = title;
         imageId = resId;
   }
}

然後再實現一個Adapter,這個Adapter的類型就是ListAdapter。我們需要在getView中加載菜單項xml、綁定數據等,相關代碼如下 :

static class CircleMenuAdapter extends BaseAdapter {
        List mMenuItems;
        public CircleMenuAdapter(List menuItems) {
            mMenuItems = menuItems;
        }

        // 加載菜單項布局,並且初始化每個菜單
        @Override
        public View getView(final int position, View convertView, ViewGroup parent) {
            LayoutInflater mInflater = LayoutInflater.from(parent.getContext());
            View itemView = mInflater.inflate(R.layout.circle_menu_item, parent, false);
            initMenuItem(itemView, position);
            return itemView;
        }

        // 初始化菜單項
        private void initMenuItem(View itemView, int position) {
            // 獲取數據項
            final MenuItem item = getItem(position); 
            ImageView iv = (ImageView) itemView
                    .findViewById(R.id.id_circle_menu_item_image);
            TextView tv = (TextView) itemView
                    .findViewById(R.id.id_circle_menu_item_text);
            // 數據綁定
            iv.setImageResource(item.imageId);
            tv.setText(item.title);
        }
        // 省略獲取item count等代碼
    }

這與我們在ListView中使用Adapter是一致的,實現getView、getCount等函數,在getView中加載每一項的布局文件,並且綁定數據等。最終將菜單View返回,然後這個View就會被添加到CircleMenuLayout中。這一步的操作原來是放在CircleMenuLayout中的,現在被獨立出來,並且通過Adapter進行了隔離。這樣就將易變的部分通過Adapter抽象隔離開來,即使用戶有成千上萬中菜單項UI效果,那麼通過Adapter就可以很容易的進行擴展、實現,而不需要每次都修改CircleMenuLayout中的代碼。CircleMenuLayout布局類相當於提供了一個圓形布局抽象,至於每一個子View是啥樣的它並不需要關心。通過Adapter隔離變化,擁抱變化,就是這麼簡單。

“原來ListView、RecyclerView通過一個Adapter是這個原因,通過Adapter將易變的部分獨立出去交給用戶處理。又通過觀察者模式將數據和UI解耦合,使得View與數據沒有依賴,一份數據可以作用於多個UI,應對UI的易變性。原來如此!”小民最後總結道。

例如,當我們的產品發生變化,需要將圓形菜單修改為普通的ListView樣式,那麼我們要做的事很簡單,就是將xml布局中的CircleMenuLayout修改為ListView,然後將Adapter設置給ListView即可。代碼如下 :

public class MainActivity extends Activity {
private ListView mListView;
    List mMenuItems = new ArrayList();

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

        // 模擬數據
        mockMenuItems();

        mListView = (ListView) findViewById(R.id.id_menulayout);
        // 設置適配器
        mListView.setAdapter(new CircleMenuAdapter(mMenuItems));
        // 設置點擊事件
        mListView.setOnItemClickListener(new OnItemClickListener(){
                @Override
                public void onItemClick(AdapterView parent, 
                    View view, int position, long id) {

                             Toast.makeText(MainActivity.this,   
                     mMenuItems.get(position).title,
                       Toast.LENGTH_SHORT).show();
            }
        });
}

這樣我們就完成了UI替換,成本很低,也基本不會引發其他錯誤。這也就是為什麼我們在CircleMenuLayout中要使用ListAdapter的原因,就是為了與現有的ListView、GridView等組件進行兼容,當然我們也沒有啥必要重新再定義一個Adapter類型,從此我們就可以任意修改我們的菜單Item樣式了,保證了這個組件的靈活性!! 替換為ListView的效果如下所示:

\

“走,我請兩位前輩吃烤魚去!”小民在重構完CircleMenuLayout之後深感收獲頗多,為了報答主管和洋叔的指點嚷嚷著要請吃飯。“那就走吧!”主管倒是爽快的答應了,洋叔老師也是立馬應允,三人收拾好電腦後就朝著樓下的巫山烤魚店走去。

20.9總結

Adapter模式的經典實現在於將原本不兼容的接口融合在一起,使之能夠很好的進行合作。但是在實際開發中,Adapter模式也有一些靈活的實現。例如ListView中的隔離變化,使得整個UI架構變得更靈活,能夠擁抱變化。Adapter模式在開發中運用非常廣泛,因此掌握Adapter模式是非常必要的。

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved