不再迷惑,也许之前你从未真正懂得 Scroller 及滑动机制

1,436 阅读35分钟

学习本来就是从困惑中摸索问题答案的过程,能够描述出来问题就已经成功了一半。只要发现了困扰你的东西是什么,那么你就离解答出来不远了。————肯尼斯 R. 莱伯德

一直以来,Android 开发中绕不过去的话题就是自定义 View,曾几何时,考验一个开发者能不能熟悉自定义 View 的基础流程作为分辨菜鸟和中级开发者的一个技术标准。但是自定义 View 本身而言,应对各种具体的需求,难度又不一样,这是因为牵扯到了各种各样的技术点。本文要讲解的一个技术点,正是广大开发者容易困惑的一个知识点————Scroller。为什么说它是一个容易让人困惑的内容呢?这是因为很多开发者勉强接受了许多书本或者是博客上直接给予的概念说明,而对于 View 中 scroll 本身思考的过少。每次顺着别人的博文来看,好像已经弄懂了。知道了怎么设置参数如 mScrollX、怎么样创建 Scroller 对象然后调用相应的 API。可是呢?当脱离博文涉及的事例而处理自己工作当中真实面对的场景,往往出现的情况是不能很好地实现既定的效果,这个时候会发现自己并没有真的理解它,所以没有办法举重若轻地将思维迁移到崭新的问题上面。各位读者,请回想下自己是否有过这种体会否则说曾经是否有过这种体会?如果有的话,我们接下来将开启一段解惑之旅。

阅读本文,你会有如下收获:

  1. 真正理解 View 滑动的机制。
  2. 能够正确编写自定义 View 滚动的代码。
  3. 能够理解并且正确使用 Scroller 这个类,并且利用它编写滚动和快速滚动效果的代码。

滚动的本质

我们在 Android 世界能够直接接触到最为直观的滚动就是 ScrollView 和 RecyclerView 的视图滚动了。
这里写图片描述
上面是一个 ScrollView,它包含了一个 TextView,但是 ScrollView 本身高度固定为 200 dp,TextView 中文字部分显然是一次性不能够完全显示出来的,所以 Android 才提供了滚动机制,正因为有了滚动,内容才能够在有限的空间被延伸,这是一种很棒的交互体验。

所以这种情况下,我们可以这样归纳:ScrollView 滚动针对的是内容,记住是内容。因为 ScrollView 和 TextView 本身尺寸和位置并没有发生变化,只是文本的显示区域进行了位移

我们再来看看另外一种情况。
这里写图片描述
上图是一个 RecyclerView,很明显,它的子 View 也无法在一屏的空间完全展示出来,所以借助于滚动机制 RecyclerView 中的所有内容才能够完整展示出来。

所以这种情况下,我们可以这样归纳:RecyclerVIew 滚动针对的是它的子 View。RecyclerView 本身尺寸和位置并没有发生变化,只是子 View 的显示区域进行了位移

综合上面两种情况,我们可以给出一个结论:

对于一个 View 或者 ViewGroup 而言,滚动针对的是它的内容,View 的内容体现在它要绘制的内容上面,ViewGroup 的内容相当于它的所有子 View。

这一节的目的在于让我们意识到,滚动与内容之间的联系。认识到这一点对于我们深刻理解滚动本身是非常重要的。

mScrollX 和 mScrollY 你真的重视过吗?

任何介绍 Scroller 与滚动的博文都会提到 View.java 中的这两个变量,这本无可厚非的,是的,它们很重要,重要到可以称为关键一环。但是,很可惜的是,很少有博文能够对它们引起足够重视。它们更关注的是后续的动作比如 scrollBy() 或者 scrollTo() 等等,因为那样会更直观。

我们先来看一看它们的定义: View.java

/**
 * The offset, in pixels, by which the content of this view is scrolled
 * horizontally.
 * {@hide}
 */
@ViewDebug.ExportedProperty(category = "scrolling")
protected int mScrollX;

/**
 * The offset, in pixels, by which the content of this view is scrolled
 * vertically.
 * {@hide}
 */
@ViewDebug.ExportedProperty(category = "scrolling")
protected int mScrollY;

如注释的解释,也如前一节我们对 ScrollView 和 RecyclerView 现象的观察推断,mScrollX 和 mScrollY 确实是 View 中内容的水平和垂直方向上的偏移量。注意的是 mScrollX 和 mScrollY 被 @hide 修饰,说明它们只是在 Android 系统源码的范围内可见,并没有暴露在 SDK 中,获取或者设置它们需要通过对应的 API。

public final int getScrollX() {return mScrollX;}

public final int getScrollY() {return mScrollY;}

public void setScrollX(int value) {scrollTo(value, mScrollY);}

public void setScrollY(int value) {scrollTo(mScrollX, value);}   


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(),而归根到底,如果要设置 mScrollX 或者 mScrollY,最终调用的还是 scrollTo()

scrollBy() 和 scrollTo() 的区别

scrollBy() 间接调用 scrollTo(),只是它是在当前滚动的基础上再进行偏移。这个下面的内容我会再细讲。我们接着说 mScrollX 与 mScrollY 相关。

原点在哪里?

这一部分,我们的主题仍然是 mScrollX 与 mScrollY。但是,在这之前我们不妨先暂时放下 mScrollX 与 mScrollY 的关注,我们来思考一些东西。我正在阅读的书籍《学习之道》称这为专注思维与发散思维的切换,我们关注直接目标的时候,大脑运用的是专注思维,它能让我们专注一些东西,让我们视线聚焦在具体某一点上,所以会更高效处理一些常见的问题。但是所有的解答都不会一蹴而就,专注思维带来的局限就是它的视野过于狭窄,它的优势在于能够快速解决已知的问题,更适用于经验。而发散思维没有那么专注,常常在一些看似无关的点子上尝试建立联系,它的优势是视野广,常常带来灵感。大家想想,你们经常的灵感发生时刻是在你专注求解的过程,还是在坐车、洗澡、干家务等无关的场景中突然恍然大悟的呢?当然,这两种思维模式不能说谁好谁坏,需要配合使用,有兴趣的同学可以去看看这本书。嗯,回到主题中心。我们暂时将目光从 mScrollX 身上挪开,来做一次看似无关的实验。

这个实验是什么呢?

观察原点。

我们知道,一个 View 有自己的坐标体系,它的原点自然就是 (0,0)。那好,现在,我们自定义一个 View,为了便于识别它的背景颜色是灰色,但是,在它原点的地方绘制一个圆,颜色是红色,为了便于观察,半径取值 40 dp。

public class TestView extends View {
    private static final String TAG = "TestView";

    Paint mPaint;

    public TestView(Context context) {
        this(context,null);
    }

    public TestView(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public TestView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.RED);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        canvas.drawColor(Color.GRAY);

        canvas.drawCircle(0,0,40.0f,mPaint);
    }
}

然后,我们把它放在一个 RelativeLayout 中居中显示,宽高尺寸都为 200 dp。
这里写图片描述
它表现正常。接下来就是有意思的事情了。我们要改变 TestView 中的 mScrollX 和 mScrollY。我们在屏幕上另外设置一个 Button,每次 Button 点击时让 TestView 在当前位置基础上滚动,前面讲过可以调用它的 scrollBy() 方法。

mTestView = (TestView) findViewById(R.id.testView);
mBtnTest = (Button) findViewById(R.id.btn_test);
mBtnTest.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        mTestView.scrollBy(-1*10,-1*10);
    }
});

这里写图片描述

这真是一件很悬乎的事情,我们并没有在程序过程中动态改变 TestView 中 onDraw() 中的代码,只是每次 Button 点击时,将 TestView 在原有的基础上滚动了 (-10,-10),在 onDraw() 中,canvas 永远是安分守纪地在它认为原点的地方也就是坐标(0,0)的地方绘制一个半径为 40 dp 的实心圆。但从结果上来说,这个原点却并不是在 TestView 的左上角,或者说 scrollBy() 方法改变了 TestView 的坐标系。

发生了什么?谁欺骗了 Canvas ?

对于上面的结果,我想到了一个词————欺骗,或者说蒙蔽也可以。TestView 在 onDraw() 中拿到的 canvas 明显已经经过了某种变化,导致它的坐标系与真实的坐标系产生了差异,TestView 认为在 (0,0)坐标原点绘制一个圆形就好了,可实际上视觉效果相去甚远。那究竟发生了什么?

让我们发散一下,说到对于 canvas 的变换,我们会想到什么?相信大家会很快想到 translate、scale、skew 这些操作。与本次主题关联性最大的也就是 translate 了。好了,我们就说它。

提一个问题,如果我们要在坐标 (100,100) 的位置绘制一个圆,在坐标(150,150)也绘制一个圆,请问怎么实现? 我相信大家会很快地写出代码。

protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.GRAY);

    canvas.drawCircle(100,100,40.0f,mPaint);
    canvas.drawCircle(150,150,40.0f,mPaint);
}

这里写图片描述
但是,我相信另外有一部分人会这样进行编码。

protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.GRAY);

    canvas.save();
    canvas.translate(100,100);

    canvas.drawCircle(0,0,40.0f,mPaint);
    canvas.drawCircle(50,50,40.0f,mPaint);

    canvas.restore();
}

代码有差别吗?有。什么差别?后面一种运用 translate 平移操作。它绘制圆的时候坐标不是针对 (100,100)而是(0,0),它认为它是在(0,0)绘制了一个圆,而实际上的效果是它在(100,100)的地方绘制了一个圆。大家有没有体会到这种感觉?前面一节的时候,TestView 的 onDraw() 方法根本就没有改变,但是 mScrollX 和 mScrollY 的改变,导致它的 Canvas 坐标体系已经发生了改变,经过刚才的示例,我们可以肯定地说,在一个 View 的 onDraw() 方法之前,一定某些地方对 canvas 进行了 translate 平移操作。

谁平移了 Canvas ?

既然一个 View 中 onDraw() 方法获取到的 Canvas 已经经过了坐标系的变换,那么如果要追踪下去,肯定就是要调查 View.onDraw() 方法被谁调用。这个时候就需要阅读 View.java 或者其它类的源码了,好在 AndroidStudio 能够直接查阅。


/**
* Implement this to do your drawing.
*
* @param canvas the canvas on which the background will be drawn
*/
protected void onDraw(Canvas canvas) {
}

在 View 中一个 onDraw() 是空方法,需要子类如 TestView 自己实现。 而 onDraw() 方法,主要是在 ViewGroup 中的 drawChild() 和 View 自身的 draw() 方法调用。

View 由它的 ViewGroup 绘制,这个也自然能够理解。因为 ViewGroup 本身也就是一个 View,所以我们先从 draw() 方法分析,然后再针对 ViewGroup 单独分析。

public void draw(Canvas canvas) {
    final int privateFlags = mPrivateFlags;
    final boolean dirtyOpaque = (privateFlags & PFLAG_DIRTY_MASK) == PFLAG_DIRTY_OPAQUE &&
            (mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState);
    mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;

    /*
     * Draw traversal performs several drawing steps which must be executed
     * in the appropriate order:
     *
     *      1. Draw the background
     *      2. If necessary, save the canvas' layers to prepare for fading
     *      3. Draw view's content
     *      4. Draw children
     *      5. If necessary, draw the fading edges and restore layers
     *      6. Draw decorations (scrollbars for instance)
     */

    // Step 1, draw the background, if needed
    int saveCount;

    if (!dirtyOpaque) {
        drawBackground(canvas);
    }

    // skip step 2 & 5 if possible (common case)
    final int viewFlags = mViewFlags;
    boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
    boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
    if (!verticalEdges && !horizontalEdges) {
        // Step 3, draw the content
        if (!dirtyOpaque) onDraw(canvas);

        // Step 4, draw the children
        dispatchDraw(canvas);

        // Overlay is part of the content and draws beneath Foreground
        if (mOverlay != null && !mOverlay.isEmpty()) {
            mOverlay.getOverlayView().dispatchDraw(canvas);
        }

        // Step 6, draw decorations (foreground, scrollbars)
        onDrawForeground(canvas);

        // we're done...
        return;
    }

    ......
}

代码有删简,去掉了 Fading 边缘效果的处理代码。不过,我们仍然可以得到一些很重要的信息,其中包括一个 View 的绘制流程。代码注释中写的很详细。

View 绘制流程
1. 绘制背景
2. 绘制内容
3. 绘制 children
4. 如果有需要,绘制渐隐(fading) 效果
5. 绘制装饰物 (scrollbars)

大家可能会注意到 dirtyOpaque 这个变量,它代表的是一个 View 是否是实心的,如果不是实心的就要绘制 background,否则就不需要。

