1 View與ViewGroup
1.1 繼承關係
在Android中,View是所有控件的父類,例如Button、TextView、LinearLayout、RelativeLayout等等。View是一種抽象的概念,代表一個控件。
ViewGroup的父類是View,它可以容納其他View,可以將它看作一個View容器。如下圖所示:LinearLayout繼承自ViewGroup,而ViewGroup又繼承自View,因此LinearLayout既是ViewGroup也是View。
1.2 View與ViewGroup
View與ViewGroup構成了Android應用的界面。官網是這樣解釋的:
Android 應用的界面使用佈局(ViewGroup
對象)和微件(View
對象)的層次結構構建而成。佈局是一種容器,用於控制其子視圖在屏幕上的放置方式。微件是界面組件,如按鈕和文本框。
1.3 View與ViewGroup的工作流程
- View的工作流程一般涉及到2個方法:
-
onMeasure
:測量View自身的尺寸 -
onDraw
:根據測量的尺寸來繪製自身
- ViewGroup的工作流程一般涉及到3個方法:
-
onMeasure
:測量ViewGroup自身和所有子View的尺寸 -
onLayout
:負責佈局,用來確定子View在佈局空間中的擺放位置 -
onDraw
:根據測量的尺寸來繪製自身
1.4 View的位置
在Android中,View的位置並不是相對於屏幕,而是相對於View的父容器來說的,並且水平向右爲x軸的正方向,豎直向下爲y軸的正方向。
如上圖所示,黑色邊框爲屏幕,紅色邊框爲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+translationX
,y=top+translationY
應用場景:在屬性動畫中,top和left表示原始的左上角位置信息,並不會發生改變,而改變的是translationX和translationY(translationX和translationY默認值爲0),因此導致x,y也發生改變。
2 實現自定義View
2.1 使用onDraw方法畫一個圓
- 先簡單歸納以下思路:
- 新建一個類並繼承自View,並創建其構造方法(這裏爲
CircleView
,並創建所有的構造方法) - 在構造方法中創建一個方法init,在其中做一些初始化工作(創建畫筆,設置畫筆顏色等等)
- 重寫onDraw方法。
- 代碼實現:
- 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();
}
}
- 然後在佈局文件中引入CircleView控件:
爲了更好地理解自定義View,這裏添加一個灰色的背景色(#808080
)
<com.cxs.customview.view.CircleView
android:layout_width="100dp"
android:layout_height="150dp"
android:background="#808080" />
- 運行結果如下所示:
2.2 實現wrap_content
先來了解下測量模式,在
MeasureSpec
類中定義了三種模式:
UNSPECIFIED
:未指定,父控件對子控件沒有任何約束,想要多大就多大,比如:ScrollView。EXACTLY
:確切的值,表示父控件爲子控件指定的確定的大小,他對應於LayoutParams中的match_parent和具體的數值。AT_MOST
:至多,一般是父控件爲子控件指定了最大值,子控件不要超過他,一般是子控件使用了wrap_content。
- 新建一個類並繼承自View(這裏爲
WrapContentCircleView
),並創建構造方法和onDraw方法(同CircleView
),更改佈局文件爲:
<com.cxs.customview.view.WrapContentCircleView
android:layout_width="wrap_content"
android:layout_height="150dp"
android:background="#808080" />
- 在將上面佈局文件中的WrapContentCircleView的
layout_width
屬性改爲wrap_content
後,發現CircleView的寬仍然填充了父佈局,如下圖灰色背景。
- 那麼如何實現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);
}
- 運行結果如下圖所示,發現灰色背景實現了wrap_content效果,與CircleView的運行結果是一致的。
2.3 處理自定義View的margin和padding
- 在上面佈局文件中加上一個marginLeft屬性,如下所示:
<com.cxs.customview.view.WrapContentCircleView
android:layout_width="wrap_content"
android:layout_height="150dp"
android:layout_marginLeft="50dp"
android:background="#808080" />
- 結果如下圖,WrapContentCircleView左側會自動添加50dp的margin值。
結論:margin是由父控件件來處理的,因此一般不需要我們再進行處理。
- 那麼padding值呢?添加完padding值後發現並沒有什麼效果,那麼需要我們自行處理了。
- 新建一個類並繼承自View(這裏爲
PaddingCircleView
),創建構造方法,並重寫onMeasure方法(同WrapContentCircleView
),更改佈局文件如下:
<com.cxs.customview.view.PaddingCircleView
android:layout_width="150dp"
android:layout_height="150dp"
android:background="#808080"
android:padding="20dp" />
- 重寫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();
}
- 重寫onDraw方法前後運行結果如下:
2.4 創建自定義屬性
- 在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>
- 新建一個類並繼承自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>
-
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();
}
}
- 運行結果爲:
- 倘若要在代碼中重新設置顏色值,則在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方法效率更高。
- 然後在對應的Activity中通過
View.setCircleColor(int color)
來重新設置顏色值。
3 移動View
3.1 滾動View中的內容
我們要滾動View中的內容,就要藉助scrollTo和scrollBy來實現,scrollTo滾動的是絕對座標,即滾動到指定的位置,scrollBy滾動的是相對座標,即在上一次滾動的基礎上疊加,並且scroll方法的座標數值是:向右滾動爲負值,向下滾動爲負值,和View的座標相反。
- 要實現的效果如下圖:
分析:我們要滾動TextView,則應該在TextView的外層容器上調用scroll方法。
- 新建一個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>
- 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類來實現。
- 新建一個類爲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();
}
}
}
- 新建一個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>
- 在點擊按鈕後藉助ScrollContentView.startContentScroll方法即可開始滾動(具體效果取決於插值器)。
3.3 移動View
要移動View,則要藉助View.offsetLeftAndRight和View.offsetTopAndBottom方法了,它們會改變當前View的位置,即getX/getY、getLeft、getRight、getTop、getBottom的值。
座標數值是向右移動View則爲正數,向左移動則爲負數;向下移動爲正數,向上移動爲負數。請仿照3.1案例自行編寫demo。
3.4 使用VelocityTracker計算滑動速度
- 實現思路:
- 初始化一個VelocityTracker實例;
- 將所有的Event添加到VelocityTracker;
- 在
ACTION_MOVE
動作下調用計算方法並獲取速度;(在實際應用中一般是在ACTION_UP
動作下調用計算方法並獲取速度的) - 釋放資源;
- 代碼實現:
- 創建一個名爲的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;
}
}
- 佈局文件爲空,則在界面上下左右滑動,查看日誌:
- 補充:還有一個與滑動相關的變量TouchSlop,它是系統所能識別出的最小滑動距離,若小於該距離,則系統不認爲是在滑動,這與設備有關如下圖所示8.0的源碼中對該常量數值的定義:
獲取方法:通過ViewConfiguration.get(this).getScaledTouchSlop()
來獲取
ViewConfiguration這個類主要定義了UI中所使用到的標準常量,像超時、尺寸、距離等。
3.5 藉助Scroller來滾動View
我們在使用scrollTo或scrollBy時,都是在瞬間完成移動,若要慢慢滾動則要藉助Scroller類來實現。
- 新建一個類爲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();
}
}
}
- 新建一個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>
- 在點擊按鈕後藉助ScrollContentView.startContentScroll方法即可開始滾動(具體效果取決於插值器)。
4 自定義ViewGroup
4.1 自定義水平ScrollView
實現效果:
- 首先創建一個類(
HorizontalScrollView
)並繼承自ViewGroup
,創建其構造方法並重寫onLayout
方法; - 然後重寫其
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;
}
- 重寫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;
}
}
}
- 我們將佈局文件放入到佈局文件中(這裏我們將自定義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>
- 此時運行,發現並不能左右滑動,這時因爲我們還需要自己處理內容的滑動,這裏我們攔截所有的事件,重寫
onInterceptTouchEvent
及onTouchEvent
方法即可。
/**
* 攔截觸摸事件
* @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。
- 如下圖將三個文本控件向左滾動:起初,紅色子View的左側與屏幕左側齊平時,getScrollX=0,此時不能再向右滑動,則只能向左滾動,即distanceX>0,即getScrollX+distanceX>0;當藍色的子View右側與屏幕右側齊平時,getScrollX=distanceX=getTotalWidth-getWidth,此時不能再向左滾動,即getScrollX+distanceX<=getTotalWidth-getWidth即可。
- 緊接着我們還需要實現
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
- 常用事件:
-
ACTION_DOWN
:第一個觸摸點按下; -
ACTION_UP
:最後一個觸摸點擡起; -
ACTION_MOVE
:當觸摸點在屏幕上移動則會觸發該事件; -
ACTION_CANCEL
:用戶不能觸發,是由系統觸發的,比如:當父容器通過onInterceptTouchEvent
方法返回true,那麼子View就會收到一個ACTION_CANCEL
事件,後面就不會再有事件傳遞給他; -
ACTION_OUTSIDE
:用戶觸摸超出了正常的UI邊界; -
ACTION_POINTER_DOWN
:當有一個觸點後,再有別觸點按下; -
ACTION_POINTER_UP
:不是最後一個觸點擡起; -
ACTION_SCROLL
:一般是鼠標,滾輪,軌跡球才觸
- 常用方法:
-
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()
:按下或擡起時間;
-
MotionEvent
是一個32位的int值,低16位代表觸控的動作(getActionMasked
),高16位代表觸控點的索引(getActionIndex
)。
這點和MeasureSpec
很像,高2位代表SpecMode,低30位代表了SpecSize,即測量模式和測量尺寸。
案例代碼下載地址:https://github.com/crazywish/...
參考資料: