自定義View詳解

1 View與ViewGroup

1.1 繼承關係

在Android中,View是所有控件的父類,例如Button、TextView、LinearLayout、RelativeLayout等等。View是一種抽象的概念,代表一個控件。
View的繼承關係

ViewGroup的父類是View,它可以容納其他View,可以將它看作一個View容器。如下圖所示:LinearLayout繼承自ViewGroup,而ViewGroup又繼承自View,因此LinearLayout既是ViewGroup也是View。
LinearLayout的繼承關係

1.2 View與ViewGroup

View與ViewGroup構成了Android應用的界面。官網是這樣解釋的:

Android 應用的界面使用佈局(ViewGroup 對象)和微件(View 對象)的層次結構構建而成。佈局是一種容器,用於控制其子視圖在屏幕上的放置方式。微件是界面組件,如按鈕和文本框。

ViewGroup 對象如何在佈局中形成分支幷包含 View 對象的圖解-圖來自官網

1.3 View與ViewGroup的工作流程

  1. View的工作流程一般涉及到2個方法:
  • onMeasure:測量View自身的尺寸
  • onDraw:根據測量的尺寸來繪製自身

View工作流程

  1. ViewGroup的工作流程一般涉及到3個方法:
  • onMeasure:測量ViewGroup自身和所有子View的尺寸
  • onLayout:負責佈局,用來確定子View在佈局空間中的擺放位置
  • onDraw:根據測量的尺寸來繪製自身

ViewGroup工作流程

1.4 View的位置

在Android中,View的位置並不是相對於屏幕,而是相對於View的父容器來說的,並且水平向右爲x軸的正方向,豎直向下爲y軸的正方向。
Android座標系下View的位置
如上圖所示,黑色邊框爲屏幕,紅色邊框爲ViewGroup1,綠色邊框爲ViewGroup2,則ViewGroup2的位置是相對於ViewGroup1的位置來說的,而View(藍色邊框)的位置是相對於ViewGroup2的位置來說的。

View的位置可以用四個屬性:Left(左邊距)、Top(上邊距)、Right(右邊距)、Bottom(下邊距)方法獲取來表示,並且分別通過getLeft、getTop、getRight、getBottom方法來獲取。

從Android 3.0開始,View增加了額外的幾個參數,分別是:x、y、translationX、translationY。它們之間的換算關係如下:x=left+translationXy=top+translationY

應用場景:在屬性動畫中,top和left表示原始的左上角位置信息,並不會發生改變,而改變的是translationX和translationY(translationX和translationY默認值爲0),因此導致x,y也發生改變。

2 實現自定義View

2.1 使用onDraw方法畫一個圓

  • 先簡單歸納以下思路:
  1. 新建一個類並繼承自View,並創建其構造方法(這裏爲CircleView,並創建所有的構造方法)
  2. 在構造方法中創建一個方法init,在其中做一些初始化工作(創建畫筆,設置畫筆顏色等等)
  3. 重寫onDraw方法。
  • 代碼實現:
  1. CircleVIew的代碼如下所示:
public class CircleView extends View {
    private Paint paint;

    public CircleView(Context context) {
        super(context);
        init();
    }

    public CircleView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init();
    }

    public void init() {
        //創建一個抗鋸齒的紅色畫筆
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setColor(Color.RED);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.save();//保存畫板
        int width = getWidth();
        int height = getHeight();
        //設置圓的半徑爲寬和高中較小值的1/2,否則顯示不全
        int radius = Math.min(width, height) / 2;
        //x,y都爲半徑,避免因寬高不一致而造成有margin值
        canvas.drawCircle(radius, radius, radius, paint);
        canvas.restore();
    }
}
  1. 然後在佈局文件中引入CircleView控件:
