当点击事件传到View 时,系统会记下触摸点的坐标,手指移动时系统记下移动后触摸的坐标并计算出偏移量,通过偏移量来修改View 的坐标。实现View 滑动有很多中方法,主要有:layout() 、offsetLeftAndRight() 与offsetTopAndBottom() 、LayoutParams 、动画、scrollTo 和scrollBy ,以及Scroller 。
1 layout() 方法
View 进行绘制的时候会调用onLayout() 方法来设置显示的位置,因此可以通过修改View 的left 、top 、right 、bottom 这4种属性来控制View 的坐标。
首先自定义一个View ,在onTouchEvent() 方法中获取触摸点的坐标,代码如下所示:
public class CustomView extends View {
private int lastX;
private int lastY;
public CustomView(@NonNull @NotNull Context context) {
super(context);
}
public CustomView(@NonNull @NotNull Context context, @Nullable @org.jetbrains.annotations.Nullable AttributeSet attrs) {
super(context, attrs);
}
public CustomView(@NonNull @NotNull Context context, @Nullable @org.jetbrains.annotations.Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
lastX = x;
lastY = y;
break;
case MotionEvent.ACTION_MOVE:
int offsetX = x - lastX;
int offsetY = y - lastY;
layout(getLeft() + offsetX, getTop() + offsetY, getRight() + offsetX, getBottom() + offsetY);
break;
}
return true;
}
}
在布局中引用自定义View :
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.example.myapplication.CustomView
android:id="@+id/custom_view"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_margin="50dp"
android:background="@android:color/holo_red_dark" />
</RelativeLayout>
2 offsetLeftAndRight() 和offsetTopAndBottom()
这两种方法和layout() 方法的效果差不多,其使用方式也差不多,将ACTION_MOVE 中的代码替代如下:
case MotionEvent.ACTION_MOVE:
int offsetX = x - lastX;
int offsetY = y - lastY;
offsetLeftAndRight(offsetX);
offsetTopAndBottom(offsetY);
break;
3 LauyoutParams (改变布局参数)
LayoutParams 主要保存了一个View 的布局参数,因此可以通过LayoutParams 来改变View 的布局参数,从而达到改变View 位置的效果, 在ACTION_MOVE 中:
case MotionEvent.ACTION_MOVE:
int offsetX = x - lastX;
int offsetY = y - lastY;
LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);
break;
4 动画
可以采用View 动画来移动,在res 目录新建anim 文件夹并创建translate.xml :
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="1000">
<translate
android:fromXDelta="0"
android:toXDelta="300" />
</set>
在Java 代码中调用:
findViewById(R.id.button).setAnimation(AnimationUtils.loadAnimation(this, R.anim.translate));
运行程序,设置的按钮会向右移动300 像素,然后又回到原来的位置。如果想要停留在当前位置,需要加上android:fillAfter="true" :
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="1000"
android:fillAfter="true">
<translate
android:fromXDelta="0"
android:toXDelta="300" />
</set>
View 动画是对View 的影像做操作,并不能真正改变View 的位置参数,包括宽高。如果对一个Button 进行上述的平移动画操作,当Button 平移300 像素停留在当前位置时,点击这个Button 并不会触发点击事件,但是点击原来的位置就会触发点击事件。Android 3.0 的属性动画解决了上述问题,因为它不仅仅可以执行动画,还能够改变View 的位置参数:
ObjectAnimator.ofFloat(button, "translationX", 0, 300).setDuration(1000).start();
在Android 3.0 以下的手机可以使用开源动画库nineolddandroids 来实现属性动画,其本质上仍然是View 动画。
5 scrollTo/scrollBy
为了实现View 的内容滑动,View 提供了专门的方法来实现这个功能,就是scrollTo 和scrollBy :
public class View implements Drawable.Callback, KeyEvent.Callback, AccessibilityEventSource {
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;
invalidateParentCaches();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
postInvalidateOnAnimation();
}
}
}
}
scrollBy 最终也是要调用scrollTo 的。scrollTo(x, y) 表示移动到一个具体的坐标点,而scrollBy(dx, dy) 则表示移动的增量dx 、dy 。ScrollBy 实现了基于当前位置的相对滑动,而scrollTo 则实现了基于所传递参数的绝对滑动。
scrollTo 、scrollBy 移动的是View 的内容而不是View 本身,如果是在ViewGroup 中使用,则是移动其所有的子View 。scrollTo 和scrollBy 并且不影响内部元素的单击事件。
在滑动过程中View 内部有两个属性mScrollX 和mScrollY ,它们可以通过getScrollX() 和getScrollY() 方法分别得到。在滑动过程中,mScrollX 的值总是等于View 左边缘和View 内容左边缘在水平方向上的距离,而mScrollY 的值总是等于View 上边缘和View 内容上边缘在竖直方向上的距离。scrollTo() 和scrollBy() 只能改变View 内容的位置而不能改变View 在布局中的位置。mScrollX 和mScrollY 的单位是像素,从左向右滑时,mScrollX 为负值,反之为正值,从上向下滑时,mScrollY 为负值,反之为正值:
使用scrollTo 和scrollBy 来实现View 的滑动,只能将View 的内容进行移动,并不能将View 本身进行移动,也就是说,不管怎么移动,也不可能将当前的View 滑动到附近View 所在的区域。
6 Scroller 弹性滑动
用于实现View 的弹性滑动。 当使用View 的scrollTo/scrollBy 方法来进行滑动时,其过程是瞬间完成的,没有过渡效果的滑动用户体验不好。 使用Scroller 来实现有过渡效果的滑动,在一定的时间间隔内完成。Scroller 本身无法让View 弹性滑动,需要和View 的computeScroll 方法配合才可以完成这个功能:
public class CustomView extends View {
Scroller mScroller;
public CustomView(@NonNull @NotNull Context context) {
this(context, null);
}
public CustomView(@NonNull @NotNull Context context, @Nullable @org.jetbrains.annotations.Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public CustomView(@NonNull @NotNull Context context, @Nullable @org.jetbrains.annotations.Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mScroller = new Scroller(context);
}
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) {
((View) getParent()).scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
invalidate();
}
}
public void smoothScrollTo(int destX, int destY) {
int scrollX = getScrollX();
int delta = destX - scrollX;
mScroller.startScroll(scrollX, 0, delta, 0, 2000);
invalidate();
}
}
CustomView mCustomView = findViewById(R.id.custom_view);
mCustomView.smoothScrollTo(-400, 0);
以下分析Scroller 的源码。
要想使用Scroller ,必须先调用new Scroller() 。以下是Scroller 的构造方法:
public class Scroller {
public Scroller(Context context) {
this(context, null);
}
public Scroller(Context context, Interpolator interpolator) {
this(context, interpolator,
context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.HONEYCOMB);
}
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);
}
}
Scroller 有三个构造方法,通常情况下都是使用第一个;第二个需要传入一个插值器Interpolator ,如果不传则采用默认的插值器ViscousFluidInterpolator 。
interpolator [in't?:p?uleit?] 插入器;内插程序 viscous [?v?sk?s] 粘性的;黏的
fluid [?flu??d] 液体的,流体的;易变的,不稳定的
接下来看Scroller.startScroll() 方法:
public class Scroller {
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;
}
}
在startScroll() 方法中并没有调用类似开启滑动的方法,而是保存了传进来的各种参数。 所以,startScroll() 方法只是用来做前期准备的,并不能使用View 进行滑动。关键的是在startScroll() 方法后调用了invalidate() 方法,这个方法会导致View 重绘,而View 重绘会调用View 的draw() 方法,draw() 方法又会调用View.computeScroll() 方法。
在自定义View 中重写computeScroll() 方法:
public class CustomView extends View {
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) {
((View) getParent()).scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
invalidate();
}
}
}
在computeScroll() 方法中通过Scroller 来获取当前的ScrollX 和ScrollY ,然后调用scrollTo() 方法进行View 的滑动,接着调用invalidate 方法来让View 进行重绘,重绘就会调用computeScroll() 方法来实现View 的滑动,这样通过不断的移动一个小的距离并连贯起来实现平滑移动的效果。
但是在Scroller 中如何获取当前位置的scrollX 和scrollY 呢?那就是在调用scrollTo() 方法前会调用Scroller 的computeScrollOffset() 方法:
public class Scroller {
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));
mCurrX = Math.min(mCurrX, mMaxX);
mCurrX = Math.max(mCurrX, mMinX);
mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
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;
}
}
Scroller 的原理:Scroller 并不能直接实现View 的滑动,它需要配合View 的computeScroll() 方法。在computeScroll() 中不断让View 进行重绘,每次重绘都会计算滑动持续的时间,根据这个持续时间就能算出这次View 滑动的位置,根据每次滑动的位置调用scrollTo() 方法进行滑动,这样不断地重复上述过程就形成了弹性滑动。
|