private void drawBackground(Canvas canvas) {
    final Drawable background = mBackground;
    if (background == null) {
        return;
    }

    setBackgroundBounds();

    // Attempt to use a display list if requested.
    if (canvas.isHardwareAccelerated() && mAttachInfo != null
            && mAttachInfo.mHardwareRenderer != null) {
        mBackgroundRenderNode = getDrawableRenderNode(background, mBackgroundRenderNode);

        final RenderNode renderNode = mBackgroundRenderNode;
        if (renderNode != null && renderNode.isValid()) {
            setBackgroundRenderNodeProperties(renderNode);
            ((DisplayListCanvas) canvas).drawRenderNode(renderNode);
            return;
        }
    }

    final int scrollX = mScrollX;
    final int scrollY = mScrollY;
    if ((scrollX | scrollY) == 0) {
        background.draw(canvas);
    } else {
        canvas.translate(scrollX, scrollY);
        background.draw(canvas);
        canvas.translate(-scrollX, -scrollY);
    }
}

在这里面,倒是看到了 canvas.translate(scrollX, scrollY),但是绘制了背景之后它又立马平移回去了。这里有些莫名其妙。但是,它不是我们的目标,我们的目标是 view.onDraw()。

在 draw() ()方法中,我们并没有找到线索。那么,我们注意到这个方法中来————dispatchDraw(),注释说它是绘制 children,那么显然它是属于 ViewGroup 中的方法。 ViewGroup.java

protected void dispatchDraw(Canvas canvas) {
    boolean usingRenderNodeProperties = canvas.isRecordingFor(mRenderNode);
    final int childrenCount = mChildrenCount;
    final View[] children = mChildren;
    int flags = mGroupFlags;



    int clipSaveCount = 0;


    // We will draw our child's animation, let's reset the flag
    mPrivateFlags &= ~PFLAG_DRAW_ANIMATION;
    mGroupFlags &= ~FLAG_INVALIDATE_REQUIRED;

    boolean more = false;
    final long drawingTime = getDrawingTime();


    final int transientCount = mTransientIndices == null ? 0 : mTransientIndices.size();
    int transientIndex = transientCount != 0 ? 0 : -1;
    // Only use the preordered list if not HW accelerated, since the HW pipeline will do the
    // draw reordering internally
    final ArrayList<View> preorderedList = usingRenderNodeProperties
            ? null : buildOrderedChildList();
    final boolean customOrder = preorderedList == null
            && isChildrenDrawingOrderEnabled();
    for (int i = 0; i < childrenCount; i++) {
        while (transientIndex >= 0 && mTransientIndices.get(transientIndex) == i) {
            final View transientChild = mTransientViews.get(transientIndex);
            if ((transientChild.mViewFlags & VISIBILITY_MASK) == VISIBLE ||
                    transientChild.getAnimation() != null) {
                more |= drawChild(canvas, transientChild, drawingTime);
            }
            transientIndex++;
            if (transientIndex >= transientCount) {
                transientIndex = -1;
            }
        }

        final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
        final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);
        if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {
            more |= drawChild(canvas, child, drawingTime);
        }
    }
    while (transientIndex >= 0) {
        // there may be additional transient views after the normal views
        final View transientChild = mTransientViews.get(transientIndex);
        if ((transientChild.mViewFlags & VISIBILITY_MASK) == VISIBLE ||
                transientChild.getAnimation() != null) {
            more |= drawChild(canvas, transientChild, drawingTime);
        }
        transientIndex++;
        if (transientIndex >= transientCount) {
            break;
        }
    }



    // mGroupFlags might have been updated by drawChild()
    flags = mGroupFlags;

    if ((flags & FLAG_INVALIDATE_REQUIRED) == FLAG_INVALIDATE_REQUIRED) {
        invalidate(true);
    }


}

我们注意到 drawChild() 这个方法。 ViewGroup.java

protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
        return child.draw(canvas, this, drawingTime);
}

这里引出了 View.draw(Canvas canvas, ViewGroup parent, long drawingTime) 方法,这个方法不同于 View.draw(Canvas canvas)。

/**
* This method is called by ViewGroup.drawChild() to have each child view draw itself.
*
* This is where the View specializes rendering behavior based on layer type,
* and hardware acceleration.
*/
boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
    final boolean hardwareAcceleratedCanvas = canvas.isHardwareAccelerated();
    /* If an attached view draws to a HW canvas, it may use its RenderNode + DisplayList.
    *
    * If a view is dettached, its DisplayList shouldn't exist. If the canvas isn't
    * HW accelerated, it can't handle drawing RenderNodes.
    */
    boolean drawingWithRenderNode = mAttachInfo != null
        && mAttachInfo.mHardwareAccelerated
        && hardwareAcceleratedCanvas;

    boolean more = false;

    final int parentFlags = parent.mGroupFlags;


    Transformation transformToApply = null;
    boolean concatMatrix = false;
    final boolean scalingRequired = mAttachInfo != null && mAttachInfo.mScalingRequired;


    // Sets the flag as early as possible to allow draw() implementations
    // to call invalidate() successfully when doing animations
    mPrivateFlags |= PFLAG_DRAWN;



    int sx = 0;
    int sy = 0;
    if (!drawingWithRenderNode) {
        computeScroll();
        sx = mScrollX;
        sy = mScrollY;
    }

    final boolean drawingWithDrawingCache = cache != null && !drawingWithRenderNode;
    final boolean offsetForScroll = cache == null && !drawingWithRenderNode;

    int restoreTo = -1;
    if (!drawingWithRenderNode || transformToApply != null) {
        restoreTo = canvas.save();
    }
    if (offsetForScroll) {
        canvas.translate(mLeft - sx, mTop - sy);
    } else {
        if (!drawingWithRenderNode) {
            canvas.translate(mLeft, mTop);
        }
        if (scalingRequired) {
            if (drawingWithRenderNode) {
                // TODO: Might not need this if we put everything inside the DL
                restoreTo = canvas.save();
            }
            // mAttachInfo cannot be null, otherwise scalingRequired == false
            final float scale = 1.0f / mAttachInfo.mApplicationScale;
            canvas.scale(scale, scale);
        }
    }




    if (!drawingWithRenderNode) {
    // apply clips directly, since RenderNode won't do it for this draw
    if ((parentFlags & ViewGroup.FLAG_CLIP_CHILDREN) != 0 && cache == null) {
        if (offsetForScroll) {
            canvas.clipRect(sx, sy, sx + getWidth(), sy + getHeight());
        } else {
            if (!scalingRequired || cache == null) {
                canvas.clipRect(0, 0, getWidth(), getHeight());
            } else {
                canvas.clipRect(0, 0, cache.getWidth(), cache.getHeight());
            }
        }
    }

    if (mClipBounds != null) {
        // clip bounds ignore scroll
        canvas.clipRect(mClipBounds);
    }
    }

    if (!drawingWithDrawingCache) {
        if (drawingWithRenderNode) {

        } else {
            // Fast path for layouts with no backgrounds
            if ((mPrivateFlags & PFLAG_SKIP_DRAW) == PFLAG_SKIP_DRAW) {
                mPrivateFlags &= ~PFLAG_DIRTY_MASK;
                dispatchDraw(canvas);
            } else {
                // 在这里调用 draw() 单参数方法。
                draw(canvas);
            }
        }
    } else if (cache != null) {

    } else {


    }

    if (restoreTo >= 0) {
        canvas.restoreToCount(restoreTo);
    }


    return more;
}