爲了更好地理解自定義View,這裏添加一個灰色的背景色(#808080
<com.cxs.customview.view.CircleView
        android:layout_width="100dp"
        android:layout_height="150dp"
        android:background="#808080" />
  1. 運行結果如下所示:

自定義CircleView

2.2 實現wrap_content

先來了解下測量模式,在MeasureSpec類中定義了三種模式:

  1. UNSPECIFIED:未指定,父控件對子控件沒有任何約束,想要多大就多大,比如:ScrollView。
  2. EXACTLY:確切的值,表示父控件爲子控件指定的確定的大小,他對應於LayoutParams中的match_parent和具體的數值。
  3. AT_MOST:至多,一般是父控件爲子控件指定了最大值,子控件不要超過他,一般是子控件使用了wrap_content。
  1. 新建一個類並繼承自View(這裏爲WrapContentCircleView),並創建構造方法和onDraw方法(同CircleView),更改佈局文件爲:
<com.cxs.customview.view.WrapContentCircleView
        android:layout_width="wrap_content"
        android:layout_height="150dp"
        android:background="#808080" />
  1. 在將上面佈局文件中的WrapContentCircleView的layout_width屬性改爲wrap_content後,發現CircleView的寬仍然填充了父佈局,如下圖灰色背景。

layout_width屬性爲wrap_content的WrapContentCircleView

  1. 那麼如何實現wrap_content效果呢?這時就需要重寫父類View的onMeasure方法了。
//由於默認單位爲px,這裏將其轉換爲dp,便於與上面的結果進行對比
private final int DEFAULT_WIDTH = DensityUtil.dip2px(getContext(), 100);
private final int DEFAULT_HEIGHT = DensityUtil.dip2px(getContext(), 150);

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    int resultWidth = widthSize;
    int resultHeight = heightSize;

    //AT_MOST,表示控件的尺寸爲wrap_content
    if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
        //寬高都爲wrap_content,所以都用默認值
        resultWidth = DEFAULT_WIDTH;
        resultHeight = DEFAULT_HEIGHT;
     } else if (widthMode == MeasureSpec.AT_MOST) {
        //寬爲wrap_content,只有寬爲默認的尺寸
        resultWidth = DEFAULT_WIDTH;
        resultHeight = heightSize;
     } else if (heightMode == MeasureSpec.AT_MOST) {
        //高爲wrap_content,只有高爲默認的尺寸!
        resultWidth = widthSize;
        resultHeight = DEFAULT_HEIGHT;
     }
     setMeasuredDimension(resultWidth, resultHeight);
}
  1. 運行結果如下圖所示,發現灰色背景實現了wrap_content效果,與CircleView的運行結果是一致的。

實現了wrap_content效果的WrapContentCircleView

2.3 處理自定義View的margin和padding

  1. 在上面佈局文件中加上一個marginLeft屬性,如下所示:
<com.cxs.customview.view.WrapContentCircleView
        android:layout_width="wrap_content"
        android:layout_height="150dp"
        android:layout_marginLeft="50dp"
        android:background="#808080" />
  1. 結果如下圖,WrapContentCircleView左側會自動添加50dp的margin值。

左側添加50dp外邊距的WrapContentCircleView

結論:margin是由父控件件來處理的,因此一般不需要我們再進行處理。
  1. 那麼padding值呢?添加完padding值後發現並沒有什麼效果,那麼需要我們自行處理了。
  2. 新建一個類並繼承自View(這裏爲PaddingCircleView),創建構造方法,並重寫onMeasure方法(同WrapContentCircleView),更改佈局文件如下:
<com.cxs.customview.view.PaddingCircleView
        android:layout_width="150dp"
        android:layout_height="150dp"
        android:background="#808080"
        android:padding="20dp" />
  1. 重寫onDraw方法,代碼如下:
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.save();//保存畫板

    //獲取當前view的所有padding
    int paddingLeft = getPaddingLeft();
    int paddingTop = getPaddingTop();
    int paddingRight = getPaddingRight();
    int paddingBottom = getPaddingBottom();

    int width = getWidth() - paddingLeft - paddingRight;
    int height = getHeight() - paddingTop - paddingBottom;
    int radius = Math.min(width, height) / 2;//設置圓的半徑爲寬和高中較小值的1/2,否則顯示不全
    //圓心x需要加上左邊的padding,y要加上上邊的padding
    canvas.drawCircle(radius + paddingLeft, radius + paddingTop, radius, paint);//x,y都爲半徑,避免因寬高不一致而造成有margin值
    canvas.restore();
}
  1. 重寫onDraw方法前後運行結果如下:

重寫onDraw方法前後結果對比

2.4 創建自定義屬性

  1. 在values目錄下創建自定義屬性的xml文件,一般命名爲attrs.xml,這裏自定義了一個circle_color的屬性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CustomAttributesView">
        <attr name="circle_color" format="color" />
    </declare-styleable>
</resources>
  1. 新建一個類並繼承自View(這裏爲CustomAttributesView),並創建構造方法和onDraw方法(同CircleView),更改佈局文件爲(自定義顏色屬性值爲深天藍色):
