浅析NestedScrolling嵌套滑动机制之实践篇-仿写饿了么商家详情页

预览

嵌套系列导航

  • 1.浅析NestedScrolling嵌套滑动机制之基础篇
  • 2.浅析NestedScrolling嵌套滑动机制之实践篇-仿写饿了么商家详情页
  • 3.浅析NestedScrolling嵌套滑动机制之CoordinatorLayout.Behavior
  • 4.浅析NestedScrolling嵌套滑动机制之实践篇-自定义Behavior实现小米音乐歌手详情

本文已在公众号鸿洋原创发布。未经许可,不得以任何形式转载!

概述

之前的《浅析NestedScrolling嵌套滑动机制之基础篇》带大家了解NestedScrolling的原理和使用还有它的改进等等,这篇文章手把手基于NestedScrolling嵌套滑动机制现实饿了么v8.27.6商家详情页。github地址:https://github.com/pengguanming/ElemeNestedScrolling

效果预览

需要仿写的有两部分,第一个是商家页的嵌套滑动,第二个是商家点餐页的嵌套滑动。

商家滑动

商家点餐滑动

apk下载地址:
github
百度云 密码:jm39

商家页的嵌套滑动实现

效果分析

商家滑动页层级

商家滑动页布局主要有上图五部分组成,逻辑上的层级如图所示有3层。

商家滑动TransY

滑动Content部分时利用View的TransitionY属性改变位置来消耗滑动值,根据Content部分的TransitionY设定各种范围从而计算百分比来执行颜色渐变、位移、Scale、Alpha效果。下面来说明上图中变量的意义:

topBarHeight;//topBar高度
contentTransY;//滑动内容初始化TransY
upAlphaScaleY;//上滑时logo,收藏icon缩放、搜索icon、分享icon透明度临界值
upAlphaGradientY;//上滑时搜索框、topBar背景,返回icon、拼团icon颜色渐变临界值
downFlingCutOffY;//从折叠状态下滑产生fling时回弹到初始状态的最大值
downCollapsedAlphaY;//下滑时折叠内容透明度临界值
downShopBarTransY;//下滑时购物内容位移临界值
downContentAlphaY;//下滑时收起按钮和滑动内容透明度临界值
ivCloseHeight;//收起按钮的高度
content部分的上滑范围=[topBarHeight,contentTransY]
content部分的下滑范围=[contentTransY,满屏高度-ivCloseHeight]

举个根据Content部分的TransitionY计算百分比例子(请注意:根据Android的坐标系contentTransY>upAlphaScaleY):

    /**
    *计算上滑时logo,收藏icon缩放、搜索icon、分享icon透明度的百分比其范围控制在[0.0f,1.0f],
    而Content部分的TranslationY值控制在[upAlphaScaleY,contentTransY],v4包的MathUtils类的clamp方法可以帮助控制值的范围,超过范围时百分下限为0,上限为1。
    */
    upAlphaScalePro= (contentTransY - MathUtils.clamp(Content部分的TranslationY, upAlphaScaleY, 
    contentTransY)) / (contentTransY-upAlphaScaleY);

    //根据upAlphaScalePro设置icon的透明度
    float alpha = 1 - upAlphaScalePro;
    setAlpha(mIvShare, alpha);

ElemeNestedScrollLayout

商家页的嵌套滑的自定义View继承FrameLayout,命名为ElemeNestedScrollLayout,在Content部分能够滑动的View只有ViewPager里Fragment的RecyclerView和NestedScrollView,它们都实现了NestedScrollingChild2接口,故自定义View要实现NestedScrollingParent2接口根据具体逻辑来消费NestedScrollingChild2的滑动值。

布局

下面是布局要点,侧重于控件的尺寸和位置,完整布局请参考:ElemeNestedScrollLayout布局


    
        
            
        
        
            <
        
     
    
    //自定义View,其作用是在折叠状态时适配沉浸式状态栏效果。
    
    

    
    
            
            
            
    
    

    
        
    

绑定需要做效果的View、引入Dimens、测量Content部分的高度

从上面图片能够分析出:折叠状态时,Content部分高度=满屏高度-TopBar部分的高度

public class ElemeNestedScrollLayout extends FrameLayout implements NestedScrollingParent2 {
    //Header部分
    private View mIvLogo;
    private View mVCollect;

    //Collaps Content部分
    private View mClCollapsedHeader;
    private View mCollapsedContent;
    private View mRvCollapsed;

    //TopBar部分
    private View mIvSearch;
    private View mIvShare;
    private View mTvSearch;
    private View mTopBar;
    private ImageView mIvBack;
    private ImageView mIvAssemble;

    //Content部分
    private View mLlContent;
    private View mIvClose;
    private View mViewPager;
    private View mStl;

    //ShopBar部分
    private View mShopBar;

    private float topBarHeight;//topBar高度
    private float shopBarHeight;//shopBar高度
    private float contentTransY;//滑动内容初始化TransY
    private float upAlphaScaleY;//上滑时logo,收藏icon缩放、搜索icon、分享icon透明度临界值
    private float upAlphaGradientY;//上滑时搜索框、topBar背景,返回icon、拼团icon颜色渐变临界值
    private float downFlingCutOffY;//从折叠状态下滑产生fling时回弹到初始状态的临界值
    private float downCollapsedAlphaY;//下滑时折叠内容透明度临界值
    private float downShopBarTransY;//下滑时购物内容位移临界值
    private float downContentAlphaY;//下滑时收起按钮和滑动内容透明度临界值
    private float downEndY;//下滑时终点值
  
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mLlContent = findViewById(R.id.cl_content);
        mCollapsedContent = findViewById(R.id.cl_collapsed_content);
        mIvSearch = findViewById(R.id.iv_search);
        mIvShare = findViewById(R.id.iv_share);
        mIvBack = findViewById(R.id.iv_back);
        mIvAssemble = findViewById(R.id.iv_assemble);
        mIvLogo = findViewById(R.id.iv_logo);
        mVCollect = findViewById(R.id.iv_collect);
        mTvSearch = findViewById(R.id.tv_search);
        mTopBar = findViewById(R.id.cl_top_bar);
        mClCollapsedHeader = findViewById(R.id.cl_collapsed_header);
        mRvCollapsed = findViewById(R.id.rv_collasped);
        mIvClose = findViewById(R.id.iv_close);
        mViewPager = findViewById(R.id.vp);
        mStl = findViewById(R.id.stl);
        mShopBar = findViewById(R.id.cl_shop_bar);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //设置Content部分高度
        topBarHeight= mTopBar.getMeasuredHeight();
        ViewGroup.LayoutParams params = mLlContent.getLayoutParams();
        params.height = (int) (getMeasuredHeight() - topBarHeight);
        //重新测量
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

     @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        shopBarHeight = getResources().getDimension(R.dimen.shop_bar_height);
        contentTransY = getResources().getDimension(R.dimen.content_trans_y);
        downShopBarTransY = contentTransY+ shopBarHeight;
        upAlphaScaleY = contentTransY - dp2px(32);
        upAlphaGradientY = contentTransY - dp2px(64);
        downFlingCutOffY = contentTransY + dp2px(28);
        downCollapsedAlphaY = contentTransY + dp2px(32);
        downContentAlphaY = getResources().getDimension(R.dimen.donw_content_alpha_y);
        downEndY = getHeight() - getResources().getDimension(R.dimen.iv_close_height);
    }
}

现实NestedScrollingParent2接口

onStartNestedScroll()

ElemeNestedScrollLayout只处理Content部分里可滑动View的垂直方向的滑动。

    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
        return child.getId() == mLlContent.getId()&&
        axes==ViewCompat.SCROLL_AXIS_VERTICAL;
    }

onNestedPreScroll()

接下来就是处理滑动,上面效果分析提过:Content部分的上滑范围=[topBarHeight,contentTransY]、
下滑范围=[contentTransY,downEndY(即满屏高度-ivCloseHeight)]即滑动范围为[topBarHeight,downEndY];ElemeNestedScrollLayout要控制Content部分的TransitionY值要在范围内,具体处理如下。

Content部分里可滑动View往上滑动时:

1、如果Content部分当前TransitionY+View滑动的dy > topBarHegiht,ElemeNestedScrollLayout设置Content部分的TransitionY为Content部分当前TransitionY+View滑动的dy达到移动的效果来消费View的dy。

2、如果Content部分当前TransitionY+View滑动的dy = topBarHegiht,ElemeNestedScrollLayout同上操作。

3、如果Content部分当前TransitionY+View滑动的dy < topBarHegiht,ElemeNestedScrollLayout只消费部分dy(即Content部分当前TransitionY到topBarHeight差值),剩余的dy让View滑动消费。

Content部分里可滑动View往下滑动并且View已经不能往下滑动
(比如RecyclerView已经到顶部还往下滑)时:

1、如果Content部分当前TransitionY+View滑动的dy >= topBarHeight 并且 Content部分当前TransitionY+View滑动的dy <= downEndY,ElemeNestedScrollLayout设置Content部分的TransitionY为Content部分当前TransitionY+View滑动的dy达到移动的效果来消费View的dy。

2、Content部分当前TransitionY+View滑动的dy > downEndY,ElemeNestedScrollLayout只消费部分dy(即Content部分当前TransitionY到downEndY差值)。

下面是代码实现:

    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        float contentTransY = mLlContent.getTranslationY() - dy;
        //处理上滑
        if (dy > 0) {
            if (contentTransY >= topBarHeight) {
                translationByConsume(mLlContent, contentTransY, consumed, dy);
            } else {
                translationByConsume(mLlContent, topBarHeight, consumed, (mLlContent.getTranslationY() - topBarHeight));
            }
        }
        if (dy < 0 && !target.canScrollVertically(-1)) {
            //处理下滑
            if (contentTransY >= topBarHeight && contentTransY <= downEndY) {
                translationByConsume(mLlContent, contentTransY, consumed, dy);
            } else {
                translationByConsume(mLlContent, downEndY, consumed, downEndY - mLlContent.getTranslationY());
            }
        }
    }

    private void translationByConsume(View view, float translationY,
     int[] consumed, float consumedDy) {
        consumed[1] = (int) consumedDy;
        view.setTranslationY(translationY);
    }

就这样处理好了Content部分的滑动,接下来就处理Content部分的滑动过程中各种View的效果并对外暴露百分比监听接口,比如上滑折叠过程改变状态栏字体就需要用到。下面的范围值是针对Content部分的TransitionY,根据之前效果分析具体如下:

1、设置logo、收藏icon缩放,搜索icon、分享icon透明度范围[upAlphaScaleY,contentTransY]

2、设置搜索框、topBar背景,返回icon、拼团icon颜色渐范围[upAlphaGradientY,contentTransY]

3、设置Collapse Content部分透明度范围[contentTransY,downCollapsedAlphaY]

4、设置Shop Bar位移范围[contentTransY,downShopBarTransY]

5、设置收起按钮和滑动内容透明度[downContentAlphaY,downEndY]

下面是代码实现:

    ...
    private ArgbEvaluator iconArgbEvaluator;//返回icon、拼团icon颜色渐变的Evaluator
    private ArgbEvaluator topBarArgbEvaluator;//topbar颜色渐变的Evaluator

    public ElemeNestedScrollLayout(@NonNull Context context) {
        this(context, null);
    }

    public ElemeNestedScrollLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, -1);
    }

    public ElemeNestedScrollLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mParentHelper = new NestedScrollingParentHelper(this);

        iconArgbEvaluator = new ArgbEvaluator();
        topBarArgbEvaluator = new ArgbEvaluator();
        ...
    }

     @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        ...

        //根据upAlphaScalePro,设置logo、收藏icon缩放,搜索icon、分享icon透明度
        float upAlphaScalePro = getUpAlphaScalePro();
        alphaScaleByPro(upAlphaScalePro);

        //根据upAlphaGradientPro,设置topBar背景、返回icon、拼团icon颜色渐变值,搜索框透明度
        float upAlphaGradientPro = getUpAlphaGradientPro();
        alphaGradientByPro(upAlphaGradientPro);

        //根据downCollapsedAlphaPro,设置折叠内容透明度
        float downCollapsedAlphaPro = getDownCollapsedAlphaPro();
        alphaCollapsedContentByPro(downCollapsedAlphaPro);

        //根据downContentAlphaPro,设置滑动内容、收起按钮的透明度
        float downContentAlphaPro = getDownContentAlphaPro();
        alphaContentByPro(downContentAlphaPro);

        //根据downShopBarTransPro,设置购物内容内容位移
        float downShopBarTransPro = getDownShopBarTransPro();
        transShopBarByPro(downShopBarTransPro);

        //根据upCollapsedContentTransPro,设置折叠内容位移
        float upCollapsedContentTransPro = getUpCollapsedContentTransPro();
        transCollapsedContentByPro(upCollapsedContentTransPro);

        if (mProgressUpdateListener!=null){
            mProgressUpdateListener.onUpAlphaScaleProUpdate(upAlphaScalePro);
            mProgressUpdateListener.onUpAlphaGradientProUpdate(upAlphaGradientPro);
            mProgressUpdateListener.onDownCollapsedAlphaProUpdate(downCollapsedAlphaPro);
            mProgressUpdateListener.onDownContentAlphaProUpdate(downContentAlphaPro);
            mProgressUpdateListener.onDownShopBarTransProUpdate(downShopBarTransPro);
            mProgressUpdateListener.onUpCollapsedContentTransProUpdate(upCollapsedContentTransPro);
        }
    }

    /**
     * 根据upCollapsedContentTransPro,设置折叠内容位移
     */
    private void transCollapsedContentByPro(float upCollapsedContentTransPro) {
        float collapsedContentTranY = - (upCollapsedContentTransPro * (contentTransY - topBarHeight));
        translation(mCollapsedContent,collapsedContentTranY);
    }

    /**
     * 根据downShopBarTransPro,设置购物内容内容位移
     */
    private void transShopBarByPro(float downShopBarTransPro) {
        float shopBarTransY = (1-downShopBarTransPro) * shopBarHeight;
        translation(mShopBar,shopBarTransY);
    }

    /**
     * 根据downContentAlphaPro,设置滑动内容、收起按钮的透明度
     */
    private void alphaContentByPro(float downContentAlphaPro) {
        setAlpha(mViewPager,downContentAlphaPro);
        setAlpha(mStl,downContentAlphaPro);
        setAlpha(mIvClose,1-downContentAlphaPro);
        if (mIvClose.getAlpha()==0){
            mIvClose.setVisibility(GONE);
        }else {
            mIvClose.setVisibility(VISIBLE);
        }
    }

    /**
     * 根据downCollapsedAlphaPro,设置折叠内容透明度
     */
    private void alphaCollapsedContentByPro(float downCollapsedAlphaPro) {
        setAlpha(mClCollapsedHeader,downCollapsedAlphaPro);
        setAlpha(mRvCollapsed,1 - downCollapsedAlphaPro);
    }

    /**
     * 根据upAlphaGradientPro,设置topBar背景、返回icon、拼团icon颜色渐变值,搜索框透明度
     */
    private void alphaGradientByPro(float upAlphaGradientPro) {
        setAlpha(mTvSearch, upAlphaGradientPro);
        int iconColor = (int) iconArgbEvaluator.evaluate(
                upAlphaGradientPro,
                getContext().getResources().getColor(R.color.white),
                getContext().getResources().getColor(R.color.black)
        );
        int topBarColor = (int) topBarArgbEvaluator.evaluate(
                upAlphaGradientPro,
                getContext().getResources().getColor(R.color.trans_white),
                getContext().getResources().getColor(R.color.white)
        );
        mTopBar.setBackgroundColor(topBarColor);
        mIvBack.getDrawable().mutate().setTint(iconColor);
        mIvAssemble.getDrawable().mutate().setTint(iconColor);
    }

    /**
     * 根据upAlphaScalePro,设置logo、收藏icon缩放,搜索icon、分享icon透明度
     */
    private void alphaScaleByPro(float upAlphaScalePro) {
        float alpha = 1 - upAlphaScalePro;
        float scale = 1 - upAlphaScalePro;
        setAlpha(mIvSearch, alpha);
        setAlpha(mIvShare, alpha);
        setScaleAlpha(mIvLogo, scale, scale, alpha);
        setScaleAlpha(mVCollect, scale, scale, alpha);
    }

    private float getDownContentAlphaPro() {
        return (downEndY - MathUtils.clamp(mLlContent.getTranslationY(), downContentAlphaY, downEndY)) / (downEndY - downContentAlphaY);
    }

    private float getDownCollapsedAlphaPro() {
        return (downCollapsedAlphaY - MathUtils.clamp(mLlContent.getTranslationY(), contentTransY, downCollapsedAlphaY)) / (downCollapsedAlphaY -contentTransY);
    }

    private float getDownShopBarTransPro() {
        return (downShopBarTransY - MathUtils.clamp(mLlContent.getTranslationY(), contentTransY, downShopBarTransY)) / (downShopBarTransY -contentTransY);
    }

    private float getUpAlphaGradientPro() {
        return (upAlphaScaleY - MathUtils.clamp(mLlContent.getTranslationY(), upAlphaGradientY, upAlphaScaleY)) / (upAlphaScaleY-upAlphaGradientY);
    }

    private float getUpAlphaScalePro() {
        return (contentTransY - MathUtils.clamp(mLlContent.getTranslationY(), upAlphaScaleY, contentTransY)) / (contentTransY-upAlphaScaleY);
    }

    private float getUpCollapsedContentTransPro() {
        return (contentTransY - MathUtils.clamp(mLlContent.getTranslationY(), topBarHeight, contentTransY)) / (contentTransY-topBarHeight);
    }

    private void setAlpha(View view, float alpha){
        view.setAlpha(alpha);
    }

    private void setScale(View view ,float scaleY,float scaleX){
        view.setScaleY(scaleY);
        view.setScaleX(scaleX);
    }
    private void setScaleAlpha(View view ,float scaleY,float scaleX,float alpha){
        setAlpha(view,alpha);
        setScale(view,scaleY,scaleX);
    }

    private void translation(View view, float translationY) {
            view.setTranslationY(translationY);
    }

    public interface ProgressUpdateListener{

        void onUpCollapsedContentTransProUpdate(float pro);

        void onUpAlphaScaleProUpdate(float pro);

        void onUpAlphaGradientProUpdate(float pro);

        void onDownCollapsedAlphaProUpdate(float pro);

        void onDownContentAlphaProUpdate(float pro);

        void onDownShopBarTransProUpdate(float pro);
    }

