Android 自定义View UC下拉刷新效果(三)

0.052字数 1561阅读 2332

这是山寨UC浏览器的下拉刷新效果的的结尾篇了,到这里,基本是实现了UC浏览器首页的效果了!还没有看之前的小伙伴记得出门左转先看看哟(Android 自定义View UC下拉刷新效果(一)Android 自定义View UC下拉刷新效果(二))。期间也有不小的改动,主要集中在那个小圆球拖拽时的绘制方式上,可以看到,最后的圆球效果比之前的顺畅漂亮了很多!!

pull.png
back.png
loading.png
PullRefreshfinal.gif

经过前面的两篇文章,分别从小球动画和下拉刷新两个方面介绍了相关的内容,最后还剩首页显示过渡列表展示的内容了!效果说明:

  • 1、向上滑动,背景和tab有个渐变效果
  • 2、向下滑动,有一个放大和圆弧出现

功能拆分

  • 1、展开关闭top默认值
    因为这里有两种状态,一种是展开的,一种是首页的关闭状态,展开的默认top是TabLayout的对应高度加上自身的top值,而关闭时,默认top值是上面的CurveView的高度加上自身的top值。
  • 2、实现拖拽滑动效果
    首先想到的就是ViewDragHelper,使用它来控制相关的拖拽。
  • 3、拖拽背景渐变效果
    这个就是设置拖拽过程中相关的回调。另外就是在首页的状态,ViewPager是没法左右滑动的。
  • 4、绘制下拉的弧度
    这个就得使用到drawPath()绘制贝塞尔曲线了。

相关对象介绍

父布局是一个CurveLayout,里面包含三个对象:

  // child views & helpers
private View sheet;//target
private ViewDragHelper sheetDragHelper;
private ViewOffsetHelper sheetOffsetHelper;

sheet就是我们的拖拽目标ViewViewDragHelper拖拽辅助类,写好对应的事件处理和Callback就可以实现拖拽功能了!这里不详细介绍。ViewOffsetHelper,对于它的介绍,可以看看下面的截图:

ViewOffsetHelper.png

因为我们这里只涉及上下的移动,所以介绍以下主要方法:

//构造方法
public ViewOffsetHelper(View view) {
    mView = view;
}
//onlayoutChange时调用
public void onViewLayout() {
    // Grab the intended top & left
    mLayoutTop = mView.getTop();
    mLayoutLeft = mView.getLeft();

    // And offset it as needed
    updateOffsets();
}
//View位置改变时调用该方法
public boolean setTopAndBottomOffset(int absoluteOffset) {
    if (mOffsetTop != absoluteOffset) {
        mOffsetTop = absoluteOffset;
        updateOffsets();
        return true;
    }
    return false;
}
//同步
public void resyncOffsets() {
    mOffsetTop = mView.getTop() - mLayoutTop;
    mOffsetLeft = mView.getLeft() - mLayoutLeft;
}
//更新值
private void updateOffsets() {
    ViewCompat.offsetTopAndBottom(mView, mOffsetTop - (mView.getTop() - mLayoutTop));
    ViewCompat.offsetLeftAndRight(mView, mOffsetLeft - (mView.getLeft() - mLayoutLeft));
}

展开、关闭的默认top值

@Override
public void addView(View child, int index, ViewGroup.LayoutParams params) {
    if (sheet != null) {
        throw new UnsupportedOperationException("CurveLayout must only have 1 child view");
    }
    sheet = child;
    sheetOffsetHelper = new ViewOffsetHelper(sheet);
    sheet.addOnLayoutChangeListener(sheetLayout);
    // force the sheet contents to be gravity bottom. This ain't a top sheet.
    ((LayoutParams) params).gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
    super.addView(child, index, params);
}

在addView()的方法中我们确定对应的Target,然后为其设置一个OnLayoutChangeListener

//设置默认的dismissTop值
public void setDismissOffset(int dismissOffset) {
    this.dismissOffset = currentTop + dismissOffset;
}
//设置默认的expandTop值
public void setExpandTopOffset(int tabOffset) {
    if (this.expandTopOffset != tabOffset) {
        this.expandTopOffset  = tabOffset;
        sheetExpandedTop = currentTop + expandTopOffset;
    }
}