注意:需要添加命名空間xmlns:app="http://schemas.android.com/apk/res-auto"
<?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:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.cxs.customview.view.CustomAttributesView
        android:layout_width="150dp"
        android:layout_height="150dp"
        android:background="#808080"
        app:circle_color="#00BFFF" />
</LinearLayout>
  1. CustomAttributesView的代碼如下:
public class CustomAttributesView extends View {
    private static final int DEFAULT_CIRCLE_COLOR = Color.RED;
    private Paint paint;
    private int paintColor = DEFAULT_CIRCLE_COLOR;

    public CustomAttributesView(Context context) {
        super(context);
        init(null);
    }

    public CustomAttributesView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(attrs);
    }

    public CustomAttributesView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(attrs);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public CustomAttributesView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init(attrs);
    }

    public void init(AttributeSet attrs) {
        //從佈局文件中解析自定義屬性
        if (attrs != null) {
            TypedArray array = getContext().obtainStyledAttributes(attrs, R.styleable.CustomAttributesView);
            paintColor = array.getColor(R.styleable.CustomAttributesView_circle_color, DEFAULT_CIRCLE_COLOR);

            array.recycle();
        }
        //創建一個抗鋸齒的畫筆
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        //設置畫筆顏色爲自定義顏色
        paint.setColor(paintColor);
    }

    //onDraw方法同前面的CircleView代碼
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.save();//保存畫板
        int width = getWidth();
        int height = getHeight();
        int radius = Math.min(width, height) / 2;//設置圓的半徑爲寬和高中較小值的1/2,否則顯示不全
        canvas.drawCircle(radius, radius, radius, paint);//x,y都爲半徑,避免因寬高不一致而造成有margin值
        canvas.restore();
    }
}
  1. 運行結果爲:

自定義顏色屬性的CustomAttributesView

  1. 倘若要在代碼中重新設置顏色值,則在CustomAttributesView中添加如下方法即可,在調用invalidate會是重新執行onDraw方法。
public void setCircleColor(int color) {
    //若傳遞進來的顏色值和原來的顏色不一樣,才設置
    if (this.paintColor != color) {
        this.paintColor = color;
        paint.setColor(paintColor);
        invalidate();
    }
}
此外,Android還提供了postInvalidate,它們都是用來刷新界面的,區別爲:invalidate是在UI線程調用,postInvalidate能在子線程和主線程中調用,但是invalidate方法效率更高。
  1. 然後在對應的Activity中通過View.setCircleColor(int color)來重新設置顏色值。

3 移動View

3.1 滾動View中的內容

我們要滾動View中的內容,就要藉助scrollTo和scrollBy來實現,scrollTo滾動的是絕對座標,即滾動到指定的位置,scrollBy滾動的是相對座標,即在上一次滾動的基礎上疊加,並且scroll方法的座標數值是:向右滾動爲負值,向下滾動爲負值,和View的座標相反。

  1. 要實現的效果如下圖:

滾動演示效果
分析:我們要滾動TextView,則應該在TextView的外層容器上調用scroll方法。

  1. 新建一個Activity命名爲ScrollViewActivity,其佈局文件如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <LinearLayout
        android:id="@+id/text_view_container"
        android:layout_width="match_parent"
        android:layout_height="400dp">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="#808080"
            android:text="@string/tv_scroll" />
    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <Button
            android:id="@+id/scroll_to"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="@string/scroll_to"
            android:textAllCaps="false" />

        <Button
            android:id="@+id/scroll_by"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="@string/scroll_by"
            android:textAllCaps="false" />

        <Button
            android:id="@+id/reset"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="@string/reset"
            android:textAllCaps="false" />
    </LinearLayout>
</LinearLayout>
  1. ScrollViewActivity代碼如下:
public class ScrollViewActivity extends BaseActivity {
    @BindView(R.id.text_view_container)
    LinearLayout tvContainer;

    @Override
    public int getLayoutId() {
        return R.layout.activity_scroll_view;
    }

    @OnClick({R.id.scroll_to, R.id.scroll_by, R.id.reset})
    void submit(View view) {
        switch (view.getId()) {
            case R.id.scroll_to:
                tvContainer.scrollTo(-100, -100);
                break;
            case R.id.scroll_by:
                tvContainer.scrollBy(-5, -10);
                break;
            case R.id.reset:
                tvContainer.scrollTo(0, 0);
                break;
        }
    }
}