onStopNestedScroll()

在下滑Content部分从初始状态转换到展开状态的过程中松手就会执行收起或展开的动画,这逻辑在onStopNestedScroll()实现,但注意如果动画未执行完毕手指再落下滑动时,应该在onNestedScrollAccepted()取消当前执行中的动画。


收起动画

如果Content部分的TransitionY没有超过downCollapsedAlphaY,执行收起Content部分动画效果,恢复到初始转态。

展开动画

如果Content部分的TransitionY超过了downCollapsedAlphaY,执行展开Content部分动画效果,转换到展开转态。
代码实现如下:

    ...
    public static final long ANIM_DURATION_FRACTION = 200L;
    private ValueAnimator restoreOrExpandAnimator;//收起或展开折叠内容时执行的动画
    
    public ElemeNestedScrollLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        ...
        restoreOrExpandAnimator = new ValueAnimator();
        restoreOrExpandAnimator.setInterpolator(new AccelerateInterpolator());
        restoreOrExpandAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                translation(mLlContent, (float) animation.getAnimatedValue());

                //根据downShopBarTransPro,设置购物内容内容位移
                float downShopBarTransPro = getDownShopBarTransPro();
                transShopBarByPro(downShopBarTransPro);

                //根据downCollapsedAlphaPro,设置折叠内容透明度
                float downCollapsedAlphaPro = getDownCollapsedAlphaPro();
                alphaCollapsedContentByPro(downCollapsedAlphaPro);

                //根据downContentAlphaPro,设置滑动内容、收起按钮的透明度
                float downContentAlphaPro = getDownContentAlphaPro();
                alphaContentByPro(downContentAlphaPro);

                if (mProgressUpdateListener!=null){
                    mProgressUpdateListener.onDownCollapsedAlphaProUpdate(downCollapsedAlphaPro);
                    mProgressUpdateListener.onDownContentAlphaProUpdate(downContentAlphaPro);
                    mProgressUpdateListener.onDownShopBarTransProUpdate(downShopBarTransPro);
                }
            }
        });
    }

    @Override
    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
        mParentHelper.onNestedScrollAccepted(child, target, axes, type);
        if (restoreOrExpandAnimator.isStarted()) {
            restoreOrExpandAnimator.cancel();
        }
    }

    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        mParentHelper.onStopNestedScroll(target, type);
        //如果不是从初始状态转换到展开状态过程触发返回
        if (mLlContent.getTranslationY() <= contentTransY) {
            return;
        }

        //根据百分比计算动画执行的时长
        float downCollapsedAlphaPro = getDownCollapsedAlphaPro();
        float downContentAlphaYPro = getDownContentAlphaPro();
        if (downCollapsedAlphaPro == 0) {
            expand((long) (downContentAlphaYPro * ANIM_DURATION_FRACTION));
        } else {
            restore((long) (downCollapsedAlphaPro * ANIM_DURATION_FRACTION));
        }
    }

     public void restore(long dur){
        if (restoreOrExpandAnimator.isStarted()) {
            restoreOrExpandAnimator.cancel();
        }
        restoreOrExpandAnimator.setFloatValues(mLlContent.getTranslationY(), contentTransY);
        restoreOrExpandAnimator.setDuration(dur);
        restoreOrExpandAnimator.start();
    }

    public void expand(long dur){
        if (restoreOrExpandAnimator.isStarted()) {
            restoreOrExpandAnimator.cancel();
        }
        restoreOrExpandAnimator.setFloatValues(mLlContent.getTranslationY(), downEndY);
        restoreOrExpandAnimator.setDuration(dur);
        restoreOrExpandAnimator.start();
    }

