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

这是山寨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 ----

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容