第一站小红书图片裁剪控件之二,自定义CoordinatorLayout联动效果

3,403 阅读17分钟

本篇续:

第一站小红书图片裁剪控件,深度解析大厂炫酷控件

先来看看几张效果图:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
emmmm,想感受高清丝滑的动画效果,有以下两种方式:

https://github.com/HpWens/MeiWidgetView 欢迎Star

https://www.pgyer.com/zKF4 APK地址

在前篇中已经讲了相关手势的处理,本篇重点讲解留白,列表联动效果。

在上一篇中由于篇幅原因,图片左下角裁剪状态的切换并没有讲解,通过分析小红书,有以下4种状态:

图1
图2
在这里插入图片描述
在这里插入图片描述
分别对应:裁切,填满,留白,充满。这里的裁切,填满(是楼主大大取的中文名字,不一定准确),他们分别对应图1,图2。那么4种状态怎么控制图片的显示?

  • 裁切,改变图片的显示区域,在前文中已经提到图片有任意尺寸,默认显示的区域为宽高相等的矩形区域(正方形区域),而在裁切状态下,显示的区域为宽高不相等的区域。以最小边为基准,剩余的一边缩至原来的四分之三,那么什么又是基准呢?这里以简单的公式来理解:
 图片宽度 = a 
 图片高度 = b

如果 a > b 则以宽度为基准,反之以高度有基准。以==demo==中的图片为例:

ic_gril.png
图片分辨率为 360*240 宽大于高的图片,那么以宽度为基准,控件高度缩放四分之三,最后裁切的效果如下:
在这里插入图片描述

  • 留白,在图片四周有白边,保证图片一边铺满控件,另一边出现白边,白边的区域大小与图片的实际尺寸有关。

  • 填满,与充满同为默认状态,铺满控件,显示区域为宽高相等的矩形区域(正方形区域)。

对了,这里有一点需要说明,裁切状态下控件一边缩放至四分之三长度,与小红书是有差异的,小红书是根据图片实际尺寸改变裁切区域,取的最小值才是四分之三。

构思代码

裁切

裁切,本质就是改变控件显示区域,那么怎么改变控件显示区域,大家一定会想到改变控件大小,对自定义view绘制流程熟悉的小伙伴肯定会知道,在测量onMeasure方法中通过改变MeasureSpec.getSize()测量大小从而改变控件大小。但小编并不想改变控件大小,而是想改变控件的显示区域,用官方说法,就是改变控件的布局区域。测量 - 布局 - 绘制,自定义view的三步骤,布局相关方法如下:

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
    }
    // onLayout layout 差异省略,这里重写layout方法
    @Override
    public void layout(int l, int t, int r, int b) {
        super.layout(l, t, r, b);
    }

我们可以改变 super.layout(l, t, r, b);l, t, r, b 的值来控制控件的显示区域。注意这里 r, b 含义:

 r = l + 控件宽度 
 b = t + 控件高度

填满、充满

填满、充满同默认状态。

留白

一边铺满,一边留白边,白边的区域大小跟图片尺寸有关,图片尺寸比例越接近1.0白边越小,反之越大。记得,在前篇中为了保证图片铺满控件,缩放取值如下:

 Math.max( 控件宽度/图片宽度 , 控件高度/图片高度 )

那么只保证一边铺满,只需要取最小值就可以了:

 Math.min( 控件宽度/图片宽度 , 控件高度/图片高度 )

编写代码

裁切