处理惯性滑动

NestedScrollingParent2处理惯性滑动的方式主要有两种:
一、在onNestedPreFling()或者onNestedFling()返回值为true消费掉。
二、在onNestedPreFling()和onNestedFling()返回值都为false的前提下,在onNestedPreScroll()或者onNestedScroll()消费掉,这种方式可以和普通的滑动共用逻辑代码。


上滑Fling到折叠状态

场景1:快速往上滑动Content部分的可滑动View产生惯性滑动,这和前面onNestedPreScroll()处理上滑的效果一模一样,因此可以复用逻辑,使用第二种方式处理。

折叠状态下滑Fling

场景2:在折叠状态并Content部分的可滑动View没有滑动到顶部尽头时,快速往下滑动Content部分的可滑动View产生惯性滑动滑到顶部尽头就停止了,这和前面onNestedPreScroll()处理下滑的效果类似,但多了个惯性滑动滑到顶部尽头就停止的条件判断,使用第二种方式处理。

下滑Fling到展开状态

场景3:快速往下滑动Content部分的可滑动View转化展开状态产生惯性滑动,这和前面onNestedPreScroll()处理下滑的效果类似,使用第二种方式处理,但注意在惯性滑动没有完全消费掉的时候,会不断触发onNestedPreScroll()来消费直到惯性滑动完全消费掉,所以当滑动到展开状态的时候要停止Content部分的View滑动因为这时已经是展开状态了,不需View继续滑动触发onNestedPreScroll(),注意NestedScrollView并没有暴露对外停止滑动的方法,只能反射获取它的OverScroller停止滑动。
下面是代码实现:

    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        float contentTransY = mLlContent.getTranslationY() - dy;
        //处理上滑和场景1
        if (dy > 0) {
            if (contentTransY >= topBarHeight) {
                translationByConsume(mLlContent, contentTransY, consumed, dy);
            } else {
                translationByConsume(mLlContent, topBarHeight, consumed, (mLlContent.getTranslationY() - topBarHeight));
            }
        }

        if (dy < 0 && !target.canScrollVertically(-1)) {
            //下滑时处理Fling,完全折叠时,下滑Recycler(或NestedScrollView) Fling滚动到列表顶部(或视图顶部)停止Fling,对应场景2
            if (type == ViewCompat.TYPE_NON_TOUCH&&mLlContent.getTranslationY() == topBarHeight) {
                stopViewScroll(target);
                return;
            }

            //处理下滑
            if (contentTransY >= topBarHeight && contentTransY <= downEndY) {
                translationByConsume(mLlContent, contentTransY, consumed, dy);
            } else {
                translationByConsume(mLlContent, downEndY, consumed, downEndY - mLlContent.getTranslationY());
                //对应场景3
                if (target instanceof NestedScrollView) {
                    stopViewScroll(target);
                }
            }
        }
        ...
    }

    /**
     * 停止View的滑动
     */
    private void stopViewScroll(View target){
        if (target instanceof RecyclerView) {
            ((RecyclerView) target).stopScroll();
        }
        if (target instanceof NestedScrollView) {
            try {
                Class clazz = ((NestedScrollView) target).getClass();
                Field mScroller = clazz.getDeclaredField("mScroller");
                mScroller.setAccessible(true);
                OverScroller overScroller = (OverScroller) mScroller.get(target);
                overScroller.abortAnimation();
            } catch (NoSuchFieldException | IllegalAccessException e) {
                e.printStackTrace();
            }
        }
    }
下滑Fling到初始状态回弹