3.2 藉助Scroller來滾動View

上面的滾動都是瞬間完成的,若要慢慢滾動則要藉助Scroller類來實現。

  1. 新建一個類爲ScrollContentView並繼承自LinearLayout,代碼如下:
public class ScrollContentView extends LinearLayout {
    private Scroller scroller;

    public ScrollContentView(Context context) {
        super(context);
        init(context);
    }

    public ScrollContentView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public ScrollContentView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public ScrollContentView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init(context);
    }

    private void init(Context context) {
        //指定一個線性插值器
        scroller = new Scroller(context, new LinearInterpolator());
    }

    public void startContentScroll() {
        //在3秒鐘內從當前位置滾動到(-300,-300)位置,若不指定時間則默認爲250毫秒,單位爲px
        scroller.startScroll(0, 0, -300, -300, 3000);
        invalidate();//調用該方法讓View重新繪製
    }

    //每次調用完startScroll方法後就會
    @Override
    public void computeScroll() {
        super.computeScroll();

        if (scroller.computeScrollOffset()) {//若還在滾動,則返回true
            scrollTo(scroller.getCurrX(), scroller.getCurrY());
            //在調用View進行繪製,使得View的computeScroll繼續執行,直至完成滾動
            invalidate();
        }
    }
}
  1. 新建一個Activity,命名爲UseScrollerActivity,其佈局文件如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.cxs.customview.view.ScrollContentView
        android:id="@+id/scv"
        android:layout_width="match_parent"
        android:layout_height="400dp">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="我是被滾動的TextView" />
    </com.cxs.customview.view.ScrollContentView>

    <Button
        android:id="@+id/start_scroll"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="開始滾動" />
</LinearLayout>
  1. 在點擊按鈕後藉助ScrollContentView.startContentScroll方法即可開始滾動(具體效果取決於插值器)。

藉助Scroller滾動View

3.3 移動View

要移動View,則要藉助View.offsetLeftAndRight和View.offsetTopAndBottom方法了,它們會改變當前View的位置,即getX/getY、getLeft、getRight、getTop、getBottom的值。
座標數值是向右移動View則爲正數,向左移動則爲負數;向下移動爲正數,向上移動爲負數。請仿照3.1案例自行編寫demo。

3.4 使用VelocityTracker計算滑動速度

  • 實現思路:
  1. 初始化一個VelocityTracker實例;
  2. 將所有的Event添加到VelocityTracker;
  3. ACTION_MOVE動作下調用計算方法並獲取速度;(在實際應用中一般是在ACTION_UP動作下調用計算方法並獲取速度的)
  4. 釋放資源;
  • 代碼實現:
  1. 創建一個名爲的Activity,代碼如下:
public class ComputeVelocityActivity extends BaseActivity {
    private static final String TAG = "ComputeVelocityActivity";
    private VelocityTracker velocityTracker;

    @Override
    pblic int getLayoutId() {
        return R.layout.activity_copute_velocity;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //2.將所有的Event添加到VelocityTracker
        addEventToVelocityTracker(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                //3.調用計算方法
                velocityTracker.computeCurrentVelocity(1000, 200);
                //4.獲取x和y方向上的速度
                float xVelocity = velocityTracker.getXVelocity();
                float yVelocity = velocityTracker.getYVelocity();
                Log.d(TAG, "onTouchEvent: " + xVelocity + "," + yVelocity);
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                releaseVelocityTracker();
                break;
        }
        return super.onTouchEvent(event);
    }

    /**
     * 添加事件到VelocityTracker
     *
     * @param event
     */
    private void addEventToVelocityTracker(MotionEvent event) {
        //1.若VelocityTracker實例爲空,則初始化一個VelocityTracker實例
        if (velocityTracker == null) {
            velocityTracker = VelocityTracker.obtain();
        }
        velocityTracker.addMovement(event);
    }

    /**
     * 釋放Velocity對象
     */
    private void releaseVelocityTracker() {
        //5.釋放資源
        velocityTracker.clear();
        velocityTracker.recycle();
        velocityTracker = null;
    }
}
  1. 佈局文件爲空,則在界面上下左右滑動,查看日誌:

日誌打印結果

  • 補充:還有一個與滑動相關的變量TouchSlop,它是系統所能識別出的最小滑動距離,若小於該距離,則系統不認爲是在滑動,這與設備有關如下圖所示8.0的源碼中對該常量數值的定義:

源碼中TouchSlop數值的定義
獲取方法:通過ViewConfiguration.get(this).getScaledTouchSlop()來獲取

ViewConfiguration這個類主要定義了UI中所使用到的標準常量,像超時、尺寸、距離等。

3.5 藉助Scroller來滾動View

我們在使用scrollTo或scrollBy時,都是在瞬間完成移動,若要慢慢滾動則要藉助Scroller類來實現。

  1. 新建一個類爲ScrollContentView並繼承自LinearLayout,代碼如下:
public class ScrollContentView extends LinearLayout {
    private Scroller scroller;

    public ScrollContentView(Context context) {
        super(context);
        init(context);
    }

    public ScrollContentView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public ScrollContentView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public ScrollContentView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init(context);
    }

    private void init(Context context) {
        //指定一個線性插值器
        scroller = new Scroller(context, new LinearInterpolator());
    }

    public void startContentScroll() {
        //在3秒鐘內從當前位置滾動到(-300,-300)位置,若不指定時間則默認爲250毫秒,單位爲px
        scroller.startScroll(0, 0, -300, -300, 3000);
        invalidate();//調用該方法讓View重新繪製
    }

    //每次調用完startScroll方法後就會
    @Override
    public void computeScroll() {
        super.computeScroll();

        if (scroller.computeScrollOffset()) {//若還在滾動,則返回true
            scrollTo(scroller.getCurrX(), scroller.getCurrY());
            //在調用View進行繪製,使得View的computeScroll繼續執行,直至完成滾動
            invalidate();
        }
    }
}
  1. 新建一個Activity,命名爲UseScrollerActivity,其佈局文件如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.cxs.customview.view.ScrollContentView
        android:id="@+id/scv"
        android:layout_width="match_parent"
        android:layout_height="400dp">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="我是被滾動的TextView" />
    </com.cxs.customview.view.ScrollContentView>

    <Button
        android:id="@+id/start_scroll"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="開始滾動" />
</LinearLayout>
  1. 在點擊按鈕後藉助ScrollContentView.startContentScroll方法即可開始滾動(具體效果取決於插值器)。

藉助Scroller滾動View

4 自定義ViewGroup

4.1 自定義水平ScrollView

實現效果:

自定義HorizontalScrollView效果圖

  1. 首先創建一個類(HorizontalScrollView)並繼承自ViewGroup,創建其構造方法並重寫onLayout方法;
  2. 然後重寫其onMeasure方法,爲了測量出ScrollView的尺寸,我們需要首先測量出各個子View的尺寸,思路如下:
  • 若沒有子元素(或稱子View),則HorizontalScrollView的寬高都爲0;
  • 若寬的測量模式爲AT_MOST,則HorizontalScrollView的寬則爲所有子元素的寬之和,
  • 若高的測量模式爲AT_MOST,則HorizontalScrollView的高爲所有子元素中最高的子元素的高度;

代碼實現如下:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
    int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
    int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);

    int widthResult = 0;
    int heightResult = 0;

    //首先必須測量所有的子元素
    measureChildren(widthMeasureSpec, heightMeasureSpec);

    if (getChildCount() == 0) {//若沒有子元素,則寬高爲0
        widthResult = 0;
        heightResult = 0;
    } else if (heightSpecMode == MeasureSpec.AT_MOST && widthSpecMode == MeasureSpec.AT_MOST) {
        //若寬高都爲至多模式,那寬度就爲所有元素寬度之和,高度就是子元素高度的最大值
        widthResult = getTotalWidth();
        heightResult = getMaxChildHeight();
    } else if (widthSpecMode == MeasureSpec.AT_MOST) {
        //如果寬度爲至多模式,那寬度就爲所有元素寬度之和
        widthResult = getTotalWidth();
        heightResult = heightSpecSize;
    } else if (heightSpecMode == MeasureSpec.AT_MOST) {
        //如果高是至多模式,那高度就是子元素高度的最大值
        widthResult = widthSpecSize;
        heightResult = getMaxChildHeight();
    } else {
        //其他情況,爲當前父類傳遞過來的尺寸
        widthResult = widthSpecSize;
        heightResult = heightSpecSize;
    }
    setMeasuredDimension(widthResult, heightResult);
}

/**
 * 獲取所有子元素的寬之和
 *
 * @return 所有子元素的寬之和
 */