裁切分为以下两步:

  1. 判定宽或高为基准边:
   // 获取图片的宽度和高度
   Drawable drawable = getDrawable();
   if (null == drawable) {
       return;
   }
   int drawableWidth = drawable.getIntrinsicWidth();
   int drawableHeight = drawable.getIntrinsicHeight();
   // mIsWidthLarger  true 宽度有基准边 高度裁剪   false 高度为基准边 宽度裁剪
   mIsWidthLarger = drawableWidth > drawableHeight;
  1. 重写layout方法,改变显示宽高:
    @Override
    public void layout(int l, int t, int r, int b) {
        if (mIsCrop && l == 0 && t == 0) {
            float scaleRatio = 1.0F;
            float defaultRatio = 1.0F;

            if (mIsWidthLarger) {
                // 高度为原高度 3/4 居中
                scaleRatio = defaultRatio + defaultRatio / 4F;
            } else {
                // 宽度为原宽度 3/4 居中
                scaleRatio = defaultRatio - defaultRatio / 4F;
            }

            int width = r - l;
            int height = b - t;

            if (scaleRatio > defaultRatio) {
                int offsetY = (int) (height * (scaleRatio - defaultRatio) / 2F);
                // 除了2  上加下减  改变高度显示区域
                t += offsetY;
                b -= offsetY;
            } else if (scaleRatio < defaultRatio) {
                int offsetX = (int) (width * (defaultRatio - scaleRatio) / 2F);
                // 左加右减  改变宽度显示区域
                l += offsetX;
                r -= offsetX;
            }
        }
        super.layout(l, t, r, b);
    }

有不明白的地方,请参考注释或留言,效果图就像这样:

在这里插入图片描述

留白

填满、充满为默认状态,在前篇已经讲解过了。留白,一边留白一边铺满,那么图片的缩放比例就会发生改变,还记得前篇中的缩放比例吗:

   Math.max(控件宽度/图片宽度,控件高度/图片高度)

这样就能保证图片最小边铺满控件,留白效果恰恰相反,图片最小边不需要铺满控件(两边留白,居中对齐),同时还需要保证非最小边铺满控件,那么图片缩放比例应该取最小值,就像这样:

    @Override
    public void onGlobalLayout() {
            // 省略...... 
            // 图片缩放比
            mBaseScale = mIsLeaveBlank ? Math.min((float) viewWidth / drawableWidth, (float) viewHeight / drawableHeight) : Math.max((float) viewWidth / drawableWidth, (float) viewHeight / drawableHeight);
  
    }

mIsLeaveBlank 参数控制是否留白,true 取最小值;false 取最大值。

留白改变了图片显示区域,那么==边界检测== 的越界判定条件也会发生变化,让我们一起来回忆一下,非留白越界判定条件:

    // 边界检测
    private void boundCheck() {
        // 获取图片矩阵
        RectF rectF = getMatrixRectF();
        if (rectF.left >= 0) {
            // 左越界
        }
        if (rectF.top >= 0) {
            // 上越界
        }
        if (rectF.right <= getWidth()) {
            // 右越界
        }
        if (rectF.bottom <= getHeight()) {
            // 下越界
        }
    }

那么留白的越界判定条件又是什么呢?先来看张图,注意左右留白的红色实线:

在这里插入图片描述
如上图,留白的情况下,左右越界的条件就需要左加右减红线部分,那么红线的长度又为多少呢?

   红线长度 = (控件宽度 -  图片宽度) / 2 

获取到留白长度,左越界的条件就需要加上留白的长度:

        RectF rectF = getMatrixRectF();
        
        float rectWidth = rectF.right - rectF.left;
        float rectHeight = rectF.bottom - rectF.top;

        // 获取到左右留白的长度
        int leftLeaveBlankLength = (int) ((getWidth() - rectWidth) / 2);
        leftLeaveBlankLength = leftLeaveBlankLength <= 0 ? 0 : leftLeaveBlankLength;
        
        float leftBound = mIsLeaveBlank ? leftLeaveBlankLength : 0;
        if (rectF.left >= 0 + leftBound) {
            // 左越界
            startBoundAnimator(rectF.left, 0 + leftBound, true);
        }

右越界需要减去留白的长度:

        float rightBound = mIsLeaveBlank ? getWidth() - leftLeaveBlankLength : getWidth();
        if (rectF.right <= rightBound) {
            // 右越界
            startBoundAnimator(rectF.left, rightBound - rectWidth, true);
        }

上下越界的情况同左右越界的情况,好了,来看下效果图:

在这里插入图片描述

缓存,压缩,保存裁剪图片

缓存

