自定義控制元件總結和思考

冰凌孤星發表於2018-08-28

前言

Android開發中自定義控制元件是很重要的一塊內容。 自定義控制元件有很多型別, 這裡做下總結。目的是為了以後不管看到什麼炫酷的自定義控制元件都能有大概的實現思路。

自定義控制元件的分類

一、 組合控制元件

組合控制元件其實嚴格意義上說其實不是自定義控制元件,但是這裡還是有必要說明一下。 組合控制元件是利用原始控制元件,通過直接設定監聽事件效果來實現一些互動。 如果能用原始控制元件解決的問題,我們就沒有必要自定義控制元件了不是。 組合控制元件大多直接寫在xml佈局中,通過id得到控制元件,做操作。 下面看一經典組合控制元件截圖:

image.png
早期的優酷app主頁下面有這樣一個控制元件,點選小房子按鈕外兩圈會旋轉收縮,再點選就又會出現。點選三橫槓最外圈收縮 再點選就可以出現。 實現: 其實這是三個圖片疊加起來的,通過圖片的選擇來實現的;

image.png

可以看到紅色三個矩形就是三個背景圖;然後把小圖示佈置在指定位置 。 點選時候就讓指定view做旋轉操作。 當然這裡有一些邏輯判斷。 組合控制元件比較簡單,注意佈局和監聽事件處理就可以了。

二、 自定義控制元件

1) 繼承自View的控制元件

繼承View的控制元件是單獨使用的。 如果放在xml中是直接用的話使用</>, 不可以在裡面再新增子控制元件。 一般是使用Canvas來畫檢視的,因為沒有子控制元件,所以不需要考慮子控制元件的擺放。 如果要畫動態的效果,其實就是不斷地呼叫invalidate();--> onDraw() --> Canvas繪圖。 onMeasure --> onDraw

2) 繼承自ViewGroup的控制元件

繼承ViewGroup在xml中可以</>或者<>xxx</> 這樣寫裡面可以直接填入子控制元件。 一般是使用Canvas來畫檢視的,因為有子控制元件,所以需要考慮子控制元件的擺放。 onMeasure -->onLayout --> onDraw 通過findchidviewbyid 等方法獲取子控制元件。

3) 繼承已有的控制元件

比如說ListView是寫列表的,但是一個帶上下拉重新整理功能的列表怎麼實現? 其實就是可以繼承ListView重寫其中的一些方法,讓其帶上下拉重新整理功能。

三、根據需求分析實現思路

1) 效果圖拆解

要實現某一個自定義控制元件,先要開始分析效果。 不管多複雜的自定義控制元件,先分解為靜態效果和動態效果。 先去實現靜態的部分, 再實現動態的部分,最後是互動部分。

2) 靜態部分

自定義控制元件的一幀, 靜態的效果用draw()方法中的Canvas畫布和Paint畫筆來繪製,看看Canvas能什麼事情。

image.png

Canvas 可以繪製文字,圖片,矩形,軌跡線等。還可以通過paint畫筆調整顏色鋸齒等。 任何一個自定義控制元件都是先實現靜態效果,再去實現動態效果。 當然在這之前需要先分析自定義控制元件是view還是viewGroup的,兩者區別的關鍵是看這個自定義控制元件中是否有子控制元件。

3)動態部分

動態部分的實現可以考慮使用屬性動畫。 屬性動畫的變化中動態的改變一個變數,然後去反覆呼叫onDraw方法。 不斷的繪製就會有動畫效果的產生。 引用網上一個自定義控制元件https://juejin.im/post/5b7a90bde51d4538a01ea519

自定義.gif

4)互動部分

自定義控制元件的互動就是一些觸控事件的處理。 重寫自定義控制元件的onTouchEvent方法來實現。 onTouchEvent方法中去去處理 觸控包括按下去的位置,滑動距離,抬起來的座標等等。

四、實踐

一、 繼承自View的控制元件 先看效果:

自定義bar.gif
我們看到的右側導航欄目就是一個繼承view的自定義控制元件。 1)先看靜態效果 是一列字母和符號。 這個是用canvas.drawText(letter, x, y, paint) 來繪製的。 再就是指定字母的位置, 這個位置需要計算 paint.getTextBounds()可以獲取字型區域,根據這些計算。
2)動態效果 點選的時 字母彈出效果, 其實是canvas.drawText(letter, x, y, paint) 中x的位置發生了變化。
3) 互動效果 就是觸發事件。 點選事件的觸發寫在public boolean onTouchEvent(MotionEvent event) {}中 ; 一般在考慮這種動效的時候,先是實現點選效果,再是實現滑動效果。 具體程式碼可以參考: github.com/zmin666/Qui…

二、 繼承自ViewGroup的控制元件 :

繼承自ViewGroup的控制元件需要重寫onLayout 通過這個方法來擺放控制元件

自定義控制元件中:

 private void init() {
        mChild1 = (Button) getChildAt(0);
        mChild2 = (TextView) getChildAt(1);
    }

    @Override
    protected void onLayout(boolean b, int i, int i1, int i2, int i3) {
        init();
        mChild1.layout(300, 100, 700, 200);
        mChild2.layout(300, 400, 700, 500);
        requestLayout();
    }
複製程式碼

xml佈局

    <com.example.zmin.demo.MineViewGroup
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <Button
            android:id="@+id/bt_1"
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:background="@color/colorPrimaryDark"/>

        <TextView
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:background="@color/colorAccent"
            android:gravity="center"
            android:text="文字顯示"
            android:textSize="20sp"/>

    </com.example.zmin.demo.MineViewGroup>
複製程式碼

獲取了子控制元件了 其他的方法其實和繼承view的自定義控制元件相同。 不同的是,可以不斷通過requestLayout(); 來調整子View的位置 大小等。

三、 繼承已有的控制元件 先看效果:

視察特效

girl01.gif

這個效果的實現就是繼承Listview來實現的。 因為其還是一個列表,如果重寫ViewGroup肯定工作量太大。繼承LsitView加以改造,可以快速顯示這個效果。

在列表到最上方是時候,繼續下拉 頭部空間變長,放手後回彈回去。

public class ParallaxListView  extends ListView {

    private ImageView iv_header;
    private int origanaldHeight;
    private int drawableHeight;


    public ParallaxListView(Context context) {
        super(context);
    }

    public ParallaxListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public ParallaxListView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, int scrollRangeY, int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent) {
        //deltaY 豎直方向滑動的瞬時變化量 頂部下拉為- 底部上拉為+
        //scrollY 豎直方向滑動超出的距離 頂部為- 底部為正
        //scrollRangeY 豎直方向滑動的距離
        //maxOverScrollY 豎直方向最大的滑動位置
        //isTouchEvent 是慣性 還是觸控
        System.out.println("deltaY: " + deltaY + " scrollY: " + scrollY
                + " scrollRangeY: " + scrollRangeY + " maxOverScrollY: " + maxOverScrollY
                + " isTouchEvent: " + isTouchEvent);
        if(deltaY < 0 && isTouchEvent){
            int newHeight = iv_header.getMeasuredHeight() + Math.abs(deltaY)/3;
            if(newHeight <= drawableHeight){
                System.out.println("newHeight: " + newHeight);
                //設定Layout值
                iv_header.getLayoutParams().height = newHeight;
                //請求重擺放
                iv_header.requestLayout();
            }
        }
        return super.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, maxOverScrollX, maxOverScrollY, isTouchEvent);
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_UP:
                //直接恢復