场景4:快速往下滑动Content部分的可滑动View,从非折叠状态转化展开状态产生惯性滑动,因为有回弹效果,所以逻辑处理和onNestedPreScroll()不一样,使用第一种方式处理。

    ...
    private ValueAnimator reboundedAnim;//回弹动画

    public ElemeNestedScrollLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        ...
        reboundedAnim = new ValueAnimator();
        reboundedAnim.setInterpolator(new DecelerateInterpolator());
        reboundedAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                translation(mLlContent, (float) animation.getAnimatedValue());

                //根据upAlphaScalePro,设置logo、收藏icon缩放,搜索icon、分享icon透明度
                float upAlphaScalePro = getUpAlphaScalePro();
                alphaScaleByPro(upAlphaScalePro);

                //根据upAlphaGradientPro,设置topBar背景、返回icon、拼团icon颜色渐变值,搜索框透明度
                float upAlphaGradientPro = getUpAlphaGradientPro();
                alphaGradientByPro(upAlphaGradientPro);

                //根据downCollapsedAlphaPro,设置折叠内容透明度
                float downCollapsedAlphaPro = getDownCollapsedAlphaPro();
                alphaCollapsedContentByPro(downCollapsedAlphaPro);

                //根据downShopBarTransPro,设置购物内容内容位移
                float downShopBarTransPro = getDownShopBarTransPro();
                transShopBarByPro(downShopBarTransPro);

                //根据upCollapsedContentTransPro,设置折叠内容位移
                float upCollapsedContentTransPro = getUpCollapsedContentTransPro();
                transCollapsedContentByPro(upCollapsedContentTransPro);

                if (mProgressUpdateListener!=null){
                    mProgressUpdateListener.onUpAlphaScaleProUpdate(upAlphaScalePro);
                    mProgressUpdateListener.onUpAlphaGradientProUpdate(upAlphaGradientPro);
                    mProgressUpdateListener.onDownCollapsedAlphaProUpdate(downCollapsedAlphaPro);
                    mProgressUpdateListener.onDownShopBarTransProUpdate(downShopBarTransPro);
                    mProgressUpdateListener.onUpCollapsedContentTransProUpdate(upCollapsedContentTransPro);
                }
            }
        });
        reboundedAnim.setDuration(ANIM_DURATION_FRACTION);
    }

    @Override
    public boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed) {
        return false;
    }

    @Override
    public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
        if (velocityY<0){//往下滑动的惯性滑动
            float translationY = mLlContent.getTranslationY();
            float dy = translationY - velocityY;
            if (translationY >topBarHeight && translationY<= downFlingCutOffY) {//非折叠状态
                //根据dy设置动画结束值,只有dy>contentTransY才会有回弹,downFlingCutOffY是回弹的最大值
                if (dy<=contentTransY){
                    reboundedAnim.setFloatValues(translationY,dy);
                }else if (dy>contentTransY&&dy

释放资源

在View从Window上移除时候,执行要停止动画、释放监听者的操作。

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        if (restoreOrExpandAnimator.isStarted()) {
            restoreOrExpandAnimator.cancel();
            restoreOrExpandAnimator.removeAllUpdateListeners();
            restoreOrExpandAnimator = null;
        }
        
        if(reboundedAnim.isStarted()){
            reboundedAnim.cancel();
            reboundedAnim.removeAllUpdateListeners();
            reboundedAnim = null;
        }

        if (mProgressUpdateListener!=null){
            mProgressUpdateListener=null;
        }
    }

商家点餐页的嵌套滑动实现

效果分析

商家点餐页层级

商家点餐页布局主要有上图五部分组成,逻辑上的层级如图所示有3层。

商家点餐页TransY

滑动Content部分时利用View的TransitionY属性改变位置来消耗滑动值,根据Content部分的TransitionY设定各种范围从而计算百分比来执行位移、Alpha效果。下面来说明上图中变量的意义:

contentTransY;//滑动内容初始化TransY
iconTransY;//分享、关闭icon初始化transY
upEndIconTransY;//分享、关闭icon上滑最终transY
downFlingCutOffY;///从展开状态下滑产生fling时回弹到初始状态的最大值

ElemeFoodNestedScrollLayout

商家点餐页的嵌套滑的自定义View继承FrameLayout、实现NestedScrollingParent2接口,命名为ElemeFoodNestedScrollLayout。

布局

下面是布局要点,侧重于控件的尺寸和位置,完整布局请参考:ElemeFoodNestedScrollLayout布局


    
    
    

    
    
    

    
    
    
    

    
    
    

    
    
    

绑定需要做效果的View、引入Dimens、设置Content部分的初始化TransitionY

从上面图片能够分析出:关闭状态时,Content部分的TransY为满屏高度

public class ElemeFoodNestedScrollLayout extends FrameLayout implements NestedScrollingParent2 {
    ...
    //shopBar部分
    private View mShopBar;

    //content部分
    private View mNestedScrollView;
    private View mTvComm;
    private View mTvGoodCommRate;
    private View mTvCommDetail;
    private View mTvCommCount;
    private View mVLine;
    private View mTvFoodDetail;
    
    //expand部分
    private View mIvExpand;
    
    //icon部分
    private View mIvShare;
    private View mIvClose;

    //mask部分
    private View mVMask;

    private float shopBarHeight;//shopBar部分高度
    private float ivExpandHegiht;//ivExpand部分高度
    private float statusBarHeight;//状态栏高度
    private float iconTransY;//分享、关闭icon初始化transY
    private float contentTransY;//滑动内容初始化TransY
    private float downFlingCutOffY;//下滑时fling上部分回弹临界值
    private float upEndIconTransY;//分享、关闭icon上滑最终transY

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        shopBarHeight = getResources().getDimension(R.dimen.shop_bar_height);
        ivExpandHegiht = getResources().getDimension(R.dimen.iv_food_expand);
        contentTransY = getResources().getDimension(R.dimen.food_content_trans_y);
        iconTransY = getResources().getDimension(R.dimen.iv_food_icon_trans_y);
        statusBarHeight = getResources().getDimensionPixelSize(getResources().getIdentifier("status_bar_height", "dimen", "android"));
        downFlingCutOffY = contentTransY + dp2px(92);
        upEndIconTransY = statusBarHeight + dp2px(8);
        //因为开始就是关闭状态,设置Content部分的TransY为满屏高度
        mNestedScrollView.setTranslationY(getMeasuredHeight());
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mNestedScrollView = findViewById(R.id.ns);
        mShopBar = findViewById(R.id.cl_food_shop_bar);

        mTvComm = findViewById(R.id.t_comm);
        mTvGoodCommRate = findViewById(R.id.t_good_comm_rate);
        mTvCommDetail = findViewById(R.id.t_comm_detail);
        mTvFoodDetail = findViewById(R.id.t_food_detail);

        mTvCommCount = findViewById(R.id.t_comm_count);
        mVLine = findViewById(R.id.view_line);

        mIvExpand = findViewById(R.id.iv_food_expand);
        mIvShare = findViewById(R.id.iv_small_share);
        mIvClose = findViewById(R.id.iv_small_close);
        mVMask = findViewById(R.id.v_mask);
    }
}

实现NestedScrollingParent2接口

onStartNestedScroll()

ElemeFoodNestedScrollLayout只处理Content部分里可滑动View的垂直方向的滑动。

    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
        //处理Content部分里可滑动View的垂直方向的滑动
        return axes == ViewCompat.SCROLL_AXIS_VERTICAL && target.getId() == R.id.ns;
    }

onNestedPreScroll()