有关LruCache的介绍,郭霖大神的 Android DiskLruCache完全解析,硬盘缓存的最佳方案 这篇文章依旧记忆犹新。使用非常简单:

    // 图片缓存
   private LruCache<String, Bitmap> mLruCache;
   //  根据实际情况 设置 maxSize 大小
   mLruCache = new LruCache<>(Integer.MAX_VALUE);
   /**
    * @param path 图片地址
    */
   public synchronized void setImagePath(String path) {
       if (path != null && !path.equals("")) {
           Bitmap lruBitmap = mLruCache.get(path);
           if (lruBitmap == null) {
               // 图片压缩
               Bitmap bitmap = BitmapUtils.getCompressBitmap(getContext(), path);
               mLruCache.put(path, bitmap);
               lruBitmap = bitmap;
           }
           if (lruBitmap != null) {
               mFirstLayout = true;
               mMaxScale = 3.0F;
               // 根据实际情况改变留白裁切状态
               setImageBitmap(lruBitmap);
               onGlobalLayout();
           }
       }
   }
   

清除缓存:

    @Override
    protected void onDetachedFromWindow() {
        // 清除缓存
        if (mLruCache != null) {
            mLruCache.evictAll();
        }
    }

压缩

相信有关图片的压缩大家也是知根知底,这里就简单的贴下代码:

    public static Bitmap getCompressBitmap(Context context, String path) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        // 不加载到内存中
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeFile(path, options);
        // 判定是否是横竖图
        boolean verEnable = options.outWidth < options.outHeight;
        int screenWidth = context.getResources().getDisplayMetrics().widthPixels;
        int screenHeight = context.getResources().getDisplayMetrics().heightPixels;
        options.inSampleSize = BitmapUtils.calculateInSampleSize(options, verEnable ? screenWidth : screenHeight, verEnable ? screenHeight : screenWidth);
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeFile(path, options);
    }

裁剪图片

最终我们需要得到,控件区域内的图片并转换成bitmap,我们可以借鉴以下方法:

    /**
     * @param leaveBlankColor 留白区域颜色
     * @return @return view转换成bitmap
     */
    public Bitmap convertToBitmap(int leaveBlankColor) {
        int w = getWidth();
        int h = getHeight();
        Bitmap bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
        Canvas c = new Canvas(bmp);
        c.drawColor(leaveBlankColor);
        layout(0, 0, w, h);
        draw(c);
        return bmp;
    }

在小红书中如果再次切到选中的图片,图片处于上次操作状态(记忆),说的简明点,图片的x,y轴平移以及缩放比同上次操作一样,怎么实现呢,需要保存与恢复图片位置缩放比信息。

保存:

    /**
     * 获取到位置信息
     *
     * @return float[2] = { x坐标, y坐标 }
     */
    public float[] getLocation() {
        float[] values = new float[9];
        mMatrix.getValues(values);
        return new float[]{values[Matrix.MTRANS_X], values[Matrix.MTRANS_Y]};
    }

    /**
     * @return 获取图片缩放比
     */
    private float getScale() {
        float[] values = new float[9];
        mMatrix.getValues(values);
        return values[Matrix.MSCALE_X];
    }

恢复:

    /**
     * 恢复位置信息
     *
     * @param x     图片平移x坐标
     * @param y     图片平移y坐标
     * @param scale 图片当前缩放比
     */
    public void restoreLocation(float x, float y, float scale) {
        float[] values = new float[9];
        mMatrix.getValues(values);

        values[Matrix.MSCALE_X] = scale;
        values[Matrix.MSCALE_Y] = scale;

        values[Matrix.MTRANS_X] = x;
        values[Matrix.MTRANS_Y] = y;

        mMatrix.setValues(values);
        setImageMatrix(mMatrix);
    }

图片裁剪控件还有一些细节,这里就不一一讲解了,有什么疑问,欢迎留言讨论?接下来重点讲解列表联动效果。

列表联动

在这里插入图片描述
相信大家第一眼看到这个效果,一定会想到CoordinatorLayout的联动效果,没错,小编刚开始也是想通过CoordinatorLayout去实现这个效果,然后就没有然后了,不知道是不是自己的姿势不对,最终嗝屁了。这个糟老头坏得很,白白花费了我大量时间

最开始并没有想到通过自定义view来实现类似CoordinatorLayout联动效果,而是一头扎进去研究CoordinatorLayout,查阅源码,断点分析,研究Behavior等,越走越远,越想越复杂,自己离真理越来越远。

