前言
自定義View的基礎是了解繪制的流程及相關方法(onMeasure()、onLayout()、onDraw()),了解事件分發機制及相關方法,還有Canvas、Paint等與繪制有關的類,詳細的學習可看大神的文章
AndroidNote。此篇文章做個梳理,以及如何自定義一個展開收起控件。
下面這張圖可以直觀看出繪制的流程,非原創。
一、自定義View分類
1、自定義組合控件。例如繼承LinearLayout,初始化時通過LayoutInflater添加xml布局,只需要得到布局的View做相應處理,不需要考慮測量、定位、繪制等方法。
2、繼承系統控件,在基礎功能上做拓展,比如繼承EditText,在它右側添加刪除按鈕。
3、繼承View、ViewGroup,這種要復雜得多,需要了解View的繪制流程和關鍵方法,實現onMeasure()、onLayout()、onDraw(),實現觸摸事件onTouchEvent()做相應處理,需要思考整個詳細的流程。
二、繪制的流程及相關方法
1、onMeasure()
@Overrideprotected
void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//1、獲取系統根據mode測量出來的寬高值,它不一定是最終的寬高值,因為重寫onMeasure(),
//一般都是想自己設置寬高,如果要拿最終的測量值,要從onSizeChanged()里面取。
int size = MeasureSpec.getSize(widthMeasureSpec);
//2、獲取mode,三種返回值解釋如下
int mode = MeasureSpec.getMode(widthMeasureSpec);
switch (mode) {
case MeasureSpec.UNSPECIFIED:
//未指定,在這個模式下父控件不會干涉子 View 想要多大的尺寸,比如可在RecyclerView源碼看到它的使用。
//自定義View時可以根據需求定制,比如mode是這個時,給寬高設置一個默認值。
break;
case MeasureSpec.AT_MOST:
//對應 wrap_content
break;
case MeasureSpec.EXACTLY:
//對應確切的值和 match_parent
break;
}
//3、最后別忘了調這個方法設置寬高
setMeasuredDimension(width, height);
}
自定義ViewGroup,除了上述方法,還要注意以下幾個方法調用。
1)measureChildren(widthMeasureSpec,heightMeasureSpec)
觸發每個子View的onMeasure(),這是必須調用的,寫在onMeausre()最前面,不然后面無法得到子View寬高。
2)getChildCount()
獲取直接子View的數量,也就是說ViewGroup里有兩個子View,兩個子View又有自己的子View,那么該ViewGroup 調用這個方法會得到 2。
3)getChildAt(int)
獲取子View。
2、onLayout()
定位,確定子View在父View中的位置。這個方法在View的源碼里是空實現,在ViewGroup源碼是抽象方法,所以自定義View不需要這個方法,自定義ViewGroup時一定要重寫這個方法。這是因為子View的定位是由父View決定,在父View的 onLayout() 方法里調用子View的 layout() 來定位子View。
大致流程如下:
/** *
* 遍歷循環子View,調用子View的layout(int l, int t, int r, int b)定位
*
* @param changed
* @param l MyViewGroup 的 左坐標
* @param t MyViewGroup 的 頂坐標
* @param r MyViewGroup 的 右坐標
* @param b MyViewGroup 的 底坐標
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int count = getChildCount();
int curHeight = 0;
for(int k = 0;k<count;k++){
View child = getChildAt(k);
int height = child.getMeasuredHeight();
int width = child.getMeasuredWidth();
//子View定位方法,它的參數是相對于父View來說的,也就是說如果要定位在父View的左上角
//那么,l 和 t 應該傳0。而不是傳onLayout() 這個方法得到的l 和 t。
child.layout(0,curHeight,width,curHeight + height);
curHeight += height;
}
}
3、onDraw()
繪制,涉及到Paint,Canvas,Path等知識,此處不詳細展開,注意不要在onDraw() 里 new 對象,例如Paint,應該在View初始化時設置。
4、onSizeChanged()
當View的size有變化時會調用,可以用來取最終寬高。
5、總結
自定義view
重寫onMeasure()、onDraw()。
1)onMeasure():MeasureSpec.size()獲取Size,MeasureSpec.mode()獲取模式,最后記得調用setMeasuredDimension(width,size);設置寬高。
2)onSizeChanged():會得到最終的寬高,當view的size有變化時會調用。
3)onDraw():注意不要在此方法創建新對象,例如Paint不要放在里面new出來,Invalidate()和postInvalidate(),都會調用onDraw()重繪。如果需要重新測量定位,調用requestLayout()。
- TypeArray:獲取attrs.xml定義的屬性。
自定義ViewGroup
除了onMeasure() 和 onDraw(),還要重寫onLayout()。
1)onMeasure():
除了上述相關內容,還要注意以下幾點,measureChildren(),會觸發每個子View的onMeasure(),注意和measureChild()區分;調用getChildCount()獲取子View數量;調用getChildAt(i)獲取子View。
2)onLayout():
遍歷循環子View,調用子View的layout(int l, int t, int r, int b)定位。
三、事件分發機制及相關方法
1、在ViewGroup 事件分發
2、在View 消費事件
總結
1)事件分發流程dispatchTouchEvent()、onInterceptTouchEvent()、onTouchEvent(),如果做攔截事件,在ViewGroup 的 onInterceptTouchEvent()返回true即可,View 沒有onInterceptTouchEvent()。
2)注意onTouchEvent() 和 onTouch() 的關系,自定義View時,常常需要重寫 onTouchEvent()。
3)ACTION_DOWN、ACTION_MOVE、ACTION_UP 傳遞流程的梳理,自定義View的時候常見。
四、其他知識點以及注意事項(待更新)
1、LayoutInflater
三種方法的理解,詳情請看 Android LayoutInflate深度解析 給你帶來全新的認識
五、自定義控件學習例子
了解View的繪制和事件分發基本知識后,再去自定義控件還是有難度的。自定義控件難點在于怎么去把效果拆分,協調父View、子View之間的關系,然后一點一點去實現,而不是看到一個完整的效果懵逼。這個可以通過拆分別人的自定義控件去學習,考慮怎么達到這樣的效果,下面推薦兩個例子學習。
1、SlideView
Android自定義滑動確認控件SlideView
這是一個日常工作中很可能用到的控件。
自定義ViewGroup 和 View,獲取自定義屬性TypedArray,繪制流程onMeasure()、onLayout()、onDraw(),觸摸事件處理onTouchEvent(),還有接口回調設置監聽,整體邏輯不復雜,實用性強,適合入門學習?;旧喜皇翘珡碗s的自定義控件就是這些內容了。
2、StepView
StepView
步驟指示器,可用于快遞收件流程、任務完成流程等。
3、SlideShowView
一個下滑展開,上滑收起的View,具體效果如下圖
需求分析:
兩個View,可拖動的View 叫 sView, 上層View 叫 topView。
1、需要定義一個父View 來裝 sView 和 topView,且 sView 是在 topView 的底層。
方案:RelativeLayout、FrameLayout、自定義ViewGroup 選一。
2、一開始只顯示topView,sView完全不顯示。
方案:重寫父View onMeasure(),一開始設置高度為 topView 的寬高。
3、下滑上滑。
方案:重寫onTouchEvent(),對三種狀態做處理。
4、sView 展開和收起。
方案:動態改變sView高度、父View 的高度,重寫onLayout()重新定位 sView。
public class SlideShowView extends ViewGroup {
private String TAG = getClass().getSimpleName();
/**
* 可拖動View的寬高
* */
private int msHeight;
private int msWidth;
/**
* 上層View的寬高
* */
private int mTopHeight;
private int mTopWidth;
/**
* 布局最大寬高
* */
private int maxHeight;
private int maxWidth;
/**
* 按下時的點
* */
private int downY = 0;
/**
* 當前高度
* */
private int curHeight;
/**
* 按下時,父View的高度
* */
private int downHeight;
/**
* 抬起時,父View的目標高度
* */
private int targetHeight;
/**
* 滑動距離
* */
private int slide = 0;
/**
* 屬性:滑動有效距離
* */
private int mSlideEffectSize;
/**
* 屬性:是否能滑動
* */
private boolean mEnableSlideShow;
public SlideShowView(Context context) {
this(context,null);
}
public SlideShowView(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public SlideShowView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//獲取自定義屬性
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SlideShowView, 0, 0);
mSlideEffectSize = a.getDimensionPixelSize(R.styleable.SlideShowView_slide_effect_size,50);
mEnableSlideShow = a.getBoolean(R.styleable.SlideShowView_enable_slide_show,true);
a.recycle();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//第一測量,需要得到子View寬高
if(curHeight == 0){
//對所有的子View進行測量
measureChildren(widthMeasureSpec,heightMeasureSpec);
//得到直接子View的數量
int childCount = getChildCount();
//子View不是2個的,此控件失效
if(childCount != 2){
setMeasuredDimension(0,0);
}else{
//第一個View的寬高
View child1 = getChildAt(0);
msWidth = child1.getMeasuredWidth();
msHeight = child1.getMeasuredHeight();
//第二個子View的寬高
View child2 = getChildAt(1);
mTopWidth = child2.getMeasuredWidth();
mTopHeight = child2.getMeasuredHeight();
//整個viewGroup最大寬高
maxWidth = Math.max(msWidth,mTopWidth);
maxHeight = msHeight + mTopHeight;
//初始設置高度為 上層View 的高度
setMeasuredDimension(maxWidth,mTopHeight);
}
}else{
//經由上下滑動改變高度測量
setMeasuredDimension(maxWidth,curHeight);
}
}
/**
* 測量后確定的值
* @param w
* @param h
* @param oldw
* @param oldh
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
Log.e(TAG,"onSizeChanged:新寬--" + w + ",新高--" + h);
curHeight = h;
}
/**
* 定位,其實是定子View 相對于父View 的位置信息。
* 此處兩個子View。
* topView:頂部和 父View 保持一致,不收滑動影響。
* sView: 底部和 父View 保持一致,收滑動影響。
*
* @param changed
* @param l
* @param t
* @param r
* @param b
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
//第一個子View是可拖動的
View child1 = getChildAt(0);
//layout()里的參數,是指子View 在 父View 里的坐標,因為要和頂部保持一致,所以l和t都是0。
child1.layout(0,curHeight - msHeight,msWidth,curHeight);
//第二個子View是不變的
View child2 = getChildAt(1);
child2.layout(0,0,mTopWidth,mTopHeight);
}
/**
* 觸摸事件
* @param event
* @return
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
if(!mEnableSlideShow){
return false;
}
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
downY = (int) event.getY();
Log.e(TAG,"downY:" + downY);
//記錄按下時,整個父view的高
downHeight = curHeight;
break;
case MotionEvent.ACTION_MOVE:
/**
* slide < 0,往下滑動。 slide>0,往上滑動
* */
slide = downY - (int)event.getY();
if(slide < 0 && curHeight < maxHeight) {
//下滑操作,且當前高度沒達到最大高度
curHeight = downHeight + Math.abs(slide);
requestLayout();
}else if(slide > 0 && curHeight > mTopHeight){
//上滑操作,當前高度沒有達到最小高度
curHeight = downHeight - Math.abs(slide);
requestLayout();
}
Log.e(TAG,"slide:" + slide);
break;
case MotionEvent.ACTION_UP:
//滑動決策,滑動距離達到某個值,就進行展開 or 收起
if(Math.abs(slide) > mSlideEffectSize){
if(slide<0){
targetHeight = maxHeight;
}else{
targetHeight = mTopHeight;
}
}else{
//恢復原樣
targetHeight = downHeight;
}
showAnim();
Log.e(TAG,"最終高度:" + targetHeight);
//requestLayout();
break;
}
return true;
}
/**
* 屬性動畫,過渡最終展開收起效果
*/
private void showAnim(){
ValueAnimator animator = ValueAnimator.ofInt(curHeight,targetHeight);
animator.setDuration(300);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
curHeight = (int) animation.getAnimatedValue();
requestLayout();
}
});
animator.setInterpolator(new LinearInterpolator());
animator.start();
}
}
<!--自定義屬性-->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SlideShowView">
<!--滑動多大距離,才判定是展開 or 收起-->
<attr name="slide_effect_size" format="dimension"/>
<!--是否可以滑動顯示-->
<attr name="enable_slide_show" format="boolean"/>
</declare-styleable>
</resources>
<!--在布局中使用-->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.sz.dzh.dandroidsummary.widget.custom.SlideShowView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="20dp"
android:orientation="vertical"
app:slide_effect_size = "20dp">
<LinearLayout
android:layout_width="200dp"
android:layout_height="200dp"
android:gravity="center"
android:background="@color/color_53">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="詳情" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_drag"
android:layout_width="200dp"
android:layout_height="100dp"
android:gravity="center"
android:background="@color/colorPrimary">
<TextView
android:id="@+id/tv_show"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="顯示詳情"
android:padding="10dp"
android:textSize="@dimen/text_size20"/>
</LinearLayout>
</com.sz.dzh.dandroidsummary.widget.custom.SlideShowView>
</LinearLayout>