关于View的那些事
世界上一成不变的东西,只有“任何事物都是在不断变化的”这条真理。 — 斯里兰卡
写在前面
在Android开发中难免会用到View,甚至有些需求还需要用到自定义View,那么View是什么?View要怎么使用呢?View都能做什么呢?怎么自定义View?
这篇文章就讲一讲关于View的那些事。
View体系
1.View是什么?
View是Android中所有控件的基类,我们日常使用的TextView,ImageView等都是继承自View。
public class TextView extends View implements ViewTreeObserver.OnPreDrawListener {
......
}
public class ImageView extends View {
......
}
2. ViewGroup是什么?
ViewGroup可以理解为View的组合,它可以包含很多View和ViewGroup,它包含的ViewGroup还可以包含View和ViewGroup,依此类推,就形成了View树。我们日常使用的LinearLayout,FrameLayout等都是继承自ViewGroup。
public class LinearLayout extends ViewGroup {
......
}
public class FrameLayout extends ViewGroup {
......
}
ViewGroup也继承自View类,作为View和ViewGroup的容器,派生出了多种布局控件的子类,如LinearLayout,FrameLayout等,在开发应用的UI界面中并不会直接使用View和ViewGroup,而是使用这两大基类的派生类。
3.View的部分继承关系
View的部分继承关系.png4. 坐标系是什么?
Android中有两种坐标系,一种是Android坐标系,一种是View坐标系,了解这两种坐标系可以帮助我们实现View的各种操作,比如实现View的滑动就需要知道View的位置。
Android坐标系:将屏幕左上角顶点作为坐标系的原点,原点向右为X轴正方向,原点向下为Y轴正方向。
View坐标系:将View左上角顶点作为坐标系的原点,原点向右为X轴正方向,原点向下为Y轴正方向。
在触控事件中,通过getRawX()和getRawY()方法获取的坐标也是Android坐标系的坐标,Android坐标系和View坐标系并不冲突,两者可共同存在,它们一起帮助开发者更好的控制View。
5.View自身的坐标
通过以下方法就可以获取到View自身到父控件的距离:
getTop():获取View自身顶边到父控件顶边的距离。
getLeft():获取View自身左边到父控件左边的距离。
getBottom():获取View自身底边到父控件顶边的距离。
getRight():获取View自身右边到父控件左边的距离。
6.View自身的宽和高
知道了View自身到父控件的距离,就可以求出View的宽和高:
int width = getRight() - getLeft();
int height = getBottom() - getTop();
View的源码也是采用此方式得到宽和高:
public final int getWidth() {
return mRight - mLeft;
}
public final int getHeight() {
return mBottom - mTop;
}
7.MotionEvent提供的方法
无论是View还是ViewGroup,最终点击事件都由onTouchEvent(MotionEvent event)处理,MotionEvent在用户交互中作用重大,其内部提供了许多事件常量,如我们日常使用的ACTION_DOWN,ACTION_UP等。
另外,MotionEvent也提供了获取焦点坐标的方法:
getX():获取点击事件到控件左边的距离,即视图坐标。
getY():获取点击事件到控件顶边的距离,即视图坐标。
getRawX():获取点击事件到整个屏幕左边的距离,即绝对坐标。
getRawY():获取点击事件到整个屏幕顶边的距离,即绝对坐标。
View滑动
View的滑动是Android中自定义控件的基础,同时在开发中我们难免会遇到View的滑动处理,不管是哪种滑动方式,其基本原理都是类似的:当点击事件传到View时,系统记下触摸点的坐标,当手指移动时记下移动后触摸的坐标并计算出偏移量,通过偏移量修改View的坐标。
1.通过layout()实现View的滑动
View进行绘制时会调用onLayout()方法设置显示的位置,因此我们也可以通过修改View的left,top,right,bottom这四种属性来控制View的坐标。
/**
* 通过layout()实现View的滑动:
* View进行绘制时会调用onLayout()方法设置显示的位置,
* 因此我们也可以通过修改View的left,top,right,bottom这四个属性控制View的坐标。
*/
public class LayoutScrollView extends View {
private float mDownX;
private float mDownY;
public LayoutScrollView(Context context) {
super(context);
}
public LayoutScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public LayoutScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
/**
* 点击事件传到View时,记下触摸点的坐标
*/
case MotionEvent.ACTION_DOWN:
mDownX = event.getX();
mDownY = event.getY();
break;
/**
* 手指移动时记下移动后触摸的坐标并计算出偏移量,
* 通过偏移量修改View的坐标。
*/
case MotionEvent.ACTION_MOVE:
int offsetX = (int) (event.getX() - mDownX);
int offsetY = (int) (event.getY() - mDownY);
layout(getLeft() + offsetX, getTop() + offsetY,
getRight() + offsetX, getBottom() + offsetY);
break;
default:
break;
}
return true;
}
}
2.通过offsetLeftAndRight()和offsetTopAndBottom()实现View的滑动
这两种方法与layout()方法的效果差不多,使用方式也差不多。
/**
* 通过offsetLeftAndRight()和offsetTopAndBottom()实现View的滑动
*/
public class OffsetScrollView extends View {
private float mDownX;
private float mDownY;
public OffsetScrollView(Context context) {
super(context);
}
public OffsetScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public OffsetScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
/**
* 当点击事件传到View时,记下触摸点的坐标
*/
case MotionEvent.ACTION_DOWN:
mDownX = event.getX();
mDownY = event.getY();
break;
/**
* 手指移动时记下移动后触摸的坐标并计算出偏移量,
* 通过偏移量修改View的坐标
*/
case MotionEvent.ACTION_MOVE:
int offsetX = (int) (event.getX() - mDownX);
int offsetY = (int) (event.getY() - mDownY);
offsetLeftAndRight(offsetX);
offsetTopAndBottom(offsetY);
break;
default:
break;
}
return true;
}
}
3.通过LayoutParams实现View的滑动
LayoutParams主要用来保存View的布局参数,因此我们可以通过LayoutParams改变View的布局参数来达到改变View位置的效果。
/**
* 通过LayoutParams实现View的滑动
* LayoutParams主要用来保存View的布局参数,
* 因此我们可以通过LayoutParams改变View的布局参数来达到改变View位置的效果
*/
public class LayoutParamsScrollView extends View {
private float mDownX;
private float mDownY;
public LayoutParamsScrollView(Context context) {
super(context);
}
public LayoutParamsScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public LayoutParamsScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
/**
* 当点击事件传到View时,记下触摸点的坐标
*/
case MotionEvent.ACTION_DOWN:
mDownX = event.getX();
mDownY = event.getY();
break;
/**
* 手指移动时记下移动后触摸的坐标并计算出偏移量
* 通过偏移量修改View的坐标
*/
case MotionEvent.ACTION_MOVE:
int offsetX = (int) (event.getX() - mDownX);
int offsetY = (int) (event.getY() - mDownY);
ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);
break;
default:
break;
}
return true;
}
}
4.通过scrollBy()实现View的滑动
scrollTo(x,y)表示移动到一个具体的坐标点,scrollBy(dx,dy)则表示移动的增量为dx和dy,scrollBy()最终调也要调用scrollTo()。scrollTo()和scrollBy()都是移动View的内容,如果在ViewGroup中使用,则移动的是其所有的子View。
/**
* 通过scrollBy()实现View的滑动
* scrollTo(x,y)表示移动到一个具体的坐标点,scrollBy(dx,dy)表示移动的增量dx和dy,
* scrollBy()最终也要调用scrollTo()。
* scrollTo()和scrollBy()都是移动View的内容,如果在ViewGroup中使用,
* 则移动的是其所有的子View。
*/
public class ScrollByView extends View {
private float mDownX;
private float mDownY;
public ScrollByView(Context context) {
super(context);
}
public ScrollByView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ScrollByView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
/**
* 当点击事件传到View时,记下触摸点的坐标
*/
case MotionEvent.ACTION_DOWN:
mDownX = event.getX();
mDownY = event.getY();
break;
/**
* 手指移动记下移动后触摸的坐标并计算出偏移量,
* 通过偏移量修改View的坐标。
*/
case MotionEvent.ACTION_MOVE:
int offsetX = (int) (event.getX() - mDownX);
int offsetY = (int) (event.getY() - mDownY);
/**
* 偏移量为什么要设置为负值呢?
* 把手机屏幕比作放大镜,画布比作报纸,放大镜外的内容并不会随着放大镜的移动而消失,
* 同理,scrollBy()滑动的时候手机在动,而画布不动,参照物不同,产生的效果也不同。
*/
((View)getParent()).scrollBy(-offsetX, -offsetY);
break;
default:
break;
}
return true;
}
}
5.通过Scroller实现View的滑动
我们使用scrollTo()和scrollBy()实现View的滑动,这个过程是瞬间完成的,用户体验不太好,因此我们可以使用Scroller来实现过渡滑动的效果,这个过程不是瞬间完成的,而是在一定的时间间隔内完成的。Scroller本身不能实现View的滑动,需要View的computeScroll()方法配合才能实现弹性滑动的效果。
/**
* 通过Scroller实现View的滑动
* 我们使用scrollTo()和scrollBy()进行滑动时,这个过程是瞬间完成的,所以用户体验不太好,
* 这里我们可以使用Scroller来实现过渡效果的滑动,这个过程不是瞬间完成的,而是在一定的时间间隔内完成的,
* Scroller本身并不能实现View的滑动,需要View的computeScroll()方法配合才能实现弹性滑动的效果。
*/
public class ScrollerView extends View {
private Scroller mScroller;
public ScrollerView(Context context) {
this(context, null);
}
public ScrollerView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ScrollerView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
mScroller = new Scroller(getContext());
}
/**
* 重写computeScroll()方法
* 系统会在绘制View的时候在draw()方法中调用该方法,
* 在这个方法中,我们调用父类的scrollTo()方法并通过Scroller来不断获取当前的滚动值,
* 每滑动一小段距离我们就调用invalidate()方法不断的进行重绘,重绘就会调用computeScroll()方法,
* 这样我们通过不断的移动一个小的距离并连贯起来就实现了平滑移动的效果。
* */
@Override
public void computeScroll() {
super.computeScroll();
// 计算滚动的偏移量
if (mScroller.computeScrollOffset()) {
((View) getParent()).scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
invalidate();
}
}
public void smoothScrollTo(int x, int y) {
int startX = getScrollX();
int dx = x - startX;
// 开始滚动
mScroller.startScroll(startX, 0, dx, 0, 2 * 1000);
}
}
下面来看一下Scroller的源码,加深印象:
首先看Scroller的构造函数,Scroller有三个构造函数,第一个构造函数只需传一个Context,第二个构造函数需要传Context和差值器,第三个构造函数需要传入Context,差值器和布尔值。
不管使用第一个构造函数还是第二个构造函数创建Scroller,最终都会调用第三个构造函数,如果传进来的差值器为null则创建默认的差值器,否则使用用户传进来的差值器。
/**
* Create a Scroller with the default duration and interpolator.
*/
public Scroller(Context context) {
this(context, null);
}
/**
* Create a Scroller with the specified interpolator. If the interpolator is
* null, the default (viscous) interpolator will be used. "Flywheel" behavior will
* be in effect for apps targeting Honeycomb or newer.
*/
public Scroller(Context context, Interpolator interpolator) {
this(context, interpolator,
context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.HONEYCOMB);
}
/**
* Create a Scroller with the specified interpolator. If the interpolator is
* null, the default (viscous) interpolator will be used. Specify whether or
* not to support progressive "flywheel" behavior in flinging.
*/
public Scroller(Context context, Interpolator interpolator, boolean flywheel) {
mFinished = true;
if (interpolator == null) {
mInterpolator = new ViscousFluidInterpolator();
} else {
mInterpolator = interpolator;
}
mPpi = context.getResources().getDisplayMetrics().density * 160.0f;
mDeceleration = computeDeceleration(ViewConfiguration.getScrollFriction());
mFlywheel = flywheel;
mPhysicalCoeff = computeDeceleration(0.84f); // look and feel tuning
}
接下来看computeScrollOffset(),该函数主要计算滚动值,timePassed表示已经执行滚动的持续时间,如果持续时间小于滚动总时长,则通过差值器和持续时间计算出滚动值,如果还能继续滚动返回true,否则返回false表示停止滚动。
/**
* Call this when you want to know the new location. If it returns true,
* the animation is not yet finished.
*/
public boolean computeScrollOffset() {
if (mFinished) {
return false;
}
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
break;
case FLING_MODE:
final float t = (float) timePassed / mDuration;
final int index = (int) (NB_SAMPLES * t);
float distanceCoef = 1.f;
float velocityCoef = 0.f;
if (index < NB_SAMPLES) {
final float t_inf = (float) index / NB_SAMPLES;
final float t_sup = (float) (index + 1) / NB_SAMPLES;
final float d_inf = SPLINE_POSITION[index];
final float d_sup = SPLINE_POSITION[index + 1];
velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
distanceCoef = d_inf + (t - t_inf) * velocityCoef;
}
mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;
mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
// Pin to mMinX <= mCurrX <= mMaxX
mCurrX = Math.min(mCurrX, mMaxX);
mCurrX = Math.max(mCurrX, mMinX);
mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
// Pin to mMinY <= mCurrY <= mMaxY
mCurrY = Math.min(mCurrY, mMaxY);
mCurrY = Math.max(mCurrY, mMinY);
if (mCurrX == mFinalX && mCurrY == mFinalY) {
mFinished = true;
}
break;
}
}
else {
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
}
return true;
}
最后看startScroll(),参数分别为起始点X坐标,起始点Y标,X轴移动的距离,Y轴移动的距离,滚动时间。该函数内部并没有立即执行滚动,而是做前期准备工作。View中调用startScroll()后执行invalidate()重绘View,重绘View就会调用draw()方法,调用draw()方法就会调用computeScroll()方法,在computeScroll()方法内部调用Scroller的computeScrollOffset()方法计算滚动值,若返回true则继续调用invalidate()进行重绘,直到computeScrollOffset()方法返回false滚动结束。
/**
* Start scrolling by providing a starting point, the distance to travel,
* and the duration of the scroll.
*
* @param startX Starting horizontal scroll offset in pixels. Positive
* numbers will scroll the content to the left.
* @param startY Starting vertical scroll offset in pixels. Positive numbers
* will scroll the content up.
* @param dx Horizontal distance to travel. Positive numbers will scroll the
* content to the left.
* @param dy Vertical distance to travel. Positive numbers will scroll the
* content up.
* @param duration Duration of the scroll in milliseconds.
*/
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
mMode = SCROLL_MODE;
mFinished = false;
mDuration = duration;
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
mFinalX = startX + dx;
mFinalY = startY + dy;
mDeltaX = dx;
mDeltaY = dy;
mDurationReciprocal = 1.0f / (float) mDuration;
}
总结
未完待续...