接下来看看OnLayoutChangeListener里面的相关逻辑:

private final OnLayoutChangeListener sheetLayout = new OnLayoutChangeListener() {
    @Override
    public void onLayoutChange(View v, int left, int top, int right, int bottom,
                               int oldLeft, int oldTop, int oldRight, int oldBottom) {

        sheetExpandedTop = top + expandTopOffset;
        sheetBottom = bottom;
        currentTop = top;
        sheetOffsetHelper.onViewLayout();

        // modal bottom sheet content should not initially be taller than the 16:9 keyline
        if (!initialHeightChecked) {
            applySheetInitialHeightOffset(false, -1);
            initialHeightChecked = true;
        } else if (!hasInteractedWithSheet
                && (oldBottom - oldTop) != (bottom - top)) { /* sheet height changed */
            /* if the sheet content's height changes before the user has interacted with it
               then consider this still in the 'initial' state and apply the height constraint,
               but in this case, animate to it */
            applySheetInitialHeightOffset(true, oldTop - sheetExpandedTop);
        }
        Log.e(TAG, "onLayoutChange: 布局变化了!!" + sheet.getTop());
    }
}; 

初始化sheetExpandedTop,currentTop等字段,并且调用上面提到的onViewLayout(),同步ViewOffsetHelper的值。

拖拽滑动实现

ViewDragHelper就不多说了,Android自带的辅助类,添加一个Callback,然后处理相关回调方法就可以了!
判断是否拦截处理事件:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    currentX = ev.getRawX();
    Log.e(TAG, "BottomSheet onInterceptTouchEvent: " + currentX);
    if (isExpanded()) {
        sheetDragHelper.cancel();
        return false;
    }
    hasInteractedWithSheet = true;

    final int action = MotionEventCompat.getActionMasked(ev);
    if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
        sheetDragHelper.cancel();
        return false;
    }
    return isDraggableViewUnder((int) ev.getX(), (int) ev.getY())
            && (sheetDragHelper.shouldInterceptTouchEvent(ev));
}

@Override
public boolean onTouchEvent(MotionEvent ev) {
    currentX = ev.getRawX();
    sheetDragHelper.processTouchEvent(ev);
    return sheetDragHelper.getCapturedView() != null || super.onTouchEvent(ev);
}

这里获取的这个currentX是为了在下拉出现那个弧度的顶点。在接下来的回调中会使用。

private final ViewDragHelper.Callback dragHelperCallbacks = new ViewDragHelper.Callback() {

    @Override
    public boolean tryCaptureView(View child, int pointerId) {
        return child == sheet && !isExpanded();//是否可以拖拽
    }

    @Override
    public int clampViewPositionVertical(View child, int top, int dy) {
        //竖直方向的值
        return Math.min(Math.max(top, sheetExpandedTop), sheetBottom);
    }

    @Override
    public int clampViewPositionHorizontal(View child, int left, int dx) {
        return sheet.getLeft();
    }

    @Override
    public int getViewVerticalDragRange(View child) {
        //竖直方向的拖拽范围
        return sheetBottom - sheetExpandedTop;
    }

    @Override
    public void onViewPositionChanged(View child, int left, int top, int dx, int dy) {
        // view的拖拽过程中
        reverse = false;
        //change的过程中通知同步改变
        sheetOffsetHelper.resyncOffsets();
        dispatchPositionChangedCallback();
        canUp = Math.abs(top - dismissOffset) > MIN_DRAG_DISTANCE;
    }

    @Override
    public void onViewReleased(View releasedChild, float velocityX, float velocityY) {
        //松手后
        boolean expand = canUp || Math.abs(velocityY) > MIN_FLING_VELOCITY;
        reverse = false;
        animateSettle(expand ? sheetExpandedTop: dismissOffset, velocityY);
    }
};