心中一直有一个念头,为啥小红书可以实现,自己却不行?是不是思路有问题?于是我再次用view层级分析工具,分析小红书视图层级:

在这里插入图片描述
当我看到这里,心里那个畅快,原来小红书也没有使用CoordinatorLayout,而是用的LinearLayout线性布局,如果是CoordinatorLayout这里应该显示ViewGroup,基本可以肯定小红书是通过自定义LinearLayout来实现列表联动效果。

接下来拆分效果,同CoordinatorLayout联动类似,同样有展开与收起两种状态,支持 ==“甩”== filing 效果,在展开状态下:

xml布局层级:

    <LinearLayout >
    
        <com.demo.mcropimageview.MCropImageView />
        
        <android.support.v7.widget.RecyclerView />

    </LinearLayout>
  1. 未触碰MCropImageView区域,RecyclerView消费滑动事件,滚动列表

  2. 在RecyclerView区域向上滑动,触碰到MCropImageView区域,RecyclerView与MCropImageView跟随手指移动,向上滑动移出屏幕;向下滑动则移入屏幕,当MCropImageView完全展示,MCropImageView停止移动,如果手指移动到RecyclerView区域,则消费滑动事件。

收起状态:

  1. 未滑动到RecyclerView顶部,RecyclerView自身消费滑动事件

  2. 滑动到RecyclerView顶部并向下滑动,RecyclerView与MCropImageView跟随手指移动,向下滑动移入屏幕,向上滑动移出屏幕,当MCropImageView完全移出屏幕,继续向上滑动,则RecyclerView消费滑动事件

大多数情况下,当我们要做一个View跟随手指移动的效果时,都是直接setOnTouchListener或者直接重写onTouchEvent去实现的,但这种方式用在我们即将要做的这个效果上,就很不合适了,因为因为我们是要做到可以作用在任意一个View上的(这里指RecyclerView与MCropImageView),这样一来,如果目标View本来就已经重写了OnTouchEvent或者设置了OnTouchListener,就很可能会滑动冲突,而且还非常不灵活,这个时候,使用自定义ViewGroup的方式是最佳选择。上文中已经明确了使用自定义LinearLayout来实现列表的联动效果。

构思代码

联动,联动,那么第一个问题就是解决, 的问题,怎样让view动起来?emmmm,这个难不倒我,动态改变view在父控件中的位置信息,在view中提供了一系列的方法来让view动起来:

scrollBy,scrollTo,setTranslation,layout,offsetTopAndBottom,setScrollY等方法,效果图上在手指抬起的时候,view会根据当前的滑动距离惯性滑动,那么借助OverScroller类实现惯性滑动就非常容易了。

知道了怎么动,那么动的距离呢,与RecyclerView滑动有关,重写onTouchEvent获取滑动偏移量,RecyclerView的父控件根据偏移量进行移动,在手指抬起时,根据偏移量判定父控件是否展开,收起。

当手指松开,借助VelocityTracker获得滑动速率,如果速率大于指定值,则判定为 “甩”,并通过Scroller来进行惯性移动,同时改变展开,收起状态。

如手指松开后滑动速率低于指定值,则视为 “放手”,这时候根据getScrollY是否大于指定值,并通过Scroller来进行展开或收起的惯性移动。

大概过程就是这样,接下来开工写代码洛~

起名字

怎么样才能取一个接地气的名字呢?我看就叫CoordinatorLinearLayout ,同时还需要自定义RecyclerView,我们就叫它,CoordinatorRecyclerView。同时还给这两个名字卜了一挂,哈哈,大吉还不错。

编写代码

创建CoordinatorRecyclerView

好,那我们来看看CoordinatorRecyclerView应该怎么写: 先是成员变量:

    private int mTouchSlop = -1;
    private VelocityTracker mVelocityTracker;
    // 是否重新测量用于改变RecyclerView的高度
    private boolean mIsAgainMeasure = true;
    // 是否展开 默认为true
    private boolean mIsExpand = true;
    // 父类最大的滚动区域 = 裁剪控件的高度
    private int mMaxParentScrollRange;
    // 父控件在y方向滚动的距离
    private int mCurrentParenScrollY = 0;
    // 最后RawY坐标
    private float mLastRawY = 0;
    private float mDeltaRawY = 0;
    // 是否消费touch事件 true 消费RecyclerView接受不到滚动事件
    private boolean mIsConsumeTouchEvent = false;
    // 回调接口
    private OnCoordinatorListener mListener;

