Android 对现有布局增加下拉改写

先直接上作用,如下GIF所示

Android:对现有布局添加自定义的下拉刷新布局(阻尼滑动、悬停、回弹动画效果)

一、简述

对现有布局增加一个下拉改写,并且这个动画的作用如上GIF所示

1、下拉阶段

下拉过程中,有阻尼滑动作用

2、下拉松手阶段

(1)、进行高度判别,若大于指定的高度后,先回弹到指定的高度后,做悬停动画作用,再然后做回弹动画回弹到原始方位
(2)、若没有大于指定的高度,则直接回弹到原始方位
(3)改写的机遇,能够自由选择,例如在松手时,即建议改写逻辑。

二、现有布局

如前面的GIF所示,蓝色区域是内容区域,便是增加下拉改写前的现有布局

三、增加下拉改写

从GIF图能够看出,增加下拉改写,需求两个控件:一个呼应下拉操作的父容器控件、一个是改写头部控件

下拉改写的主要思路:

页面布局:将呼应下拉操作的父容器控件包裹赤色下拉改写头部区域 和 蓝色内容区域,其间蓝色内容区域掩盖在赤色下拉改写头部区域的上面。

下拉操作:下拉时,动态地改动赤色下拉改写头部区域的高度,以及动态改动蓝色内容区域的marginTop值

然后,便是动画操作,也是动态地改动赤色下拉改写头部区域的高度 和 蓝色内容区域的marginTop值。

1、一个呼应下拉操作的父容器控件

为写起来简略,直接继承RelativeLayout,重点重写onInterceptTouchEvent 和 onTouchEvent办法。

(1)onInterceptTouchEvent

阻拦工作办法:

首先,判别该工作是否需求阻拦;

然后,若阻拦该工作:在down工作时,将之前操作赤色下拉改写头部区域 及 蓝色内容区域都重置下

然后,在move工作时,判别当时移动的间隔是否 > mTouchSlop(表明滑动的最小间隔) ,当大于时,认为此刻发生了拖拽滑动

最后,在up\cancel工作时,将拖拽标志 重置回来

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    if (不阻拦工作的判别条件) {
        return false;
    }
    if (若此刻正在履行动画,则阻拦该工作) {
        return true;
    }
    final int action = event.getActionMasked();//获取触控手势
    switch (action) {
    case MotionEvent.ACTION_DOWN:
        // 重置操作
        updateHeightAndMargin(0);
        mIsDragging = false;
        // 手指按下的间隔
        this.mDownY = event.getY();
        break;
    case MotionEvent.ACTION_MOVE:
        final float y = event.getY();
        final float yDiff = y - this.mDownY;
        if (yDiff > mTouchSlop) {
            //判别是否时发生了拖拽
            mIsDragging = true;
        }
        break;
    case MotionEvent.ACTION_UP:
    case MotionEvent.ACTION_CANCEL:
        mIsDragging = false;
        break;
    default:
        break;
    }
    return mIsDragging;
}

(2)onTouchEvent

触摸工作处理办法:

若此刻没有发生拖拽,或许此刻正在动画中: 不处理该工作

当在move工作时:核算阻尼滑动间隔,然后更新给赤色的下拉改写头部区域 及 蓝色的内容区域

当在up/cancel工作时: 敞开动画逻辑

@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
    if (!mIsDragging || mIsAnimation) {
        return super.onTouchEvent(event);
    }
    //获取触控手势
    final int action = event.getActionMasked();
    switch (action) {
    case MotionEvent.ACTION_MOVE: {
        //获取移动间隔
        float eventY = event.getY();
        float yDiff = eventY - mDownY;
        float scrollTop = yDiff * 0.5;
        //核算实际需求被拖拽发生的移动百分比
        mDragPercent = scrollTop / mDp330;
        if (mDragPercent < 0) {
            return false;
        }
        //核算阻尼滑动的间隔
        int targetY = (int) (computeTargetY(scrollTop, mDragPercent, mDp330) + 0.5f);
        updateHeightAndMargin(targetY);
        break;
    }
    case MotionEvent.ACTION_UP:
    case MotionEvent.ACTION_CANCEL: {
        final float upDiffY = event.getY() - mDownY;
        final float overScrollTop = upDiffY * DEFAULT_DRAG_RATE;
        mIsDragging = false;
        if (overScrollTop > mDp54) {
            animateToHover();
        } else {
            animateToPeak();
        }
        mExtraDrag = 0;
        mPullRefreshBehavior.onUp();
        return false;
    }
    default:
        break;
    }
    return true;
}

阻尼滑动的核算方式:

/*核算阻尼滑动间隔*/
public int computeTargetY(float scrollTop, float dragPercent, float maxDragDistance) {
    float boundedDragPercent = Math.min(1.0f, Math.abs(dragPercent));
    float extraOS = Math.abs(scrollTop) - maxDragDistance;
    float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, maxDragDistance * 2) / maxDragDistance);
    float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow((tensionSlingshotPercent / 4), 2)) * 2f;
    float extraMove = (maxDragDistance) * tensionPercent / 2;
    return (int) ((maxDragDistance * boundedDragPercent) + extraMove);
}