private int getTotalWidth() {
    int childCount = getChildCount();
    int width = 0;
    for (int i = 0; i < childCount; i++) {
        View childView = getChildAt(i);
        width += childView.getMeasuredWidth();
    }
    return width;
}

/**
 * 獲取最高子元素的高度
 *
 * @return 最高子元素的高度
 */
private int getMaxChildHeight() {
    int childCount = getChildCount();
    int maxHeight = 0;
    for (int i = 0; i < childCount; i++) {
        View childView = getChildAt(i);
        if (childView.getMeasuredWidth() > maxHeight) {
            maxHeight = childView.getMeasuredWidth();
        }
    }
    return maxHeight;
}
  1. 重寫onLayout方法,擺放各個子元素,代碼如下:
@Override
protected void onLayout(boolean b, int i, int i1, int i2, int i3) {
    //這裏先忽略padding
    int childLeft = 0;

    for (int j = 0; j < getChildCount(); j++) {
        View childView = getChildAt(j);
        //跳過狀態爲GONE的元素
        if (childView.getVisibility() != View.GONE) {
            int childWidth = childView.getMeasuredWidth();
            int childHeight = childView.getMeasuredHeight();
            childView.layout(childLeft, 0, childLeft + childWidth, childHeight);
            //將child的寬度累加,作爲下一個子元素起始位置
            childLeft += childWidth;
        }
    }
}
  1. 我們將佈局文件放入到佈局文件中(這裏我們將自定義ViewGroup放在最外層),如下所示,並放入幾個文本控件:
<?xml version="1.0" encoding="utf-8"?>
<com.cxs.customview.view.HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:layout_width="300dp"
        android:layout_height="200dp"
        android:background="#f00" />

    <TextView
        android:layout_width="300dp"
        android:layout_height="200dp"
        android:background="#0f0" />

    <TextView
        android:layout_width="300dp"
        android:layout_height="200dp"
        android:background="#00f" />
</com.cxs.customview.view.HorizontalScrollView>
  1. 此時運行,發現並不能左右滑動,這時因爲我們還需要自己處理內容的滑動,這裏我們攔截所有的事件,重寫onInterceptTouchEventonTouchEvent方法即可。
/**
  * 攔截觸摸事件
  * @param ev
  * @return
  */
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    return true;//爲了簡單,這裏先返回true以便攔截所有事件
}

/**
  * 針對攔截的事件進行處理
  *
  * @param event
  * @return
  */
@Override
public boolean onTouchEvent(MotionEvent event) {
    final int action = event.getAction();
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            if (getChildCount() == 0) {//若沒有子元素,則不再攔截事件,返回false
                return false;
            }
            mLastX = (int) event.getX();
            break;
        case MotionEvent.ACTION_MOVE:
        //滾動距離=上一次觸摸點的x座標-當前觸摸點的x座標:則向右滾動爲負,向左爲正
            int distanceX = (int) (mLastX - event.getX());
            //若原來的滾動距離+當前滾動距離>0,表示沒有滾出左邊的邊界
            //並且原來的滾動距離+當前滾動距離必須<所有子元素的寬度-當前屏幕的寬度
            if (getScrollX() + distanceX > 0 && getScrollX() + distanceX <= getTotalWidth() - getWidth()) {
                scrollBy(distanceX, 0);
            }
            mLastX = (int) event.getX();
            break;
    }
    return true;
}

分析:

  • getScrollX:意思是返回當前滑動View左邊界的位置,如下圖所示,初始時view在屏幕左側的位置爲起始位置,此時getScrollX=0;當向左滑動View100px的距離時,getScrollX=100;當向右滑動View100px的距離時,getScrollX=-100。

getScrollX的計算

  • 如下圖將三個文本控件向左滾動:起初,紅色子View的左側與屏幕左側齊平時,getScrollX=0,此時不能再向右滑動,則只能向左滾動,即distanceX>0,即getScrollX+distanceX>0;當藍色的子View右側與屏幕右側齊平時,getScrollX=distanceX=getTotalWidth-getWidth,此時不能再向左滾動,即getScrollX+distanceX<=getTotalWidth-getWidth即可。

上述示例演示


  1. 緊接着我們還需要實現HorizontalScrollView擡手後的慣性滾動效果,首先在onTouchEvent方法中將event添加到VelocityTracker中,然後在ACTION_UP中計算滑動速度,若速度大於系統定義的最小滑動速度,則向前滑動,而滑動時藉助scroller.fling()方法實現滾動,同時重寫computeScroll方法實現,最後再釋放VelocityTracker