再到构造方法:

    public CoordinatorRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        // 用于处理手势filing动作
        mVelocityTracker = VelocityTracker.obtain();
        // 最大滑动范围 = 图片裁剪控件高度 (图片裁剪控件是宽高相等)
        mMaxParentScrollRange = context.getResources().getDisplayMetrics().widthPixels;
    }

通过上文的构思,CoordinatorRecyclerView暴露滚动,“甩” 的接口方法:

    public interface OnCoordinatorListener {
        /**
         * @param y                    相对RecyclerView的距离
         * @param deltaY               偏移量
         * @param maxParentScrollRange 最大滚动距离
         */
        void onScroll(float y, float deltaY, int maxParentScrollRange);

        /**
         * @param velocityY y方向速度
         */
        void onFiling(int velocityY);
    }

重写onTouchEvent方法:

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        switch (e.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 重置数据 由于篇幅原因 省略相应代码 ......
                break;
            case MotionEvent.ACTION_MOVE:
                // y 相对于 RecyclerView y坐标
                float y = e.getY();
                measureRecyclerHeight(y);

                if (mLastRawY == 0) {
                    mLastRawY = e.getRawY();
                }

                mDeltaRawY = mLastRawY - e.getRawY();

                if (mIsExpand) {
                    // 展开
                    mListener.onScroll(y, mDeltaRawY, mMaxParentScrollRange);
                } else {
                    // 收起 canScrollVertically 判定是否滑动到底部
                    if (!mIsConsumeTouchEvent && !canScrollVertically(-1)) {
                        mIsConsumeTouchEvent = true;
                    }
                    if (mIsConsumeTouchEvent && mDeltaRawY != 0) {
                        mListener.onScroll(y, mDeltaRawY, mMaxParentScrollRange);
                    }
                }

                // 处于非临界状态
                mIsConsumeTouchEvent = mCurrentParenScrollY > 0 & mCurrentParenScrollY < mMaxParentScrollRange;
                mVelocityTracker.addMovement(e);
                mLastRawY = e.getRawY();

                if (y < 0 || mIsConsumeTouchEvent) {
                    return false;
                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                // 重置数据
                resetData();

                mLastRawY = 0;

                // 处理滑动速度
                mVelocityTracker.addMovement(e);
                mVelocityTracker.computeCurrentVelocity(1000);

                int velocityY = (int) Math.abs(mVelocityTracker.getYVelocity());
                mListener.onFiling(mDeltaRawY > 0 ? -velocityY : velocityY);
                mDeltaRawY = 0;
                y = e.getY();

                if (y < 0) {
                    return false;
                }
                break;
        }
        return super.onTouchEvent(e);
    }

可以看到,ACTION_MOVE事件中通过e.getY()来获取相对父类的y轴坐标,前后两次e.getRawY()值获取偏移量,在展开状态下,暴露接口onScroll方法,在收起状态下,根据是否滑动到底部且偏移量不为0,暴露接口onScroll方法;在ACTION_UP事件中获取手指抬起的速度与方向暴露onFiling接口方法。注意,onTouchEvent方法的返回值,如果返回false,RecyclerView向下传递消费事件(不能滑动)。

有一个细节大家是否注意到了,RecyclerView的高度在父类展开,收起过程中并不一样,如下图,在非完全展开的状态下,高度为绿色+粉丝区域;在完全展开状态下,高度为绿色区域。

08.png
相关代码如下:

    /**
     * @param y 手指相对RecyclerView的y轴坐标
     *          y <= 0 表示手指已经滑出RecyclerView顶部
     */
    private void measureRecyclerHeight(float y) {
        if (y <= 0 && mIsAgainMeasure) {
            if (getHeight() < mMaxParentScrollRange && mIsExpand) {
                mIsAgainMeasure = false;
                getLayoutParams().height = getHeight() + mMaxParentScrollRange;
                requestLayout();
            }
        }
    }
    
    // 重置高度
    public void resetRecyclerHeight() {
        if (getHeight() > mMaxParentScrollRange && mIsExpand && mIsAgainMeasure) {
            getLayoutParams().height = getHeight() - mMaxParentScrollRange;
            requestLayout();
        }
    }