原本的代码很长,并且涉及到软件绘制和硬件绘制两种不同的流程。为了便于学习,现在剔除了硬件加速绘制流程和一些矩阵变换的代码。

drawingWithRenderNode 变量代表的就是是否要执行硬件加速绘制。

代码运行中,先会调用 computeScroll() 方法,然后将 mScrollX 和 mScrollY 赋值给变量 sx 和 sy 变量。

/**
 * Called by a parent to request that a child update its values for mScrollX
 * and mScrollY if necessary. This will typically be done if the child is
 * animating a scroll using a {@link android.widget.Scroller Scroller}
 * object.
 */
public void computeScroll() {
}

在 View 中 computeScroll() 是一个空方法,但注释说的很明白,这个方法是用来更新 mScrollX 和 mScrollY 的。典型用法就是一个 View 通过 Scroller 进行滚动动画(animating a scroll)时在这里更新 mScrollX 和 mScrollY。 computeScroll() 是一个比较重要的方法,但是一般要与 Scroller 这个类的对象配合使用,所以我们留到后面讲。

接下来就是最关键的一环了。

final boolean drawingWithDrawingCache = cache != null && !drawingWithRenderNode;
final boolean offsetForScroll = cache == null && !drawingWithRenderNode;

int restoreTo = -1;
if (!drawingWithRenderNode || transformToApply != null) {
    restoreTo = canvas.save();
}
if (offsetForScroll) {
    canvas.translate(mLeft - sx, mTop - sy);
} else {
    if (!drawingWithRenderNode) {
        canvas.translate(mLeft, mTop);
    }

}

由于我们研究的目标不是说 View 的绘制是通过之前的缓存绘制,而是全新的绘制,所以 cache == null,offsetForScroll = true。那么,程序就会执行下面这段代码:

canvas.translate(mLeft - sx, mTop - sy);

我们苦苦追寻的答案终于来临,canvas 确实平移了。好,我们继续向下。

if ((mPrivateFlags & PFLAG_SKIP_DRAW) == PFLAG_SKIP_DRAW) {
                mPrivateFlags &= ~PFLAG_DIRTY_MASK;
    dispatchDraw(canvas);
} else {
    // 在这里调用 draw() 单参数方法。
    draw(canvas);
}

最后的地方调用了 draw(canvas),而 draw(canvas) 中调用了开发者常见的 onDraw(canvas)。

我们之前有提问过,谁欺骗了 onDraw() 方法,谁在它之前平移了 canvas ?现在有了答案的。

我们再看 canvas 平衡细节。

canvas.translate(mLeft - sx, mTop - sy);

sx 与 sy 等同于 mScrollX 和 mScrollY,这里又牵扯到 mLeft 和 mTop 两个属性。

/**
 * The distance in pixels from the left edge of this view's parent
 * to the left edge of this view.
 * {@hide}
 */
@ViewDebug.ExportedProperty(category = "layout")
protected int mLeft;


/**
* The distance in pixels from the top edge of this view's parent
* to the top edge of this view.
* {@hide}
*/
@ViewDebug.ExportedProperty(category = "layout")
protected int mTop;

mLeft 是 View 距离 parent 的左边间距,mTop 是上边间距。

这里写图片描述

上面的图片指示了 View 中 mLeft、mTop 与 parent 的关系。注意上面演示的情况是 mScrollX 与 mScrollY 都为 0 。如果它们不为 0 会怎样呢?
这里写图片描述
上图中绿色的区域,代表 View 中内容的区域,它相对于 (mLeft,mTop)位置进行了偏移,但是背景与内容的显示区域并没有发生偏移,也就是说内容区域虽然偏移了,但是它能够显示的区域也只有在上图中的黑色框线以内。

为什么说背景没有偏移呢?

之前分析 drawBackground() 的时候,canvas 有对坐标进行回正。

那么显示区域为什么也没有偏移呢?因为在 draw(Canvas canvas, ViewGroup parent, long drawingTime) 方法中,也做了相应处理。

if (offsetForScroll) {
    //canvas 之前已经调用了 translate() 方法,平移了 -sx,-sy 的距离,这里调整回来
    canvas.clipRect(sx, sy, sx + getWidth(), sy + getHeight());
}

接下来,我们就来调整 mScrollX 与 mScrollY 的取值,看看 View 中内容的变化。
这里写图片描述

在上面动画中,很直观显示了 mScrollX、mScrollY 与内容区域的位置变化。处理好 mScrollX 与 mScrollY,滑动这一块的知识就完全没有问题了,所以这也是为什么我在文章开头说大多数同学没有真正重视过这两个属性的原因。

之前我们通常是被迫接受了一个结论,mScrollX 为负数时,内容向右滑,反之向左滑。mScrollY 数值为负数时,内容向下滑,反之向上滑。但是,有趣的地方是,我们大多通过手势操作来控制一个 View 的滑动。这个又会引发一起容易让人困惑的事情。

按照习惯,或者说思维定势吧,手指向左滑动,代表我们想翻看右边的内容,但是内容区域是向左偏移的,mScrollX 这个时候数值应该为正。因为一个 View 的显示区间并没有因为滚动而发生偏移,所以内容区域位置的偏移,往往会让人混淆方向,这到底是算向左,还是向右呢?是向上还是向下呢?

如果,你看了这篇文章,我想你已经轻而易举地给出了答案。

如果,还没有整明白的话,没有关系,我送你一句口诀。

一句口诀,记住 mScrollX 与 mScrollY 的数值符号

如果你想翻看前面或者是上面的内容,mScrollX 和 mScrollY 取值为负数,如果想翻看后面或者是下面的内容,mScrollX 和 mScrollY 取值为正。

还是没有明白吗?我大概知道为什么,可能是你混淆了手指滑动方向和内容滑动方向。

手指向左滑动,内容将向右显示,这时 mScrollX > 0。

