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

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

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

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

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

滚动的本质

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

因此这种状况下,咱们能够这样概括:ScrollView 滚动针对的是内容,记住是内容。由于 ScrollView 和 TextView 自己尺寸和位置并无发生变化,只是文本的显示区域进行了位移canvas

咱们再来看看另一种状况。
这里写图片描述
上图是一个 RecyclerView,很明显,它的子 View 也没法在一屏的空间彻底展现出来,因此借助于滚动机制 RecyclerView 中的全部内容才可以完整展现出来。 缓存

因此这种状况下,咱们能够这样概括:RecyclerVIew 滚动针对的是它的子 View。RecyclerView 自己尺寸和位置并无发生变化,只是子 View 的显示区域进行了位移app

综合上面两种状况,咱们能够给出一个结论: ide

对于一个 View 或者 ViewGroup 而言,滚动针对的是它的内容,View 的内容体如今它要绘制的内容上面,ViewGroup 的内容至关于它的全部子 View。 svg

这一节的目的在于让咱们意识到,滚动与内容之间的联系。认识到这一点对于咱们深入理解滚动自己是很是重要的。工具

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(yVelocity) > 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(yVelocity) > 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 数值的关系。

这里写图片描述