接下来看看父类CoordinatorLinearLayout怎么写。

创建CoordinatorLinearLayout

在上文中已经提及到CoordinatorLinearLayout继承LinearLayout,功能相对简单,根据CoordinatorRecyclerView暴露的接口方法进行惯性滑动,同样先是成员变量:

    // 是否展开
    private boolean mIsExpand;
    private OverScroller mOverScroller;
    // 快速抛的最小速度
    private int mMinFlingVelocity;
    // 滚动最大距离 = 图片裁剪控件的高度
    private int mScrollRange;
    // 滚动监听接口
    private OnScrollListener mListener;
    // 最大展开因子
    private static final int MAX_EXPAND_FACTOR = 6;
    // 滚动时长
    private static final int SCROLL_DURATION = 500;

构造方法,相关变量的初始化:

    public CoordinatorLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        mOverScroller = new OverScroller(context);
        mMinFlingVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity();
        // 设置默认值 =  图片裁剪控件的宽度
        mScrollRange = context.getResources().getDisplayMetrics().widthPixels;
    }

onScroll方法:

    /**
     * @param y                    相对RecyclerView的距离
     * @param deltaY               偏移量
     * @param maxParentScrollRange 最大滚动距离
     */
    public void onScroll(float y, float deltaY, int maxParentScrollRange) {
        int scrollY = getScrollY();
        int currentScrollY = (int) (scrollY + deltaY);

        if (mScrollRange != maxParentScrollRange) {
            mScrollRange = maxParentScrollRange;
        }

        // 越界检测
        if (currentScrollY > maxParentScrollRange) {
            currentScrollY = maxParentScrollRange;
        } else if (currentScrollY < 0) {
            currentScrollY = 0;
        }

        // 处于展开状态
        if (y <= 0) {
            setScrollY(currentScrollY);
        } else if (y > 0 && scrollY != 0) { // 处于收起状态
            setScrollY(currentScrollY);
        }

        if (mListener != null) {
            mListener.onScroll(getScrollY());
        }
    }

先获取到y轴方向滑动值,然后滑动值的最大最小判定,最后根据展开,收起状态设置滑动值并同时暴露滑动值。

onFiling方法:

    /**
     * @param velocityY y方向速度
     */
    public void onFiling(int velocityY) {
        int scrollY = getScrollY();
        // 判定非临界状态
        if (scrollY != 0 && scrollY != mScrollRange) {

            // y轴速度是否大于最小抛速度
            if (Math.abs(velocityY) > mMinFlingVelocity) {
                if (velocityY > mScrollRange || velocityY < -mScrollRange) {
                    startScroll(velocityY > mScrollRange);
                } else {
                    collapseOrExpand(scrollY);
                }
            } else {
                collapseOrExpand(scrollY);
            }
        }
    }

在手指抬起时,先获取y轴方向滑动值,在展开与收起的过程当中,根据RecyclerView返回的y方向速度与 ==“甩”== 的最小值比较。如果小于最小值,则根据滑动值进行惯性滑动;反之,大于最小值,并在(mScrollRange , -mScrollRange)区间之外,分别展开与收起,在区间之类同样根据滑动值进行惯性滑动。

   /**
     * 展开或收起
     *
     * @param scrollY
     */
    private void collapseOrExpand(int scrollY) {
        // MAX_EXPAND_FACTOR = 6
        int maxExpandY = mScrollRange / MAX_EXPAND_FACTOR;
        if (isExpanding()) {
            startScroll(scrollY < maxExpandY);
        } else {
            startScroll(scrollY < (mScrollRange - maxExpandY));
        }
    }