更新赤色头部区域(mPullRefreshHeadView)高度 及 蓝色的内容区域(mTarget)

private void updateHeightAndMargin(int offsetTop) {
    if (mPullRefreshHeadView == null || mTarget == null) {
        return;
    }
    // 更新下拉改写的头部高度
    ViewGroup.LayoutParams headViewLayoutParams = mPullRefreshHeadView.getLayoutParams();
    if (headViewLayoutParams != null) {
        headViewLayoutParams.height = Math.max(offsetTop, mDp54);
    }
    // 更新 mTarget view 的 topMargin
    MarginLayoutParams targetLayoutParams = (MarginLayoutParams) mTarget.getLayoutParams();
    if (targetLayoutParams != null) {
        targetLayoutParams.topMargin = offsetTop;
    }
    mOffsetTop = offsetTop;
    mPullRefreshBehavior.onMove(mOffsetTop);
    // 改写界面
    requestLayout();
}

2、下拉改写头部区域

这儿能够依据自己的需求去构建下拉改写头部区域的布局,例如增加Lottie动画等

代码示例,是比较简略的一个 Textview + 背景展示下

public class PullRefreshHeadView extends RelativeLayout {
    private View mHeaderView;
    public PullRefreshHeadView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }
    private void init(Context context) {
        Resources resources = context.getResources();
        mHeaderView =  LayoutInflater.from(context).inflate(R.layout.vivoshop_classify_pull_refresh_head, this, false);
        LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, context.getResources().getDimensionPixelSize(R.dimen.dp54));
        params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
        params.addRule(RelativeLayout.CENTER_HORIZONTAL);
        params.bottomMargin = resources.getDimensionPixelSize(R.dimen.dp9);
        addView(mHeaderView, params);
    }
}

3、将下拉改写头部 及 内容区域 引入到 呼应下拉操作的父容器控件中

布局:呼应下拉操作的父容器控件包裹着下拉改写头部及内容区域

<?xml version="1.0" encoding="utf-8"?>
<com.qlli.pulllayout.PullRefreshLayout
    android:id="@+id/pull_layout"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    android:background="@color/teal_700">
      <com.qlli.pulllayout.PullRefreshHeadView
          android:id="@+id/pull_header"
          android:layout_width="match_parent"
          android:layout_height="@dimen/dp54"
          android:background="@color/red"/>
      <RelativeLayout
          android:layout_width="match_parent"
          android:layout_height="match_parent"
          android:background="@color/color_415fff"
          android:gravity="center"
          android:clickable="true">
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textColor="@color/white"
                android:textSize="20sp"
                android:text="这儿是内容区域, 下拉试试看"/>
      </RelativeLayout>
</com.qlli.pulllayout.PullRefreshLayout>

在呼应下拉操作的父容器控件初始化时,在onFinishInflate中将下拉改写头部、内容区域分别进行赋值

@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    ensureTargetView();
}
//寻觅需求控制滑动的内容区域的父容器
private void ensureTargetView() {
    if (mTarget != null || getChildCount() <= 0) {
        return;
    }
    for (int index = 0; index < getChildCount(); index++) {
        View child = getChildAt(index);
        if (child instanceof PullRefreshHeadView) {
            mPullRefreshHeadView = (PullRefreshHeadView) child;
            continue;
        }
        if (child != mPullRefreshHeadView) {
            mTarget = child;
            break;
        }
    }
}

4、回弹悬停动画

回弹悬停动画是指:先回弹到指定方位,然后开端悬停一段时刻后,再敞开一个新的动画

回弹动作:是指将 下拉改写头部 及 内容区域 回弹至指定方位,能够在一个时刻段中,通过监听0到100改动的,从而动态核算改动下拉改写头部及内容区域的高度并更新

悬停动作:在回弹完毕后,其实此刻悬停是指回弹动画完毕后,就保持当时方位不动了,此刻使用Handler发一个延时使命去履行 一个新的回弹动画(将下拉改写及内容区域回弹至原始方位),这个中心的过程给出的视觉作用是一个悬停的作用