可以看到,在onViewPositionChanged()的方法中会去调用resyncOffsets()的方法同步ViewOffsetHelper的对应值。
onViewReleased()的方法中调用了animateSettle()的方法,两种情况,一种是展开,一种是关闭(首页的状态),所以这里有一个expand的变量来标识,如果展开,就展开到sheetExpandedTop的高度,关闭的话,那么就是到dismissOffset的高度。

animateSettle()方法最终执行以下方法逻辑:

private void animateSettle(int initialOffset, final int targetOffset, float initialVelocity) {
    if (settling) return;
    Log.e(TAG, "animateSettle:TopAndBottom :::" + sheetOffsetHelper.getTopAndBottomOffset());
    if (sheetOffsetHelper.getTopAndBottomOffset() == targetOffset) {
        if (targetOffset >= dismissOffset) {
            dispatchDismissCallback();
        }
        return;
    }

    settling = true;
    final boolean dismissing = targetOffset == dismissOffset;
    final long duration = computeSettleDuration(initialVelocity, dismissing);
    final ObjectAnimator settleAnim = ObjectAnimator.ofInt(sheetOffsetHelper,
            ViewOffsetHelper.OFFSET_Y,
            initialOffset,
            targetOffset);
    settleAnim.setDuration(duration);
    settleAnim.setInterpolator(getSettleInterpolator(dismissing, initialVelocity));
    settleAnim.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            dispatchPositionChangedCallback();
            if (dismissing) {
                dispatchDismissCallback();
            }
            settling = false;
        }
    });
    if (callbacks != null && !callbacks.isEmpty()) {
        settleAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                if (animation.getAnimatedFraction() > 0f) {
                    dispatchPositionChangedCallback();
                }
            }
        });
    }
    settleAnim.start();
}

这里有一个settleAnim的属性动画,传入的是ViewOffsetHelper里面的OFFSET_Y,在OFFSET_Yset()方法中,调用setTopAndBottomOffset()的方法去修改对应的top值,从而实现了松手后展开或者关闭的动画效果。

final ObjectAnimator settleAnim = ObjectAnimator.ofInt(sheetOffsetHelper,
        ViewOffsetHelper.OFFSET_Y,
        initialOffset,
        targetOffset);


public static final Property<ViewOffsetHelper, Integer> OFFSET_Y =
      AnimUtils.createIntProperty(
              new AnimUtils.IntProp<ViewOffsetHelper>("topAndBottomOffset") {
          @Override
          public void set(ViewOffsetHelper viewOffsetHelper, int offset) {
              viewOffsetHelper.setTopAndBottomOffset(offset);
          }

          @Override
          public int get(ViewOffsetHelper viewOffsetHelper) {
              return viewOffsetHelper.getTopAndBottomOffset();
          }
      });

拖拽背景渐变效果

说到背景的渐变效果,那么肯定就是要讲相关的回调了!Callbacks用来处理对应的回调,提供了三个方法:onSheetNarrowed(),onSheetExpanded(),onSheetPositionChanged(),分别对应的时候关闭了,展开了,和改变了三种情况。

onSheetPositionChanged(int sheetTop, float currentX, int dy, boolean userInteracted)的方法中,有四个参数,分别是当前的top值,当前touch的x值,竖直方向的改变值,以及是否是由开到关或者由关到开的情况。

public static abstract class Callbacks {

    public void onSheetNarrowed() {

    }

    public void onSheetExpanded() {
    }

    public void onSheetPositionChanged(int sheetTop, float currentX, int dy, boolean userInteracted) {
    }
}

public void registerCallback(Callbacks callback) {
    if (callbacks == null) {
        callbacks = new CopyOnWriteArrayList<>();
    }
    callbacks.add(callback);
}

public void unregisterCallback(Callbacks callback) {
    if (callbacks != null && !callbacks.isEmpty()) {
        callbacks.remove(callback);
    }
}