接下来就是处理滑动,上面效果分析提过:Content部分的上滑范围=[0,contentTransY]、
下滑范围=[contentTransY,即满屏高度]即滑动范围为[0,即满屏高度],ElemeFoodNestedScrollLayout要控制Content部分的TransitionY值要在范围内,之前的商家页已经说过了具体思路,这里不再赘述。

    ...
    private ProgressUpdateListener mProgressUpdateListener;
    
    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        float contentTransY = target.getTranslationY() - dy;
        //处理上滑
        if (dy > 0) {
            if (contentTransY >= 0) {
                translationByConsume(target, contentTransY, consumed, dy);
            } else {
                translationByConsume(target, 0, consumed, (target.getTranslationY() - 0));
            }
        }

        //处理下滑
        if (dy < 0 && !target.canScrollVertically(-1)) {
            if (contentTransY >= 0 && contentTransY < getMeasuredHeight()) {
                translationByConsume(target, contentTransY, consumed, dy);
            } else {
                translationByConsume(target, getMeasuredHeight(), consumed, getMeasuredHeight() - target.getTranslationY());
            }
        }

        alphaTransView(contentTransY);

        if (mProgressUpdateListener!=null){
            mProgressUpdateListener.onDownConetntCloseProUpdate(getDownConetntClosePro());
        }
    }

    private void alphaTransView(float transY) {
        float upCollapseTransPro = getUpExpandTransPro();
        //位移购物内容
        float shopBarTransY = (1 - upCollapseTransPro) * shopBarHeight;
        translation(mShopBar, shopBarTransY);

        //设置商品信息View的透明度变化
        setAlpha(mTvComm, upCollapseTransPro);
        setAlpha(mTvGoodCommRate, upCollapseTransPro);
        setAlpha(mTvCommDetail, upCollapseTransPro);
        setAlpha(mTvFoodDetail, upCollapseTransPro);
        setAlpha(mTvCommCount, 1 - upCollapseTransPro);
        setAlpha(mVLine, 1 - upCollapseTransPro);

        //位移share close两个Icon,设置展开icon透明度
        if (transY <= contentTransY) {
            float ivExpandUpTransY = upCollapseTransPro * -contentTransY;
            translation(mIvExpand, ivExpandUpTransY);
            setAlpha(mIvExpand, 1 - upCollapseTransPro);

            float iconTransY = upEndIconTransY + (1 - upCollapseTransPro) * (this.iconTransY - upEndIconTransY);
            translation(mIvShare, iconTransY);
            translation(mIvClose, iconTransY);

        } else if (transY > contentTransY && transY <= getMeasuredHeight()) {
            float ivExpandDowndTransY = (1 - getDownIvExpnadPro()) * ivExpandHegiht;
            translation(mIvExpand, ivExpandDowndTransY);

            float iconTransY = transY + dp2px(16);
            translation(mIvShare, iconTransY);
            translation(mIvClose, iconTransY);
        }
    }

    private float getDownConetntClosePro() {
        return (mNestedScrollView.getTranslationY() - contentTransY) / (getMeasuredHeight() - contentTransY);
    }

    private float getDownIvExpnadPro() {
        return ((contentTransY+ivExpandHegiht)-MathUtils.clamp(mNestedScrollView.getTranslationY(), contentTransY, contentTransY+ivExpandHegiht)) / ivExpandHegiht;
    }

    private float getUpExpandTransPro() {
        return (contentTransY - MathUtils.clamp(mNestedScrollView.getTranslationY(), 0, contentTransY)) / contentTransY;
    }

    public interface ProgressUpdateListener{
        void onDownConetntCloseProUpdate(float pro);
    }

onStopNestedScroll()

在从初始状态到展开状态的上滑过程中松手,若上滑百分比小于等于50%则执行恢复到初始状态的动画,否则执行转化到展开状态的动画;同理从初始状态到关闭状态下滑过程中松手,若下滑百分比小于等于50%则执行恢复到初始状态的动画,否则执行转化到关闭状态的动画;

    ...
    private ValueAnimator restoreOrExpandOrCloseAnimator;//收起或展开折叠内容时执行的动画
    private NestedScrollingParentHelper mParentHelper;

    public ElemeFoodNestedScrollLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mParentHelper = new NestedScrollingParentHelper(this);
        ...
        restoreOrExpandOrCloseAnimator = new ValueAnimator();
        restoreOrExpandOrCloseAnimator.setInterpolator(new AccelerateInterpolator());
        restoreOrExpandOrCloseAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                translation(mNestedScrollView, (float) animation.getAnimatedValue());
                alphaTransView(mNestedScrollView.getTranslationY());
                if (mProgressUpdateListener!=null){
                    mProgressUpdateListener.onDownConetntCloseProUpdate(getDownConetntClosePro());
                }
            }
        });
        restoreOrExpandOrCloseAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                int alpha=mNestedScrollView.getTranslationY() >= getMeasuredHeight()?0:1;
                setAlpha(mIvClose,alpha);
                setAlpha(mIvShare,alpha);
            }
        });
    }

    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        mParentHelper.onStopNestedScroll(target, type);
        float translationY = target.getTranslationY();
        if (translationY == contentTransY|| reboundedAnim.isStarted()|| restoreOrExpandOrCloseAnimator.isStarted()) {
            return;
        }

        long dur;
        if (translationY < contentTransY) {
            if (getUpExpandTransPro() <= 0.5f) {
                dur = (long) (getUpExpandTransPro() *  ANIM_DURATION_FRACTION);
                restore(dur);
            } else {
                dur = (long) ((1 - getUpExpandTransPro()) *  ANIM_DURATION_FRACTION);
                expand(dur);
            }
        } else {
            if (getDownConetntClosePro() >= 0.5f) {
                dur = (long) (getDownConetntClosePro() *  ANIM_DURATION_FRACTION);
                close(dur);
            } else {
                dur = (long) ((1 - getDownConetntClosePro()) *  ANIM_DURATION_FRACTION);
                restore(dur);
            }
        }
    }

    public void restore(long dur) {
        mIvClose.setClickable(true);
        mVMask.setClickable(true);
        mIvExpand.setClickable(true);
        if (restoreOrExpandOrCloseAnimator.isStarted()) {
            restoreOrExpandOrCloseAnimator.cancel();
        }
        restoreOrExpandOrCloseAnimator.setFloatValues(mNestedScrollView.getTranslationY(), contentTransY);
        restoreOrExpandOrCloseAnimator.setDuration(dur);
        restoreOrExpandOrCloseAnimator.start();
    }

    public void expand(long dur) {
        if (restoreOrExpandOrCloseAnimator.isStarted()) {
            restoreOrExpandOrCloseAnimator.cancel();
        }
        restoreOrExpandOrCloseAnimator.setFloatValues(mNestedScrollView.getTranslationY(), 0);
        restoreOrExpandOrCloseAnimator.setDuration(dur);
        restoreOrExpandOrCloseAnimator.start();
    }

    public void close(long dur) {
        mNestedScrollView.scrollTo(0,0);
        mIvClose.setClickable(false);
        mVMask.setClickable(false);
        mIvExpand.setClickable(false);
        if (restoreOrExpandOrCloseAnimator.isStarted()) {
            restoreOrExpandOrCloseAnimator.cancel();
        }
        restoreOrExpandOrCloseAnimator.setFloatValues(mNestedScrollView.getTranslationY(), getMeasuredHeight());
        restoreOrExpandOrCloseAnimator.setDuration(dur);
        restoreOrExpandOrCloseAnimator.start();
    }

