天天看點

自定義ScrollView實作彈性效果

    彈性效果包括過度拉伸效果和反彈效果。

    實作思路請看這篇文章,我基于他的實作進行了一些優化。

    一、實作思路

    這個問題的本質是控制ScrollView裝載的View(不妨叫它innerView)的顯示位置。而innerView顯示位置的改變可以通過兩種方式實作。

  • 改變ScrollView的mScrollY
  • 調用innerView的layout(l, t, r, b)函數

    前者就是ScrollView實作正常滾動的方式。而後者就是我們拿來實作反彈效果的。也就是說,在ScrollView正常滾動達到極限的時候,我們調用innerView的layout(l, t, r, b)函數繼續"滾動"innerView。手指擡起時,意味着一次滑動的結束,這個時候,我們要判斷是否要"反彈"。是以這裡面有兩個關鍵問題。

  • 如何判斷ScrollView的滾動已經達到極限
  • 如何判斷是否需要反彈

    對于問題一,參照以下代碼。

public boolean needOverScroll(float deltaY) {
        final int offset = innerView.getMeasuredHeight() - getHeight();
        final float scrollY = getScrollY();
        return (scrollY == 0 && deltaY > 0)|| (scrollY == offset && deltaY < 0);
    }
           

    ScrollView的滾動已經達到極限分兩種情況。

    第一種情況是手指往下滑動無法移動innerView了。我們知道,手指往下滑動,ScrollView的mScrollY減小,直至為0。ScrollView的mScrollY減為0之後(scrollY == 0),再向下滑動(deltaY > 0),mScrollY就不再變化了(不會變為負數),innerView也就不會移動了。這個時候,就需要我們調用innerView的layout(l, t, r, b)函數繼續"滾動"innerView。

    第二種情況是手指往上滑動無法移動innerView了。我們知道,手指往上滑動,ScrollView的mScrollY增大,直至為innerView.getMeasuredHeight() - getHeight()(即mScrollY最大值為ScrollView裝載的View的測量高度與ScrollView提供給其顯示的高度的內插補點)。ScrollView的mScrollY增大為innerView.getMeasuredHeight() - getHeight()之後(scrollY == offset),再向上滑動(deltaY < 0),mScrollY就不再變化了,innerView也就不會移動了。這個時候,也需要我們調用innerView的layout(l, t, r, b)函數繼續"滾動"innerView。

   這裡相對于我參考博文的那種實作,增加了對deltaY的考慮。這麼做,可以避免本可以利用ScrollView的本身滾動的時候,使用layout(l, t, r, b)進行滾動。是對初始時就向上滑動的情況的優化。如果不考慮deltaY的話,一開始向上滑動也會調用layout(l, t, r, b)移動布局。雖然根據列印的日志,隻移動了幾個像素(之後因為mScrollY不再為0就不再調用layout(l, t, r, b)),但是既然能避免還是應該避免。

    對于問題二,參照以下代碼。

public boolean needRebound() {
        return getInnerViewActualTop() > 0 || getInnerViewActualBottom() < getHeight();
    }

    public int getInnerViewActualTop(){
        return  innerView.getTop() - getScrollY();
    }

    public int getInnerViewActualBottom(){
        return  innerView.getBottom() - getScrollY();
    }
           

    是否需要反彈也分兩種情況

    第一種情況是需要向上反彈,即getInnerViewActualTop() > 0。

    第二種情況是需要向下反彈,即getInnerViewActualBottom() < getHeight()。

    說明一下getInnerViewActualTop()函數。我在調試的時候發現,使用原生的ScrollView時,無論你如何滾動,innerView.getTop()都是0。而視覺上,以ScrollView為參考系,innerView的上邊界是不斷變化的。(我推想,應該是在onDraw()函數中,getTop()-getScrollY()來确定上邊界,是以getTop()可以一直不變,改變mScrollY就行了)是以,我寫了getInnerViewActualTop()函數,來獲得innerView相對于ScrollView視覺上的上邊界。

    這樣實作有一個好處,就是當你過度拉伸,不松手,再往回滑動,讓innerView重新顯示到合理的位置上(沒有過度拉伸,沒有空白的區域),擡手,innerView不會反彈。而我參考博文的那種實作,隻要你過度拉伸過,擡手就會反彈,體驗不是太好。

    二、源碼

package com.example.ligang.demo_autopullrefreshlistview;

import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.TranslateAnimation;
import android.widget.ScrollView;


public class OverScrollView extends ScrollView {

    private static final float OVER_SCROLL_RATIO = 0.5f;
    private static final int REBOUND_DURATION = 200;

    private View innerView;
    private float lastY = -1;
    private Rect originalRect = new Rect();

    public OverScrollView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onFinishInflate() {
        if (getChildCount() > 0) {
            innerView = getChildAt(0);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if (innerView != null) {
            handleTouchEvent(ev);
        }
        return super.onTouchEvent(ev);
    }

    public void handleTouchEvent(MotionEvent ev) {
        final float currY = ev.getY();
        if (lastY == -1) {
            lastY = currY;
        }
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastY = currY;
                break;
            case MotionEvent.ACTION_MOVE:
                final float deltaY = (currY - lastY) * OVER_SCROLL_RATIO;
                lastY = currY;
                if (needOverScroll(deltaY)) {
                    if (originalRect.isEmpty()) {
                        originalRect.set(innerView.getLeft(), innerView.getTop(), innerView.getRight(), innerView.getBottom());
                    }
                    if (deltaY > 0) {
                        innerView.layout(innerView.getLeft(), innerView.getTop() + (int) (Math.ceil(deltaY)), innerView.getRight(), innerView.getBottom() + (int) (Math.ceil(deltaY)));
                    } else {
                        innerView.layout(innerView.getLeft(), innerView.getTop() + (int) (Math.floor(deltaY)), innerView.getRight(), innerView.getBottom() + (int) (Math.floor(deltaY)));
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                if (needRebound()) {
                    rebound();
                }
                reset();
                break;
            default:
                reset();
                break;
        }
    }

    private void reset() {
        lastY = -1;
    }

    public void rebound() {
        TranslateAnimation translateAnimation = new TranslateAnimation(0, 0, innerView.getTop(), originalRect.top);
        translateAnimation.setDuration(REBOUND_DURATION);
        innerView.startAnimation(translateAnimation);
        innerView.layout(originalRect.left, originalRect.top, originalRect.right, originalRect.bottom);
        originalRect.setEmpty();
    }

    public boolean needRebound() {
        return getInnerViewActualTop() > 0 || getInnerViewActualBottom() < getHeight();
    }

    public int getInnerViewActualTop(){
        return  innerView.getTop() - getScrollY();
    }

    public int getInnerViewActualBottom(){
        return  innerView.getBottom() - getScrollY();
    }

    public boolean needOverScroll(float deltaY) {
        final int offset = innerView.getMeasuredHeight() - getHeight();
        final float scrollY = getScrollY();
        return (scrollY == 0 && deltaY > 0)|| (scrollY == offset && deltaY < 0);
    }
}