private ValueAnimator mHoverAnimator;//回弹悬停动画
private final Handler mHoverHandler = new Handler(Looper.getMainLooper());
private void animateToHover() {
    // 这儿是内容区域marginTop的间隔
    final int startPosition = mOffsetTop;
    // 这儿是动画完毕的方位,要保留一个下拉改写头部高度间隔
    final int totalDistance = startPosition - mDp54;
    // 设置悬停动画的一些初始化东西
    if (mHoverAnimator == null) {
        mHoverAnimator = ValueAnimator.ofFloat(0f, 100f);
        mHoverAnimator.setInterpolator(new DecelerateInterpolator(2.0f));
    } else {
        mHoverAnimator.removeAllUpdateListeners();
        mHoverAnimator.removeAllListeners();
        mHoverAnimator.end();
    }
    // 在动画监听过程中,通过updateHeightAndMargin移动下拉改写及内容区域的间隔
    mHoverAnimator.addUpdateListener(animation -> {
        Object value = animation.getAnimatedValue();
        if (value instanceof Float) {
            float percent = ((float) value) / 100f;
            int targetTop = startPosition - (int) (totalDistance * percent);
            updateHeightAndMargin(targetTop);
        }
    });
    // 监听此动画开端 和 完毕点
    mHoverAnimator.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationStart(Animator animation) {
            mIsAnimation = true;
        }
        // 在该动画完毕后,在1.6s后,做一个回弹动画,因此在1.6s的时刻内便是一个悬停作用
        // 能够在这个悬停的期间干些工作,例如播映Lottie动画等
        @Override
        public void onAnimationEnd(Animator animation) {
            mHoverHandler.removeCallbacksAndMessages(null);
            mHoverHandler.postDelayed(() -> {
                if (isAttachedToWindow()) {
                    // 例如在这个播映Lottie动画
                    ensureTargetView();
                    // 回弹动画
                    animateToPeak();
                }
            }, 1600);
        }
    });
    // 此动画设置一下时刻
    float animationPercent = Math.min(1.0f, Math.abs(totalDistance) * 1.0f / mDp54);
    long duration = Math.abs((long) (ANIMATION_DURATION_300 * animationPercent));
    mHoverAnimator.setDuration(duration);
    mHoverAnimator.start();
}

5、回弹到顶部的动画

这个回弹到顶部的操作是指:将下拉改写头部 及 内容区域 在必定时刻内 回到顶部

private ValueAnimator mPeakAnimator;//回弹动画
private void animateToPeak() {
    float startDragPercent = mDragPercent;
    //松手后开端从此方位滑动
    final int totalDistance = mOffsetTop;
    if (mPeakAnimator == null) {
        mPeakAnimator = ValueAnimator.ofFloat(0f, 100f);
        mPeakAnimator.setInterpolator(new DecelerateInterpolator(2.0f));
    } else {
        mPeakAnimator.removeAllListeners();
        mPeakAnimator.removeAllUpdateListeners();
        mPeakAnimator.end();
    }
    mPeakAnimator.addUpdateListener(animation -> {
        Object value = animation.getAnimatedValue();
        if (value instanceof Float) {
            float percent = ((float) value) / 100f;
            int targetTop = (int) (totalDistance * (1.0f - percent));
            updateHeightAndMargin(targetTop);
        }
    });
    mPeakAnimator.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationStart(Animator animation) {
            mIsAnimation = true;
        }
        @Override
        public void onAnimationEnd(Animator animation) {
            mIsAnimation = false;
            updateHeightAndMargin(0);
        }
    });
    float ratio = Math.abs(startDragPercent);
    // 滑动到顶部的时刻
    mPeakAnimator.setDuration((long) (800 * ratio));
    mPeakAnimator.start();
}

6、在某些机遇下,进行回调

能够结合自己的需求写一个接口,例如下面这样:

public interface PullRefreshBehavior {
    // 移动的高度
    void onMove(int height);
    // 手指抬起
    void onUp();
    // 悬停
    void onHover();
    // 回弹
    void onSpringBack();
    // 完结
    void onComplete();
}

然后在下拉操作的过程中 去选择性地调用 上面接口中的办法,这样在实现该接口的详细实现类中,就能依据当时下拉操作的不同机遇来去做一些想做的工作

四、遇到的问题

1、在下拉操作时,在onInterceptTouchEvent办法时仅呼应down工作,move工作不呼应

导致该问题的主要原因是:呼应下拉操作的父容器内包裹的子控件没有耗费down工作,所以后续收不到move工作

2、看下ViewGroup中的工作分发这段代码

能够看到下面代码中: 是down工作,或许 mFirstTouchTarget != null

若父容器包裹的子控件没有耗费down工作,则mFirstTouchTarget == null,那么当move工作到来是,即不满足条件,则不会调用到 onInterceptTouchEvent办法。

// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
    if (!disallowIntercept) {
        intercepted = onInterceptTouchEvent(ev);
        ev.setAction(action); // restore action in case it was changed
    } else {
        intercepted = false;
    }
} else {
    // There are no touch targets and this action is not an initial down
    // so this view group continues to intercept touches.
    intercepted = true;
}

3、如何解决呢

在子控件中,加一个耗费down工作的操作即可,例如在子控件布局中,增加一个clickable属性为 true 即可

因为可点击工作,是耗费down工作的

 <RelativeLayout
          android:layout_width="match_parent"
          android:layout_height="match_parent"
          android:background="@color/color_415fff"
          android:gravity="center"
          android:clickable="true">