先来瞅一眼看完本篇后最终要实现的效果:ScrollerTextView。普通的TextView是不具备滑动功能的,当文字的长度超出了TextView的宽高范围后就会被截断,所以我通过Scroller与View的scrollTo、scrollBy方法结合实现了一个可以跟随手指移动,并且带有fling效果的TextView。
1. startScroll() 源码解析
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
//设置当前的模式为 SCROLL
mMode = SCROLL_MODE;
//整个滑动的过程重置
mFinished = false;
//滑动的间隔
mDuration = duration;
//从调用这个方法的那一时刻起,记录下当前的时间,这个参数很重要,是后续动态计算滑动距离的关键
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
mFinalX = startX + dx;
mFinalY = startY + dy;
mDeltaX = dx;
mDeltaY = dy;
//这个参数的含义是:整个滑动时间的倒数。例如,整个滑动时间是500ms,那么这个值就是 1/500
//整个滑动过程分为500份,每一份的时间是 1/500 ms
mDurationReciprocal = 1.0f / (float) mDuration;
}
startX: 开始滑动时 X 坐标轴的初始坐标
startY: 开始滑动时 Y 坐标轴的初始坐标
dx: 整个滑动过程中,X 方向滑动的距离
dy: 整个滑动过程中,Y 方向滑动的距离
duration:整个滑动过程所消耗的时间
2. computeScrollOffset()源码解析
在 startScroll 方法中,做的都是最简单的赋值操作,所以想要实现复杂的效果还需要搭配 computeScrollOffset 方法。当我们一旦调用了 startScroll 方法后,会记录下当时的时间,后续随着时间的流逝,我们需要计算出某一时刻的 X、Y 坐标的值,就需要再次调用这个方法:
public boolean computeScrollOffset() {
// startScroll 方法中已经将这个值置为false了
if (mFinished) {
return false;
}
// 从调用 startScroll 方法到现在,时间已经流逝了多少
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
// 如果已经流逝的时间还在整个滑动时间的范围内,则计算此时此刻的 X、Y 的值
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
// timePassed * mDurationReciprocal 就是流逝的时间占整个滑动时间的百分比
// mInterpolator 是一个 估值器,类似于属性动画中的估值器。用来控制时间流逝不同阶段的快慢
//最终得到的 x 就是一个小于 1 的百分比
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
// 某一时刻的坐标值 = 开始点的值 + 百分比 乘以 整个滑动的距离
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
break;
// 先暂时省略fling相关的代码
}
//如果流逝的时间已经超过了整个滑动所需的时间了,那么就认为此次滑动结束,并将X、Y的坐标设置为最终值
else {
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
}
return true;
}
3.逃不开的scrollTo、scrollBy
刚一接触这两个方法的时候觉得很简单,如果想在 Y 轴上将内容随手指向下滚动100像素,那就直接调用 scrollTo(0, 100) , 但是真的是这样吗?结果可能会与你想要的效果恰恰相反。
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()) {
invalidate();
}
}
}
View 中有两个属性: mScrollX 和 mScrollY , 可见当调用 scrollTo 方法后,实际就是将新的 x、y 的值赋值给 mScrollX 和 mScrollY ,然后调用 invalidate 引起view重新绘制,这样就产生了滑动的效果。所以实际上引起界面滑动的真正原因就在于 mScrollX 和 mScrollY,原因其实就在于 View 的 draw 方法中,在绘制内容的时候,考虑了 mScrollX 和 mScrollY :
public void invalidate(int l, int t, int r, int b) {
final int scrollX = mScrollX;
final int scrollY = mScrollY;
//这句代码至关重要
invalidateInternal(l - scrollX, t - scrollY, r - scrollX, b - scrollY, true, false);
}
在引起View的重新绘制后,View在draw方法中会一步步最终再次调用到上边这个方法,最后一行代码: l - scrollX, t - scrollY, r - scrollX, b - scrollY 就是根据 mScrollX 和 mScrollY 重新确定 View 的边界,由于这里上、下、左、右都是减法,所以如果我们调用 scrollTo(0, 100),view在重新绘制的时候, left 和 right 的值不变, 而 top 和 bottom 的值都减小了 100像素,根据View坐标系这个时候View的位置是在 Y 轴方向整体向上移动了!!!!而并不是我们所想的向下移动!!!!
4.fling源码解析
直接看 fling() 的源码吧:
public void fling(int startX, int startY, int velocityX, int velocityY,
int minX, int maxX, int minY, int maxY) {
// 省略无关紧要的代码
mMode = FLING_MODE;
mFinished = false;
//勾股定理,根据X Y方向计算出斜边的速度
float velocity = (float) Math.hypot(velocityX, velocityY);
mVelocity = velocity;
//根据速度计算出此次完整的fling需要的时间
mDuration = getSplineFlingDuration(velocity);
//记录下当前时间
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
float coeffX = velocity == 0 ? 1.0f : velocityX / velocity;
float coeffY = velocity == 0 ? 1.0f : velocityY / velocity;
double totalDistance = getSplineFlingDistance(velocity);
mDistance = (int) (totalDistance * Math.signum(velocity));
mMinX = minX;
mMaxX = maxX;
mMinY = minY;
mMaxY = maxY;
// 计算出X方向最终的坐标
mFinalX = startX + (int) Math.round(totalDistance * coeffX);
// Pin to mMinX <= mFinalX <= mMaxX
mFinalX = Math.min(mFinalX, mMaxX);
mFinalX = Math.max(mFinalX, mMinX);
//计算出Y方向最终的坐标
mFinalY = startY + (int) Math.round(totalDistance * coeffY);
// Pin to mMinY <= mFinalY <= mMaxY
mFinalY = Math.min(mFinalY, mMaxY);
mFinalY = Math.max(mFinalY, mMinY);
}
参数解释:
startX:开始滑动点的X坐标
startY:开始滑动点的Y坐标
velocityX:X方向上的初始速度
velocityY:Y方向上的初始速度
minX:当fling结束后,view滑动到X方向上的最小坐标
maxX:当fling结束后,view滑动到X方向上的最大坐标
minY:当fling结束后,view滑动到Y方向上的最小坐标
maxY:当fling结束后,view滑动到Y方向上的最小坐标
fling方法和startScroll方法原理是一样的,内部只是一些赋值操作,记录下初始状态的数据。后续随着时间的流逝,我们需要计算出某一时刻的 X、Y 坐标的值,就需要再次调用这个方法:
public boolean computeScrollOffset() {
if (mFinished) {
return false;
}
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
if (timePassed < mDuration) {
switch (mMode) {
//现在是 FLING_MODE 模式了!!!
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;
}
当 mMode 变为 FLING_MODE 的时候,这时再调用 computeScrollOffset 方法就是计算fling过程中某一个时刻的 X Y 坐标了。里边用了一堆数学上的算法,随着时间的流逝,根据初始速度,计算出某一时刻的X Y 坐标。这里就不分析了(因为我也没仔细看2333)。总之不管是 startScroll 还是 fling , computeScrollOffset 的究极奥义都是根据当前流逝的时间以及一些初始值,计算出某一时刻下的 X Y 的坐标值。
5.实现开头的ScrollerTextView
5.1 实现文字跟随手指上下移动
这个比较简单直接重写 onTouchEvent 方法:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
float dy = mLastY - event.getY();
scrollBy(0, (int) dy);
mLastY = event.getY();
break;
}
return true;
}
首先,在 ACTION_DOWN 手机按下的时候,记录下此时的 Y 坐标为 mLastY,当手指移动的时候,用上一次记录的 mLastY - 当前的Y坐标,调用scrollBy滚动内容并且更新 mLastY 的坐标。这块比较难理解的是为什么是: mLastY - 当前的Y坐标 ,而不是 当前的Y坐标 - mLastY ?。试想下边一种情况:在初始状态没有滚动的情况下,ScrollerTextView 的属性值 mScrollY = 0 ,手指从上向下移动,文字也应该向下滚动。这时mLastY - 当前的Y坐标 得到的值 dy 一定是负值,然后把这个dy传入scrollBy方法中从而使: mScrollY = 0 - dy,mScrollY 也从0 变为了一个负值,根据本文第三章所讲当ScrollerTextView重新绘制的时候,由于 减法的存在 此时ScrollerTextView 的 top 和 bottom 的值都变大了!!!!而上下边框都变大了也就导致视图在视觉上向下移动了,从而也就达到了我们的目的。
5.2 实现fling效果
fling效果只是在 手指抬起的一瞬间触发,所以我们只处理 Touch 事件中的 ACTION_UP 就ok了
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
switch (event.getAction()) {
case MotionEvent.ACTION_UP:
mVelocityTracker.computeCurrentVelocity(1000, mMaxV);
//手指抬起的时候,计算此时的Y方向的速度
int vy = (int) mVelocityTracker.getYVelocity();
if (Math.abs(vy) > mMinV) {
mInitFlingY = getScrollY();
mScroller.fling(0, getScrollY(), 0, vy, 0, 0, -Integer.MAX_VALUE, Integer.MAX_VALUE);
invalidate();
}
mVelocityTracker.recycle();
mVelocityTracker = null;
break;
}
return true;
}
首先在 onTouchEvent 事件的顶部将所有事件都加入到速度跟踪器中,在手指抬起的时候计算当前的速度。这里定义了触发 fling 操作的最大和最小速度,只有在这个范围内滑动效果才最好。并且使用 getScrollY() 作为此次fling的初始Y坐标。并且记录下开始fling的Y坐标mInitFlingY。然后调用invalidate方法立刻触发View的重新绘制。
在速度这块:
当手指从上向下滑动时,得到的Y方向的速度为 正值,随着时间的流逝当执行scroller.computeScrollOffset方法计算最新的坐标时,新的坐标会在 startY 的基础上逐渐增大,但是不会超过 maxY(startY的值可能是正值、负值、0)。
当手指从下向上滑动时, 得到的Y方向的速度为负值。随着时间的流逝当执行scroller.computeScrollOffset方法计算最新的坐标时,新的坐标会在 startY 的基础上逐渐减小,但是不会小于 minY(startY的值可能是正值、负值、0)。
当调用了invalidate方法后,会触发View的重新绘制,View在重绘的过程中会回调 computeScroll 方法,默认这个方法是一个空实现,所以我们就可以利用这个方法,在这个方法中调用scroller.computeScrollOffset计算新的坐标:
@Override
public void computeScroll() {
//计算当前时刻最新的坐标值,如果整个过程还没有结束,会返回true
if (mScroller.computeScrollOffset()) {
//获取当前最新的Y坐标
int currY = mScroller.getCurrY();
//用上一个时刻的Y坐标减去当前时刻的Y坐标得到一个差值
int diff = mInitFlingY - currY;
//滚动内容
scrollBy(0, diff);
//再次触发invalidate使View不断重绘,也就实现了在fling的整个过程中,随着时间的流逝
//View 不断的回调computeScroll方法 -> 获取新的坐标 -> 滚动内容 -> 再次出发重绘
//这样在视觉上就出现了fling的效果
postInvalidate();
mInitFlingY = mScroller.getCurrY();
}
}
源码在这里:https://github.com/haoxinlei1994/studyPro
ok!大功告成!!