手指向右滑动,内容将向左显示,这时 mScrollX < 0.

手指向上滑动,内容将向下显示,这时 mScrollY < 0。

手指向下滑动,内容将向上显示,这时 mScrollY > 0.

再谈 scrollBy() 和 scrollTo()

代码已经解释的很清楚了,scrollBy() 在当前 mScrollX 和 mScrollY 的基础上添加偏移量调用了 scrollTo()。这本来没有什么解释的,大家结合上面的分析,自然能够明白个中奥妙,如果还需要说清楚的话,我打个比方好了。

长官在地点 A 通过传声器给两个士兵下达命令。

对士兵甲的命令是:到地点 F 去,士兵甲马上就去了,他用的是 scrollTo(),一步到位。

对士兵乙的命令是:到下一个地点,地点 A 的下一个地点是地点 B,于是士兵乙在当前地点挪到了地点 B,这期间士兵乙本身也运用了 scrollTo(),但是他的目的地是下一站,然后一步到位。 而对于长官而言,它运用的是 scrollBy()。

一个小时后,长官又发了相同的命令。

对士兵甲的命令是:到地点 F 去,士兵甲不需要再行动了,他直接回复,我已经到位。

对士兵乙的命令是:到下一个地点,地点 B 的下一个地点是地点 C,于是士兵乙在当前地点挪到了地点 C,这期间士兵乙本身也运用了 scrollTo(),但是它的目的地是下一站,然后一步到位。 而对于长官而言,它运用的是 scrollBy()。

滚动的前提

通过上面的文章内容,我们知道了,发生滚动时,必须要对 mScrollX 和 mScrollY 进行操作。而 View 在重绘过程中对于 canvas 的平移操作,导致了内容区域的位置变动,从而在视觉上达到了滚动的效果。所以,前提是 mScrollX 和 mScrollY 要被正确的设置。

让滚动更平滑

我们再来看看,文章开头时对于 TestView 的滚动处理。
这里写图片描述

内容确实是滚动了,但是因为是瞬时移动,毫无美感而言。现在,我们要对这个东西进行改良,让它平滑地进行滚动。

怎么做呢?

依照以往的经验我们自然可以想到的是运用属性动画来实现。好吧,我们可以这样写代码了。

public class TestView extends View {
    private static final String TAG = "TestView";
    Paint mPaint;

    public TestView(Context context) {
        this(context,null);
    }

    public TestView(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public TestView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.RED);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        canvas.drawColor(Color.GRAY);

        canvas.save();
        canvas.translate(100,100);
        canvas.drawCircle(0,0,40.0f,mPaint);
        canvas.drawCircle(50,50,40.0f,mPaint);
        canvas.restore();
    }

    public void startGunDong(int dx,int dy) {
        int startX = getScrollX();
        int startY = getScrollY();
        PropertyValuesHolder xholder = PropertyValuesHolder.ofInt("scrollX",startX,startX+dx);
        PropertyValuesHolder yholder = PropertyValuesHolder.ofInt("scrollY",startY,startY+dy);
        ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(this,xholder,yholder);
        animator.setDuration(1000);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                invalidate();
            }
        });
        animator.start();
    }
}

我们再改变 MainActivity 中的测试代码。

mBtnTest.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        mTestView.startGunDong(-1*100,-1*100);
//      mTestView.scrollBy(-1*10,-1*10);
    }
});

效果如下:
这里写图片描述

效果达到了,确实很顺滑。如果我们够用心,还可以在 startGunDong() 方法中,暴露动画时长、动画插值器等参数,这样我们可以更加按照自己的想法来控制滚动时的动画。

因为基于对 mScrollX 与 mScrollY 的认知,我们通过属性动画操作它们值的变化,最终达到了平滑滚动的效果。

这种感觉是不是如沐春风?

不过,我们能够想到的 Android 工程师自然早就想到了,它们提供了另外一个工具类,那就是 Scroller。

Scroller 出场

文章讲到这里的时候,Scroller 才出现,但我相信读者已经对迎接它做好了准备。
这里写图片描述

Scroller 只是一个普通的类,它封装了滚动事件。但是,它只是提供滚动时的数据变化,它本身不控制对于 View 的滚动动画。如何制作的平滑的滚动效果,这个责任在于开发者自己,Scroller 能做的就是提供数值及时间在一个滚动动画周期中的值。所以它只是一个辅助类。

mScrollX、mScrollY 与 Scroller 之间的关系

我们前面已经知道,关系到一个 View 的滚动效果就是 mScrollX 与 mScrollY 的属性变化,我们先前通过属性动画已经很好地处理了这两个值的变化,并且已经达到了比较好的效果。那么 Scroller 作为一个辅助类,它有没有操作 mScrollX 与 mScrollY 呢?

很可惜,没有的。

public class Scroller  {

    private int mStartX;
    private int mStartY;
    private int mFinalX;
    private int mFinalY;

    private int mCurrX;
    private int mCurrY;
    private long mStartTime;
    private int mDuration;
    private float mDurationReciprocal;
    private float mDeltaX;
    private float mDeltaY;


    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) {
        .......
    }


    public final int getDuration() {
        return mDuration;
    }


    public final int getCurrX() {
        return mCurrX;
    }


    public final int getCurrY() {
        return mCurrY;
    }


    public final int getStartX() {
        return mStartX;
    }


    public final int getStartY() {
        return mStartY;
    }


    public final int getFinalX() {
        return mFinalX;
    }


    public final int getFinalY() {
        return mFinalY;
    }

    ......
}

它只是定义了自己的属性,我可以解释如下:

mStartX //滚动开始的 x 坐标
mStartX //滚动开始的 y 坐标
mFinalX //滚动结束时的 x 坐标
mFinalY //滚动结束时的 y 坐标
mCurrentX //当前 x 坐标
mCurrentY //当前 y 坐标

所以,我在这里有一个设想————Scroller 内部也一定有一个属性动画的机制,就如同我在前面博文模拟的一样,它在初始的时候设置好 mStartX 和 mFinalX 之类,然后在动画的过程中不断改变 mCurrent 的值,所以它是一个数值的变化,形如 ValueAnimator 一样。那么,实际情况是不是这样子呢?我们可以查看它的源码,查看产生滚动的地方,Scroller 有个 startScroll() 方法,我们查看它的源码好了。

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;
}

天哪,代码如此简单,它只简单设置了各种坐标参数和动画开始时间,就没有了,甚至一个定时器都没有设定,那么,它的数值变化是怎么实现的呢?官网的文档,让我意识到了还有另外一个比较重要的方法————computeScrollOffset()。

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;
}