新增代碼如下:

public class HorizontalScrollView extends ViewGroup {
    private int mLastX;
    private VelocityTracker velocityTracker;
    private int mMinFlingVelocity;
    private int mMaxFlingVelocity;
    private Scroller scroller;

    public HorizontalScrollView(Context context) {
        super(context);
        init();
    }

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

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

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public HorizontalScrollView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init();
    }

    private void init() {
        ViewConfiguration viewConfiguration = ViewConfiguration.get(getContext());
        mMinFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity();
        mMaxFlingVelocity = viewConfiguration.getScaledMaximumFlingVelocity();
        //可以添加一個插值器
        scroller = new Scroller(getContext(),new FastOutLinearInInterpolator());
    }

    /**
     * 針對攔截的事件進行處理
     *
     * @param event
     * @return
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        final int action = event.getAction();

        addEventToVelocityTracker(event);

        switch (action) {
            //...
            case MotionEvent.ACTION_UP:
                velocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                int xVelocity = (int) velocityTracker.getXVelocity();
                //若速度大於最小滑動速度,就調用fling方法,最小滑動速度可以通過ViewConfiguration獲取
                if (Math.abs(xVelocity) > mMinFlingVelocity) {
                    fling(-xVelocity);//這裏記住要取反
                }
                releaseVelocityTracker();
                break;
        }
        return true;
    }

    private void addEventToVelocityTracker(MotionEvent event) {
        if (velocityTracker == null) {
            velocityTracker = VelocityTracker.obtain();
        }
        velocityTracker.addMovement(event);
    }

    private void fling(int velocity) {//參數爲x方向上的速度
        if (getChildCount() > 0) {
            int width = getWidth();
            int right = getTotalWidth() - getWidth();//最大滾動距離
            scroller.fling(getScrollX(), getScrollY(), velocity, 0, 0, right, 0, 0);
            invalidate();
        }
    }

    @Override
    public void computeScroll() {
        super.computeScroll();

        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.getCurrX(), scroller.getCurrY());
            invalidate();
        }
    }

    private void releaseVelocityTracker() {
        velocityTracker.clear();
        velocityTracker.recycle();
        velocityTracker = null;
    }
}

5 MotionEvent

  1. 常用事件:
  • ACTION_DOWN第一個觸摸點按下;
  • ACTION_UP最後一個觸摸點擡起;
  • ACTION_MOVE:當觸摸點在屏幕上移動則會觸發該事件;
  • ACTION_CANCEL:用戶不能觸發,是由系統觸發的,比如:當父容器通過onInterceptTouchEvent方法返回true,那麼子View就會收到一個ACTION_CANCEL事件,後面就不會再有事件傳遞給他;
  • ACTION_OUTSIDE:用戶觸摸超出了正常的UI邊界;
  • ACTION_POINTER_DOWN:當有一個觸點後,再有別觸點按下;
  • ACTION_POINTER_UP:不是最後一個觸點擡起;
  • ACTION_SCROLL:一般是鼠標,滾輪,軌跡球才觸
  1. 常用方法:
  • event.getAction():動作類型,不能用它判斷多點;
  • event.getActionMasked():多點的動作類型,不管單點或者多點都可以用它;
  • event.getActionIndex():當前MotionEvent是第幾點觸控;
  • event.getPointerCount():當前共有多少個觸摸點;
  • event.getX()/event.getY()相對於當前View左上角的X、Y座標,可以說是這個View內的座標。;
  • event.getRawX()/event.getRawy()相對於手機屏幕的左上角的X、Y座標,包括手機的狀態欄高度
  • event.getX(pointIndex)/event.getY(pointIndex):獲取對應的觸摸點座標;
  • event.getDownTime()/event.getEventTime():按下或擡起時間;
  1. MotionEvent是一個32位的int值,低16位代表觸控的動作(getActionMasked),高16位代表觸控點的索引(getActionIndex)。
這點和MeasureSpec很像,高2位代表SpecMode,低30位代表了SpecSize,即測量模式和測量尺寸。

案例代碼下載地址:https://github.com/crazywish/...


參考資料:

  1. Android getScrollX()詳解
  2. 愛學啊之《詳解View》
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章