在展开与收起状态下,根据滑动值scrollY是否大于指定值来控制展开与收起。

    /**
     * 开始滚动
     *
     * @param isExpand 是否展开
     */
    private void startScroll(boolean isExpand) {
        mIsExpand = isExpand;

        if (mListener != null) {
            mListener.isExpand(isExpand);
            if (mIsExpand) {
                // 必须保证滚动完成 再触发回调
                postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        mListener.completeExpand();
                    }
                }, SCROLL_DURATION);
            }
        }

        if (!mOverScroller.isFinished()) {
            mOverScroller.abortAnimation();
        }

        int dy = isExpand ? -getScrollY() : mScrollRange - getScrollY();
        // SCROLL_DURATION = 500
        mOverScroller.startScroll(0, getScrollY(), 0, dy, SCROLL_DURATION);
        postInvalidate();
    }

首先根据isExpand暴露isExpand接口方法,在展开状态下并且惯性滚动完成时暴露completeExpand接口方法,然后根据是否展开获取滚动值,最后调用mOverScroller.startScroll方法进行惯性滚动并重写computeScroll方法:

    @Override
    public void computeScroll() {
        // super.computeScroll();
        if (mOverScroller.computeScrollOffset()) {
            setScrollY(mOverScroller.getCurrY());
            postInvalidate();
        }

    }

相关接口方法如下:

    public interface OnScrollListener {
        void onScroll(int scrollY);
        /**
         * @param isExpand 是否展开
         */
        void isExpand(boolean isExpand);
        // 完全展开
        void completeExpand();
    }

CoordinatorRecyclerView与CoordinatorLinearLayout接口实现如下:

        // 实现回调接口
        mRecyclerView.setOnCoordinatorListener(new CoordinatorRecyclerView.OnCoordinatorListener() {
            @Override
            public void onScroll(float y, float deltaY, int maxParentScrollRange) {
                mCoordinatorLayout.onScroll(y, deltaY, maxParentScrollRange);
            }

            @Override
            public void onFiling(int velocityY) {
                mCoordinatorLayout.onFiling(velocityY);
            }
        });

        mCoordinatorLayout.setOnScrollListener(new CoordinatorLinearLayout.OnScrollListener() {
            @Override
            public void onScroll(int scrollY) {
                mRecyclerView.setCurrentParenScrollY(scrollY);
            }

            @Override
            public void isExpand(boolean isExpand) {
                mRecyclerView.setExpand(isExpand);
            }

            @Override
            public void completeExpand() {
                mRecyclerView.resetRecyclerHeight();
            }
        });

到这里,联动效果就差不多实现了,先来看看效果:

在这里插入图片描述
在感受丝滑的过程中,发现了一个很奇怪的问题。如下图:
在这里插入图片描述
问题:点击RecyclerView的子view,点击事件失效。猜测,滑出了RecyclerView区域,事件ACTION_CANCEL执行,导致再次点击RecyclerView区域,事件标志Flag重置的原因造成 。同时新草app也同样存在改问题,小红书却不存在。具体原因小编会具体跟进事件传递机制,查找相关资料给出大家一个合理的解释。

下面,小编给出自己的兼容方案,既然能够拿到RecyclerView触摸点的坐标,那么可以通过坐标判定在哪个RecyclerView的子view中,然后调用performClick方法,模拟点击事件:

    /**
     * @param recyclerView
     * @param touchX
     * @param touchY
     */
    public void handlerRecyclerInvalidClick(RecyclerView recyclerView, int touchX, int touchY) {
        if (recyclerView != null && recyclerView.getChildCount() > 0) {
            for (int i = 0; i < recyclerView.getChildCount(); i++) {
                View childView = recyclerView.getChildAt(i);
                if (childView != null) {
                    if (childView != null && isTouchView(touchX, touchY, childView)) {
                        childView.performClick();
                        return;
                    }
                }
            }
        }
    }

    // 触摸点是否view区域内
    private boolean isTouchView(int touchX, int touchY, View view) {
        Rect rect = new Rect();
        view.getGlobalVisibleRect(rect);
        return rect.contains(touchX, touchY);
    }

好了,本篇文章到此结束,明天是妇女节,祝程序员嫂子节日快乐!

有错误的地方请指出,多谢~

Github地址:https://github.com/HpWens/MeiWidgetView 欢迎 star

在这里插入图片描述

扫一扫 关注我的公众号 新号希望大家能够多多支持我~