在具体是实现中是这样的:

    mBoottom.registerCallback(new CurveLayout.Callbacks() {
        private int dy;

        @Override
        public void onSheetExpanded() {
            Log.e(TAG, "onSheetExpanded: ");
            mCurveView.onDispatchUp();
            mCurveView.setTranslationY(0);
            mCurveView.setVisibility(View.GONE);
            mTab.setTranslationY(-mCurveView.getHeight());
            mTab.setVisibility(View.VISIBLE);
            mCurveView.setScaleX(1.f);
            mCurveView.setScaleY(1.f);
            mViewPager.setScrollable(true);
            dy = 0;
        }

        @Override
        public void onSheetNarrowed() {
            Log.e(TAG, "onSheetNarrowed: ");
            mCurveView.onDispatchUp();
            mCurveView.setTranslationY(0);
            mCurveView.setScaleX(1.f);
            mCurveView.setScaleY(1.f);
            mTab.setVisibility(View.GONE);
            mViewPager.setScrollable(false);
            mCurveView.setVisibility(View.VISIBLE);
            dy = 0;

        }

        @Override
        public void onSheetPositionChanged(int sheetTop, float currentX, int ddy, boolean reverse) {

            if (mCurveViewHeight == 0) {
                mCurveViewHeight = mCurveView.getHeight();
                mBoottom.setDismissOffset(mCurveViewHeight);
            }
            this.dy += ddy;
            float fraction = 1 - sheetTop * 1.0f / mCurveViewHeight;
            if (!reverse) {
                if (fraction >= 0 && !mBoottom.isExpanded()) {//向上拉
                    mTab.setVisibility(View.VISIBLE);
                    mBoottom.setExpandTopOffset(mTab.getHeight());
                    mCurveView.setTranslationY(dy * 0.2f);
                    mTab.setTranslationY(-fraction * (mCurveView.getHeight() + mTab.getHeight()));
                } else if (fraction < 0 && !mBoottom.isExpanded()) {//向下拉
                    mTab.setVisibility(View.GONE);
                    mCurveView.onDispatch(currentX, dy);
                    mCurveView.setScaleX(1 - fraction * 0.5f);
                    mCurveView.setScaleY(1 - fraction * 0.5f);
                }
            }
        }
    });

可以看到,在onSheetPositionChanged()的方法中,首先是进行了一些值的初始化,然后根据reverse来判断,如果不是由开到关或者由关到开的状态改变,那么就开始背景的移动或者背景的放大及画出对应的弧形。另外在onSheetNarrowed()或者onSheetExpanded()中就是对View做的一些初始化或者重置操作!

绘制下拉的弧度

当是下拉的时候,需要绘制出弧形,这里使用到了CurveView以及它的onDispatch()方法!

@Override
protected void onDraw(Canvas canvas) {
    path.reset();
    path.moveTo(0, getMeasuredHeight());
    path.quadTo(currentX, currentY + getMeasuredHeight(), getWidth(), getMeasuredHeight());
    canvas.drawPath(path, paint);
}

public void onDispatch(float dx, float dy) {
    currentY = dy > MAX_DRAG ? MAX_DRAG : dy;
    currentX = dx;
    if (dy > 0) {
        invalidate();
    }
}

其实很简单,就是使用当前的X值的坐标和dy的值来进行drawPath()的操作。当然这里有一个上限的限制。

到这里,实现拖拽展开及关闭的逻辑就实现完成了,总结起来就是使用ViewDragHelper来辅助实现拖拽功能,在松手的时候调用ViewOffsetHelper来实现展开或者关闭的渐变动画效果,期间调用Callbacks回调对应的状态(展开了、关闭了、位置变化了)。

圆球绘制逻辑改动

之前的第一篇文章中介绍的圆球拉伸绘制时采用的是drawArc()和drawPath结合的方法,所以看着总觉得有点儿怪,然后查了相关的资料,这里使用了新的方式,请看图:


网上拷的

意思就是一个圆形,可以理解为是采用了drawPath()画了四段弧。每段弧就是使用path.cubicTo()绘制的贝塞尔曲线。

根据网上的资料,这里的m的值就是半径R*0.551915024494f。在竖直方向拖拽的过程中,其实就是改变这12个点的坐标,从而绘制出想要的弧形。

项目下载:https://github.com/lovejjfg/UCPullRefresh

喜欢就请点个Start呗。。

参考资料

1、Plaid项目
2、三次贝塞尔曲线练习之弹性的圆

---- Edit By Joe ----

推荐阅读更多精彩内容