上面代码可以得到下面的结论:
1. computeScrollOffset() 方法会返回当前动画的状态,true 代表动画进行中,false 代表动画结束了。
2. 如果动画没有结束,那么每次调用 computeScrollOffset() 方法,它就会更新 mCurrentX 和 mCurrentY 的数值。

但是,翻阅 Scroller 的代码,也没有找到一个定时器,或者是一个属性动画启动的地方,相关联的只有一个插值器。所以,我的猜测就是,如果要让 Scroller 正常运行,就编写下面这样的代码。


Scroller scroller = new Scroller(context);

scroller.startScroll(0,0,100,100);

boolean condition = true;

while ( condition ) {

    if ( scroller.computeScrollOffset() ) {
        ...
    }

    .....
}

实际上,官方文档也是这样建议的。因为 computeScrollOffset() 被不断地调用,所以 Scroller 中的 mCurrentX 和 mCurrentY 被不断地被更新,所以 Scroller 动画就能够跑去起来。但是,Scroller 跑去起来想 View 本身滚动与否没有一丁点关系,我们还需要一些东西,需要什么?

雀桥,你在哪里?

如果把 mScrollX 与 mScrollY 比作牛郎,把 Scroller 比作织女的话,要想相会,雀桥是少不了的。

Scroller 中 mCurrentX、mCurrentY 必须要与 View 中的 mScrollX、mScrollY 建立相应的映射才能够使 View 真正产生滚动的效果,那么就必须要找一个合适的场所,进行这个庄严的仪式,这个场所我称为雀桥。那么,在一个 View 中由谁担任呢?

不知道大家还有没有印象?文章开始的地方,分析到 ViewGroup 的 drawChild() 时,ViewGroup 会调用 View 的 draw() 方法,在这个方法中有这么一段代码。

if (!drawingWithRenderNode) {
    computeScroll();
    sx = mScrollX;
    sy = mScrollY;
}

程序会先调用,computeScroll() 方法,然后再对 sx 和 sy 进行赋值,最终 canvas.translate(mLeft-sx,mTop-sy),导致了 View 本身的滚动效果。

然后在 View.java 中 computeScroll() 方法只是一个空方法,但它的注释说,这个方法由继承者实现,主要用来更新 mScrollX 与 mScrollY。

看到这里,大家应该意识到了,computeScroll() 就是雀桥,我们可以在每次重绘时调用 Scroller.computeScrollOffset() 方法,然后通过获取 mCurrentX 与 mCurrentY,依照我们特定的意图来更改 mScrollX 和 mScrollY 的值,这样 Scroller 就能驱动起来,并且 Scroller 中的属性也驱动了 View 本身的滚动。

用一个例子来加深理解,继续改造我们的 TestView,现在不借助于属性动画的方式,通过 Scroller 来进行滚动操作。

Scroller mScroller;

 public TestView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.RED);

        mScroller = new Scroller(context);
}

public void startScrollBy(int dx,int dy) {

        mScroller.forceFinished(true);
        int startX = getScrollX();
        int startY = getScrollY();
        mScroller.startScroll(startX,startY,startX+dx,startY+dy,1000);
        invalidate();
}

@Override
public void computeScroll() {
    super.computeScroll();
    if (mScroller.computeScrollOffset()) {
        Log.d(TAG, "computeScroll: ");

        scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
        postInvalidate();
    } else {
        Log.d(TAG, "computeScroll is over: ");
    }
}

然后,我们再在 MainActivity 中改变测试代码。

mBtnTest.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        mTestView.startScrollBy(-1*100,-1*100);
      //mTestView.startGunDong(-1*100,-1*100);
      //mTestView.scrollBy(-1*10,-1*10);
    }
});

这里写图片描述

效果实现了,每次在当前的位置上向右下滑动了指定的距离。

不过,没有完,继续解疑。

之前说过,Scroller.computeScrollOffset() 需要在动画期间循环调用,当时我用了一个 while 循环示例,但是上面的代码并没有使用 while。这是怎么回事?

回想一下 Android 常用的编程技巧,如果让一个自定义的 View 不断重绘,我们可以怎么做?
1.通过一个 Handler 不停地发送消息,在接收消息时调用 postInvalidate() 或者 invalidate(),然后延时再发送相同的消息。
2.在 onDraw() 方法中调用 postInvalidate() 方法,可以导致 onDraw() 方法不断重绘。

显然,我们在这里采取的是第二种方法。当调用 mScroller.startScroll() 时,我们马上调用了 invalidate() 方法,这样会导致重绘,重绘的过程中 computeScroll() 方法会被执行,而我们在 computeScrollOffset() 中获取了 Scroller 中的 mCurrentX 和 mCurrentY,然后通过 scrollTo() 方法设置了 mScrollX 和 mScrollY,这一过程本来会导致重绘,但是如果 scrollTo() 里面的参数没有变化的话,那么就不会触发重绘,所以呢,我们又在后面代码补充了一个 postInvalidate() 方法的调用,当然,为了避免重复请求,可以在这个代码之前添加条件判断,判断的依据就是此次参数是不是和 mScrollX、mScrollY 相等。所以代码可以改成这样:

public void computeScroll() {
    super.computeScroll();
    if (mScroller.computeScrollOffset()) {

        scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
        if (mScroller.getCurrX() == getScrollX()
                && mScroller.getCurrY() == getScrollY() ) {
            postInvalidate();
        }
    }
}

这里写图片描述

用时序图可以表达大致的流程。

但我觉得,时序图不足以引起大家对于 Scroller 与 mScrollX、mScrollY 之间的联系。

这里写图片描述

上面图表,显示需要为 Scroller 与 mScrollerX、mScrollerY 建立相应的映射关系,而建立的场所就是在自定义 View 中的 computeScroll() 方法中,最终需要调用 scrollTo() 方法,至于要制定何种映射,这需要根据开发过程中的实际需求,这个是不固定的。

上面的示例,已经介绍了 Scroller 的基本用法,现在是时候对 Scroller 进行全面的分析了。

Scroller 全面介绍

Scroller 的创建

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); // look and feel tuning
}

创建 Scroller 的时候可以指定动画插值器。常见的动画插值器在学习属性动画的时候,大家应该都有了解过。

AccelerateDecelerateInterpolator  //先加速后减速
AccelerateInterpolator  //加速
AnticipateInterpolator  //运动时先向反方向运动一定距离,再向正方向目标运动
BounceInterpolator  //模拟弹球轨迹
DecelerateInterpolator  // 减速
LinearInterpolator  //匀速