处理惯性滑动

这里有两个场景需要使用onNestedPreFling()处理惯性滑动:
1、从展开状态下滑时处理回弹Fling,执行回弹动画;
2、从初始状态到关闭状态下滑百分比超过50%惯性滑动关闭;

    private ValueAnimator reboundedAnim;//回弹动画
    ...

    public ElemeFoodNestedScrollLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        ...
        reboundedAnim = new ValueAnimator();
        reboundedAnim.setInterpolator(new DecelerateInterpolator());
        reboundedAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                translation(mNestedScrollView, (float) animation.getAnimatedValue());
                alphaTransView(mNestedScrollView.getTranslationY());
                if (mProgressUpdateListener!=null){
                    mProgressUpdateListener.onDownConetntCloseProUpdate(getDownConetntClosePro());
                }
            }
        });
        reboundedAnim.setDuration(ANIM_DURATION_FRACTION);
    }

    @Override
    public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
        if (velocityY<0) {
            float translationY = target.getTranslationY();
            float dy = translationY - velocityY;
            //从展开状态下滑时处理回弹Fling,执行回弹动画
            if (translationY >= 0 && translationY <= downFlingCutOffY){
                if (dycontentTransY&&dydownFlingCutOffY){
                restore((long) dur);
                return true;
            }else {
                close((long) dur);
                return true;
            }
        }
        return false;
    }

释放资源

在View从Window上移除时候,执行要停止动画、释放监听者的操作。

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        if (restoreOrExpandOrCloseAnimator.isStarted()) {
            restoreOrExpandOrCloseAnimator.cancel();
            restoreOrExpandOrCloseAnimator.removeAllUpdateListeners();
            restoreOrExpandOrCloseAnimator.removeAllListeners();
            restoreOrExpandOrCloseAnimator = null;
        }

        if (reboundedAnim.isStarted()) {
            reboundedAnim.cancel();
            reboundedAnim.removeAllUpdateListeners();
            restoreOrExpandOrCloseAnimator = null;
        }

        if (mProgressUpdateListener!=null){
            mProgressUpdateListener=null;
        }
    }

ElemeNestedScrollLayout和ElemeFoodNestedScrollLayout联动

从效果预览看的出,在点击商品时ElemeFoodNestedScrollLayout内容弹出,而背后有蒙层和ElemeFoodNestedScrollLayout内容的缩放效果,它们两都提供滑动百分比监听者,所以这部分逻辑应该在Activity处理:

    mElemeFoodNsLayout.setProgressUpdateListener(new ElemeFoodNestedScrollLayout.ProgressUpdateListener() {
        @Override
        public void onDownConetntCloseProUpdate(float pro) {
            mElemeNSLayout.setScaleX(0.9f+(pro*0.1f));
            mElemeNSLayout.setScaleY(0.9f+(pro*0.1f));
            mVMask.setAlpha(1-pro);
            if (pro==1){
                mVMask.setVisibility(View.GONE);
            }else {
                mVMask.setVisibility(View.VISIBLE);
            }
        }
    });

总结

NestedScrolling机制结合View的Alpha、Scale、Transitio、动画等等可以弄出各种神奇而体验好的交互,当自定义View遇上NestedScrolling机制,给你不一样的精彩,只要理清思路、分析问题、一步一步来解决或许就会守得云开见月明,由于本人水平有限仅给各位提供参考,希望能够抛砖引玉,如果有什么可以讨论的问题可以在评论区留言或联系本人,本文饿了么例子图片素材来源于h5饿了么和阿里巴巴矢量素材库。

你可能感兴趣的:(浅析NestedScrolling嵌套滑动机制之实践篇-仿写饿了么商家详情页)