//                iv_header.getLayoutParams().height = origanaldHeight;
//                iv_header.requestLayout();

                // 做成動畫效果  --> 屬性動畫
                ValueAnimator valueAnimator = ValueAnimator.ofInt(iv_header.getHeight(), origanaldHeight);
                valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                    @Override
                    public void onAnimationUpdate(ValueAnimator animation) {
                        //獲取動畫的分度值
                        float animatedFraction = animation.getAnimatedFraction();
                        Integer animatedValue = (Integer) animation.getAnimatedValue();
                        iv_header.getLayoutParams().height = animatedValue;
                        iv_header.requestLayout();
                    }
                });
                valueAnimator.setInterpolator(new OvershootInterpolator(4));
                valueAnimator.setDuration(500);
                valueAnimator.start();



                break;
        }
        return super.onTouchEvent(ev);
    }

    public void setParallaxImage(ImageView iv_header) {
        this.iv_header = iv_header;
        // imageView的高度
        origanaldHeight = iv_header.getMeasuredHeight();
        // imageView中的圖片的高度
        drawableHeight = iv_header.getDrawable().getIntrinsicHeight();
    }

}
複製程式碼

使用:

 plv = (ParallaxListView) findViewById(R.id.plv);
        View activity_heard = View.inflate(this, R.layout.activity_heard, null);
        final ImageView iv_header = (ImageView) activity_heard.findViewById(R.id.iv_header);
        plv.addHeaderView(activity_heard);

        //為什麼對自定義控制元件設定成員變數需要在這裡設定, 這個方法是為了填充玩整個View樹的監聽.然後再設定.
        //這樣設定能保證在自定義控制元件中拿到高度
        plv.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                plv.setParallaxImage(iv_header);
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                    plv.getViewTreeObserver().removeOnGlobalLayoutListener(this);
                }
            }
        });

        plv.setAdapter(new ArrayAdapter<String>(this,android.R.layout.simple_list_item_1,Cheeses.NAMES));
複製程式碼

靜態部分: 列表+頭部圖片 -> 想到listview和addHeaderView方法來實現 動態部分: 頭部圖片可以拉伸 想到設定頭部 Layout.Height的值 互動:列表在最上方,下拉的時候才觸發。 ---->考慮view.getViewTreeObserver().addOnGlobalLayoutListener 來監聽 拉下圖片後放手 圖片回彈 ---> onTouchEvent -->MotionEvent.ACTION_UP中觸發

五、自定義控制元件中需要必備知識

1) 獲取自定義控制元件的寬高

自定義控制元件方法執行順序 Constructor->onFinishInflate->onMeasure..->onSizeChanged->onLayout->addOnGlobalLayoutListener->onWindowFocusChanged->onMeasure->onLayout 其中 onMeasure和onLayout會被多次呼叫.

如果自定義控制元件使用過程中寬高不變推薦使用 onMeasure --》 可以通過 getMeasuredHeight()和getMeasuredWidth()來獲取控制元件的高和寬 如果自定義控制元件使用過程中寬高改變的 而且一開始就需要寬高資訊 View.getViewTreeObserver().addOnGlobalLayoutListener(OnGlobalLayoutListener listener)的方式,因為這種方式有getViewTreeObserver().removeOnGlobalLayoutListener(this);來避免回撥函式因寬高資訊的變化而多次呼叫,如果使用其他方式的話,就要藉助額外的變數來保證獲取到的寬高是View的初始高度. 當然還有其他的方法獲取寬高 如; onSizeChanged onLayout onWindowFocusChanged等。

2) 獲取滑動點選的臨界值

int s = ViewConfiguration.get(getContext()).getScaledTouchSlop(); 觸控軌跡超過這個距離就是滑動, 小於這個距離就算是點選。

3) 自定義控制元件的移動
  1. setTranslationX 改變了view的位置,但沒有改變view的LayoutParams裡的margin屬性;

2.layout
view在檢視中的位置. 這個位置的同時指定左上角和右下角的位置. 還可以使用這個改變View的大小.