所以,如果我们创建一个 Scroller,可以这样编写代码。

Scroller mScroller = new Scroller(context);
或者
AccelerateInterpolator interpolator = new AccelerateInterpolator(1.2f);
Scroller mScroller = new Scroller(context,interpolator);

Scroller 启动

Scroller 启动动画通过调用这个两个方法中的一个

public void startScroll(int startX, int startY, int dx, int dy) {}

public void startScroll(int startX, int startY, int dx, int dy,int duration) {}

方法 1 其实调用的是方法 2 ,只不过传递了 DEFAULT_DURATION 这个时长参数,它的数值为 250,表明 Scroller 动画时间如果不被指定就是 250 ms。

Scroller 的运行与数据计算

前面文章研究过,Scroller 无法自驱动,一定需要外部条件多次调用它的 computeScrollOffset() 方法,正因为这些源源不断的调动,驱动了 Scroller 本身。这有点像自行车的后飞轮,只有踏板采了一圈,后飞轮自己才会转一圈。踏板不间断地踩踏,自行车才会平滑地向前行驶。而后飞轮齿轮与踏板齿轮之间的比例关系可以看作是 Scroller 中的 mCurrentX、mCurrentY 与 View 中的 mScrollerX、mScrollerY 之间的某种映射关系。
这里写图片描述

所以运行 Scroller 的一般方法是在 View 中调用的地方编写这样的代码

//强制结束 mScroller 未完成的动画
mScroller.forceFinished(true);
int startX = getScrollX();
int startY = getScrollY();
// 调用 startScroll() 方法,其中的参数由开发者自己决定
mScroller.startScroll(startX,startY,startX+dx,startY+dy,1000);
// 让 View 重绘
invalidate();

然后,我们复写自定义 View 中的 computeScroll() 方法,在此获取 Scroller 动画当前数值,根据相应的规则调用 scrollTo() 设置 mScrollX 或者 mScrollY 的值,产生滚动的效果。 当然,在 computeScroll() 只是建议场合,如果你愿意,你可以在一个 while 循环中实现它。反正目的只有一个,那就是让 Scroller 的 computeScrollOffset() 方法多次调用,然后获取它的数值多次调用 scrollTo() 方法达到滚动动画效果。

@Override
public void computeScroll() {
    super.computeScroll();
    // 如果动画正在进行中,则返回 true,否则返回 false。
    // 我们只需要针对 Scroller 正在运行的状态
    if (mScroller.computeScrollOffset()) {

        // 通过获取 Scroller 中 mCurrentX、mCurrentY 的值,直接设置为 mScrollX、mScrollY
        // 在实际开发中,mCurrentX、mCurrentY 与 mScrollX、mScrollY 的关系由开发者自己定义
        scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
        if (mScroller.getCurrX() == getScrollX()
                && mScroller.getCurrY() == getScrollY() ) {
            postInvalidate();
        }
    }
}

Scroller 的快速滚动功能 fling

对于开发者而言,fling 这个概念大家应该不会陌生吧。

当手指在一个 RecyclerView 上快速滑动,如果抬起手指后,RecyclerView 中的内容继续滑动一段距离才停下来的这种情况就称为快速滚动。Scroller 也提供了这种动画的数值计算,调用的 API 为:

public void fling(int startX, int startY, int velocityX, int velocityY,
            int minX, int maxX, int minY, int maxY) {}

参数比较多,但都比较容易理解。

startX //开始滚动时 X 坐标
startY //开始滚动时 Y 坐标

velocityX //开始滚动时 X 方向的初始速度
velocityY //开始滚动时 Y 方向的初始速度

minX // 滚动过程,X 坐标不能小于这个数值
maxX //滚动过程,X 坐标不能大于这个值

minY //滚动过程,Y 坐标不能小于这个数值
maxY //滚动过程,Y 坐标不能大于这个数值

初始速度的方向,决定了滚动时的方向,当然,和 startScroll() 一样,这种方向只是数值上的变化,和 View 本身的滚动没有产生任何联系,我们同样在 View 的 computeScroll() 方法中处理,不过代码与处理 scroll 一致。另外,一个方向轴上的初始速度和最大取值、最小取值,决定了该方向的滚动的距离。Scroller 在 computeScrollOffset() 方法中封装了这种复杂的数学计算,所以开发者不需要关心具体细节,这样可以集中精力处理业务逻辑本身。

大家,一定想亲自尝试 Scroller 的 fling 效果。接下来,我们就来一次 Scroller 的完整实战。

Scroller 的完整实战

我们现在的目标是自定义一个 View,检验我们所学习的 Scroller 知识及 View 自身滑动机制如 scrollBy。包括:
1. Scroller 的 scroll 滚动,也就是普通滚动。
2. 自定义 View 对触摸机制的反馈,也就是手指能够滑动 View 的内容而不需要外部控件的点击事件触发,在 View 的 onTouchEvent() 自行处理,这考察 scrollBy 的知识。
3. Scroller 的 fling 滚动,也就是快速滚动。

对于目标 1 ,我们前面的示例已经完成了。

public void startScrollBy(int dx,int dy) {

        mScroller.forceFinished(true);
        int startX = getScrollX();
        int startY = getScrollY();
        mScroller.startScroll(startX,startY,startX+dx,startY+dy,1000);
        invalidate();
    }

@Override
public void computeScroll() {
    super.computeScroll();
    if (mScroller.computeScrollOffset()) {

        scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
        if (mScroller.getCurrX() == getScrollX()
                && mScroller.getCurrY() == getScrollY() ) {
            postInvalidate();
        }
    }
}

对于目标 2,我们需要复写自定义 View 的触摸事件。自然是要复写 TestView 的 onTouchEvent() 方法了,根据手指本次滑动的距离来调用 scrollBy()。

public TestView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    mPaint = new Paint();
    mPaint.setAntiAlias(true);
    mPaint.setColor(Color.RED);

    mScroller = new Scroller(context);
    //获取最小能够识别的滑动距离
    mSlop = ViewConfiguration.getTouchSlop();
    setClickable(true);
}

@Override
public boolean onTouchEvent(MotionEvent event) {

    switch (event.getAction())
    {
        case MotionEvent.ACTION_DOWN:
            restoreTouchPoint(event);
            break;

        case MotionEvent.ACTION_MOVE:
            int deltaX = (int) (event.getX() - mLastPointX);
            int deltaY = (int) (event.getY() - mLastPointY);
            if(Math.abs(deltaX) > mSlop || Math.abs(deltaY) > mSlop) {
                //取值的正负与手势的方向相反,这在前面的文章已经解释过了
                scrollBy(-deltaX,-deltaY);
                restoreTouchPoint(event);
            }
            break;

        case MotionEvent.ACTION_UP:
            break;

        default:
            break;
    }

    return true;
}

private void restoreTouchPoint(MotionEvent event) {
    mLastPointX = event.getX();
    mLastPointY = event.getY();
}

我们来看看效果。
这里写图片描述

好了,现在我们来聚集目标 3 ,它要实现的是一个 fling 动作,也就是快速滚动动力。在手指离开屏幕时,我们要判断它的初始速度,如果速度大于我们特定的一个阀值,那么我们就得借助 Scroller 中 fling() 方法的力量了。我们仍然在 onTouchEvent 处理手指离开屏幕时的情景,最重要的是如何来捕捉手指的速度。Android 中给我们提供了这么一个类 VelocityTracker,看名字就知道是速度追踪器的意思。

VelocityTracker 怎么使用呢?

//1. 第一步创建
VelocityTracker mVelocityTracker = VelocityTracker.obtain();

//2. 将 MotionEvent 事件添加到 VelocityTracker 变量中去
mVelocityTracker.addMovement(event);

//3. 计算 x、y 轴的速度,
//第一个参数表明时间单位,1000 代表 1000 ms 也就是 1 s,计算 1 s 内滚动多少个像素,后面表示最大速度
mVelocityTracker.computeCurrentVelocity(1000,600.0f);

//4. 获取速度
float xVelocity = mVelocityTracker.getXVelocity();
float yVelocity = mVelocityTracker.getYVelocity();

//之后的事情就是你自己根据获取的速度值做一些处理了。

知道 VelocityTracker 的使用方法之后,我们就可以马上应用到 TestView 中了,我们的 fling 操作需要的就是获取手指离开屏幕时的初始速度。

@Override
public boolean onTouchEvent(MotionEvent event) {

    if ( mVelocityTracker == null ) {
        mVelocityTracker = VelocityTracker.obtain();
    }

    mVelocityTracker.addMovement(event);

    switch (event.getAction())
    {
        case MotionEvent.ACTION_DOWN:
            restoreTouchPoint(event);
            break;

        case MotionEvent.ACTION_MOVE:
            int deltaX = (int) (event.getX() - mLastPointX);
            int deltaY = (int) (event.getY() - mLastPointY);
            if(Math.abs(deltaX) > mSlop || Math.abs(deltaY) > mSlop) {
                scrollBy(-deltaX,-deltaY);
                restoreTouchPoint(event);
            }
            break;

        case MotionEvent.ACTION_UP:
            mVelocityTracker.computeCurrentVelocity(1000,2000.0f);
            int xVelocity = (int) mVelocityTracker.getXVelocity();
            int yVelocity = (int) mVelocityTracker.getYVelocity();
            Log.d(TAG, "onTouchEvent: xVelocity:"+xVelocity+" yVelocity:"+yVelocity);
            if ( Math.abs(xVelocity) > MIN_FING_VELOCITY
                    || Math.abs(xVelocity) > MIN_FING_VELOCITY ) {
                mScroller.fling(getScrollX(),getScrollY(),
                        -xVelocity,-yVelocity,-1000,1000,-1000,2000);
                invalidate();
            }
            break;

        default:
            break;
    }

    return true;
}

我们看看效果:
这里写图片描述
TestView 中的内容可以在任意方向滚动,如果我们想进行限制,只想上下垂直滚动和左右水平滚动,那么怎么办呢?其实,很简单,把相应的方向上的速度设置为 0 就好了。

水平滚动时,yVelocity == 0,垂直滚动时,xVelocity == 0。

case MotionEvent.ACTION_UP:
mVelocityTracker.computeCurrentVelocity(1000,2000.0f);
int xVelocity = (int) mVelocityTracker.getXVelocity();
int yVelocity = (int) mVelocityTracker.getYVelocity();
Log.d(TAG, "onTouchEvent: xVelocity:"+xVelocity+" yVelocity:"+yVelocity);
if ( Math.abs(xVelocity) > MIN_FING_VELOCITY
        || Math.abs(xVelocity) > MIN_FING_VELOCITY ) {

    xVelocity = Math.abs(xVelocity) > Math.abs(yVelocity) ? -xVelocity : 0;
    yVelocity = xVelocity == 0 ? -yVelocity : 0;

    mScroller.fling(getScrollX(),getScrollY(),
            xVelocity,yVelocity,-1000,1000,-1000,2000);
    invalidate();
}
break;

这里写图片描述

至此,我们已经学会了 Scroller 的全部用法,其实想想来看也挺简单的不是吗?

总结

文章最后,我建议大家可以闭上眼睛回顾下这篇博文的内容,这样有助于自己的记忆与理解,并且这种方法对于学习其它新的知识也非常有效。

这篇文章的主要内容可以总结如下:

  1. View 滑动的基础是 mScrollX 和 mScrollY 两个属性。
  2. Android 系统处理滑动时会在 onDraw(Canvas canvas) 方法之前,对 canvas 对象进行平移,canvas.translate(mLeft-mScrollX,mRight-mScrollY)。平移的目的是将坐标系从 ViewGroup 转换到 child 中。
  3. 调用一个 View 的滑动有 scrollBy() 和 scrollTo() 两种,前一种是增量式,后一种直接到位。
  4. 如果要实现平滑滚动的效果,不借助于 Scroller 而自己实现属性动画也是可以完成的,原因还是针对 mScrollX 或者 mScrollY 的变化引起的重绘。
  5. View 滚动的区域是内容,也就是它绘制的内容,而对于一个 ViewGroup 而言,它的内容还包括它的 children。所以,如果想移动一个 View,本身那么就应该调用它的 parent 的 scrollBy() 或者 scrollTo() 方法。
  6. Scroller 本身并不神秘与复杂,它只是模拟提供了滚动时相应数值的变化,复写自定义 View 中的 computeScroll() 方法,在这里获取 Scroller 中的 mCurrentX 和 mCurrentY,根据自己的规则调用 scrollTo() 方法,就可以达到平稳滚动的效果。
  7. Scroller 提供快速滚动的功能,需要在自定义 View 的 onTouchEvent() 方法中获取相应方向的初始速度,然后调用 Scroller 的 startFling() 方法。
  8. 最重要的一点就是要深刻理解 mScrollX、mScrollY 在 Canvas 坐标中的意义,要区分手指滑动方向、内容滑动方向和 mScrollX、mScrollY 数值的关系。