3.LayoutParams LayoutParams要使用view的父容器的LayoutParams 然後通過設定margin值來改變view的位置. 通過這個方法設定的位置是改變想對父容器的位置. 只能在父容器中移動;

  1. scrollBy scrollTo 一般用於自定義控制元件本身的移動.

  2. 調整自定義佈局控制元件的高度 直接變高 getLayoutParams().height = mStarHight; requestLayout();

  3. 設定padding

  4. 繪製移動 是通過 invalidate --> ondraw() --> canvans 畫出動態效果.

4)自定義控制元件事件傳遞

a.傳遞——dispatchTouchEvent()函式 b.攔截——onInterceptTouchEvent()函式 (只有ViewGroup才有這個方法) c.消費——onTouchEvent()函式和 OnTouchListener(返回true,事件消費) (1) 事件從 Activity.dispatchTouchEvent()開始傳遞,只要沒有被停止或攔截,從最上層的 View(ViewGroup)開始一直往下(子 View)傳遞。子 View 可以通過 onTouchEvent()對事件進行處理。 (2) 事件由父 View(ViewGroup)傳遞給子 View,ViewGroup 可以通過 onInterceptTouchEvent()對事件做攔截,停止其往下傳遞。 (3) 如果事件從上往下傳遞過程中一直沒有被停止,且最底層子 View 沒有消費事件,事件會反向往上傳遞,這時父 View(ViewGroup)可以進行消費,如果還是沒有被消費的話,最後會到 Activity 的 onTouchEvent()函式,從下往上的消費次序。 (4) 如果 View 沒有對 ACTION_DOWN 進行消費,之後的其他事件不會傳遞過來。 (5) OnTouchListener 優先於 onTouchEvent()對事件進行消費。

5)事件衝突處理

onInterceptTouchEvent 返回true 就是自己攔截事件自己處理 ---> onTouchEvent 返回true 就把事件處理 了. 返回false就是攔截了事件,但是自己也不處理. (子控制元件得不到事件)

6)自定義控制元件自定義屬性

1.建立屬性檔案(res/values/attrs.xml)

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ViewZ2View">
        <attr name="circle_color" format="color" />
        <attr name="radius" format="dimension" />
        <attr name="gap" format="dimension" />
    </declare-styleable>
</resources>
複製程式碼

name是自定義屬性的名字。format是自定義屬性的型別,color是顏色屬性,integer是基本資料型別,除此之外還有很多,可以閱讀文件或直接使用as的程式碼提示。 2.佈局檔案中新增屬性

xmlns:app="http://schemas.android.com/apk/res-auto"

<com.sdwfqin.sample.view.viewz2.ViewZ2View
    ... ...
    app:circle_color="#ffffff"
    app:gap="10dp"
    app:radius="10dp" />
複製程式碼

3.在程式碼中讀取屬性

// 載入自定義屬性集合
	TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.ViewZ2View);
	// Color.WHITE為預設顏色
	mColor = typedArray.getColor(R.styleable.ViewZ2View_circle_color, Color.WHITE);
	radius = typedArray.getDimensionPixelSize(R.styleable.ViewZ2View_radius, radius);
	gap = typedArray.getDimensionPixelSize(R.styleable.ViewZ2View_gap, gap);
	typedArray.recycle();
複製程式碼

讀取屬性完畢後 一定要記得recycle();因為程式在執行時維護了一個 TypedArray的池,程式呼叫時,會向該池中請求一個例項,用完之後,呼叫 recycle() 方法來釋放該例項,從而使其可被其他模組複用。

六、總結思考

其實自定義控制元件中細節很多,這裡的知識總結,只是總結分析一個自定義控制元件的大概實現思路。 不管一個自定義控制元件多麼炫酷複雜,最基礎的分析其實是一樣的。 我寫的一些自定義控制元件, 環形倒數計時: github.com/zmin666/Loo… 快速索引: github.com/zmin666/Qui…

如果需要學習更細緻的知識點,推薦學習拋物線大神的一些自定義控制元件教程。 juejin.im/post/5962a3…

相關文章