SkillAgentSearch skills...

MCropImageView

炫酷的小红书图片裁剪控件

Install / Use

/learn @HpWens/MCropImageView
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

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

先来看两张效果图:

在这里插入图片描述

在这里插入图片描述

哈哈,就是这样了。效果差了一些,感兴趣的小伙伴们可以运行代码感受丝滑与弹性。前段时间在竞品小红书上看到了这样的效果:图片可以跟随手指移动,双指可以(无限)放大,缩小,还可以挤压,手指抬起后还有一个有趣的效果,图片回弹。。。一直想撸一个手势的控件,正好可以模仿小红书图片裁剪控件,话不多说,撸起袖子就是干。

本系列共有两篇,在第二篇会重点讲解与RecyclerView的联动效果,先放一张效果图,感兴趣的小伙伴们继续关注哦:

在这里插入图片描述

初步分析

先来看看小红书的样子:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

emmmm,从效果上来看呢,其实也只是基本的Translation和Scale组合而已,难点在于缩小态下的阻尼计算,左下角那个按钮用来控制留白,填充等状态的切换(好像小红书还有bug,状态切换会导致图片位置不正确,哈哈哈),接下来我们就一步步分析,从而打造出属于我们的自己的效果。

仔细观察,有没有发现:

  • 单指滑动,图片跟随手指移动,当手指滑动到图片边缘继续沿同一方向滑动,会出现阻尼效果,滑动的距离越大,阻尼越大,手指抬起后,图片回弹到控件边缘;

  • 双指触摸分两种情况,一种是双指向内挤压,图片缩小;另一种是双指向外扩散,图片放大;

  • 当双指向外扩散达到一定的临界值,手指抬起后,图片缩小到临界值状态;

  • 手指触摸且有一定的滑动值,会显示线条九宫格,且线条跟随图片的大小动态改变,始终分割图片为9等分,如果手指触摸停止,线条消失,再次滑动,线条则再次出现;

那么图片缩放时,需要一个缩放中心点,也就是PivotX和PivotY,这个点默认情况下在View的中心。但很明显,它这个就不是在中心了,至于在哪里,先看下这张图:
在这里插入图片描述 可以看到,图片始终是以双指的中点在缩放,那么缩放中心点就是双指连线的中点位置上了。又怎么获取到双指的中点坐标呢?这里涉及到了Android提供的两个帮助类:GestureDetector、ScaleGestureDetector。接下来让我们先来了解下这两个类,揭开它的神秘面纱。神秘?你个糟老头,坏得很,信你个鬼。。。

手势帮助类

什么是手势帮助类?Android手机屏幕上,当我们触摸屏幕的时候,会产生许多手势事件,如down,up,scroll,filing等等。我们可以在onTouchEvent()方法里面完成各种手势识别。但是,我们自己去识别各种手势就比较麻烦了,而且有些情况可能考虑的不是那么的全面。所以,为了方便我们的使用Android就提供了GestureDetector帮助类,先来看看他的构造方法:

    public GestureDetector(Context context, OnGestureListener listener, Handler handler,
            boolean unused) {
    }

context表示上下文,listener表示手势的监听回调,handler可以指定线程(UI线程、非UI线程),unused未被使用的参数。如果我们的手势不需要在子线程中处理,我们一般只关心前两个参数,context是上下文这个简单,重点看下listener参数:

GestureDetector给我们提供了三个接口类与一个外部类:

  • OnGestureListener:接口,用来监听手势事件(6种);

  • OnDoubleTapListener:接口,用来监听双击事件;

  • OnContextClickListener:接口,外接设备,比如外接鼠标产生的事件(本文中我们不考虑);

  • SimpleOnGestureListener:外部类,SimpleOnGestureListener其实是上面三个接口中所有函数的集成,它包含了这三个接口里所有必须要实现的函数而且都已经重写,但所有方法体都是空的。需要自己根据情况去重写;

OnGestureListener接口方法:

public interface OnGestureListener {
        /**
         * 按下。返回值表示事件是否处理
         */
        boolean onDown(MotionEvent e);
        
        /**
         * 短按(手指尚未松开也没有达到scroll条件)
         */
        void onShowPress(MotionEvent e);

        /**
         * 轻触(手指松开)
         */
        boolean onSingleTapUp(MotionEvent e);

        /**
         * 滑动(一次完整的事件可能会多次触发该函数)。返回值表示事件是否处理
         */
        boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);

        /**
         * 长按(手指尚未松开也没有达到scroll条件)
         */
        void onLongPress(MotionEvent e);

        /**
         * 滑屏(用户按下触摸屏、快速滑动后松开,返回值表示事件是否处理)
         */
        boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);
    }

OnDoubleTapListener接口方法:

    public interface OnDoubleTapListener {
        /**
         * 单击事件(onSingleTapConfirmed,onDoubleTap是两个互斥的函数)
         */
        boolean onSingleTapConfirmed(MotionEvent e);

        /**
         * 双击事件
         */
        boolean onDoubleTap(MotionEvent e);

        /**
         * 双击事件产生之后手指还没有抬起的时候的后续事件
         */
        boolean onDoubleTapEvent(MotionEvent e);
    }

GestureDetector的使用:

  • 定义GestureDetector类;

  • 将touch事件交给GestureDetector(onTouchEvent函数里面调用GestureDetector的onTouchEvent函数);

  • 处理SimpleOnGestureListener或者OnGestureListener、OnDoubleTapListener、OnContextClickListener三者之一的回调;

GestureDetector使用流程如下(有关例子会在后文中讲到):

    public GestureView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public GestureView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // 第一步
        mGestureDetector = new GestureDetector(context, mOnGestureListener);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 第三步
        return mGestureDetector.onTouchEvent(event);
    }
    //  第二步
    GestureDetector.OnGestureListener mOnGestureListener = new GestureDetector.OnGestureListener() {
        @Override
        public boolean onDown(MotionEvent e) {
            return false;
        }

这里就不再深入GestureDetector源码讲解,有感兴趣的小伙伴可以自行查阅资料,接着了解ScaleGestureDetector缩放手势类,用法与GestureDetector类似,都是通过onTouchEvent()关联相应的MotionEvent事件。

ScaleGestureDetector类给提供了OnScaleGestureListener接口,来告诉我们缩放的过程中的一些回调:

  public interface OnScaleGestureListener {
        /**
         * 缩放进行中,返回值表示是否下次缩放需要重置,如果返回ture,那么detector就会重置缩放事件,如果返回false,detector会在之前的缩放上继续进行计算
         */
        public boolean onScale(ScaleGestureDetector detector);

        /**
         * 缩放开始,返回值表示是否受理后续的缩放事件
         */
        public boolean onScaleBegin(ScaleGestureDetector detector);

        /**
         * 缩放结束
         */
        public void onScaleEnd(ScaleGestureDetector detector);
    }

ScaleGestureDetector类常用函数介绍,因为在缩放的过程中,要通过ScaleGestureDetector来获取一些缩放信息:

    /**
     * 缩放是否正处在进行中
     */
    public boolean isInProgress();

    /**
     * 返回组成缩放手势(两个手指)中点x的位置
     */
    public float getFocusX();

    /**
     * 返回组成缩放手势(两个手指)中点y的位置
     */
    public float getFocusY();

    /**
     * 组成缩放手势的两个触点的跨度(两个触点间的距离)
     */
    public float getCurrentSpan();

    /**
     * 同上,x的距离
     */
    public float getCurrentSpanX();

    /**
     * 同上,y的距离
     */
    public float getCurrentSpanY();

    /**
     * 组成缩放手势的两个触点的前一次缩放的跨度(两个触点间的距离)
     */
    public float getPreviousSpan();

    /**
     * 同上,x的距离
     */
    public float getPreviousSpanX();

    /**
     * 同上,y的距离
     */
    public float getPreviousSpanY();

    /**
     * 获取本次缩放事件的缩放因子,缩放事件以onScale()返回值为基准,一旦该方法返回true,代表本次事件结束,重新开启下次缩放事件。
     */
    public float getScaleFactor();

    /**
     * 返回上次缩放事件结束时到当前的时间间隔
     */
    public long getTimeDelta();

    /**
     * 获取当前motion事件的时间
     */
    public long getEventTime();

ScaleGestureDetector使用方式与GestureDetector类似,这里就不再重复讲解,了解了相关手势类,接下来开始代码构思。

构思代码

想一想,图片有任意尺寸,怎样才能让图片铺满控件,那么就需要对图片进行缩放,平移。还有一点是必须考虑的,在加载高分辨率的图片非常消耗内存,在低内存的手机上很容易造成OOM,那么针对高分辨率的图片就必须压缩。还有一种情况是来回切换相同的两张图片,如果每次都加载本地图片,既消耗内存速度还很慢,这时候缓存就很有必要了,第一次加载本地图片,再次切回到该图片加载缓存图片。

显示图片,一般有两种方式,一种是Android提供了ImageView控件来显示图片;另一种直接在onDraw()方法里调用canvas.drawBitmap()方法,通过调研小红书显示方案,发现他采用了第二种:

在这里插入图片描述 (^__^) 嘻嘻……那我们就用第一种显示图片的方式,继承ImageView来显示图片。

通过观察小红书,我们会发现:

  1. 图片显示区域为宽高相等的矩形,那么在测量onMeasure的时候需要保证宽高一致,左下角小按钮的状态切换先不考虑,后面会重点讲解。

  2. 图片默认会充满整个控件并居中对齐,那么怎么保证图片充满控件,最常规的做法就是:取控件的宽高与图片的宽高比的最大值缩放Math.max(控件宽度/图片宽度,控件高度/图片高度);同理,取控件宽高与图片宽高的偏移量的一半来平移图片保证居中对齐。

  3. 在2的基础上,非宽高相等的图片有一部分会显示在控件区域之外,可以通过手指滑动来显示,相信大家都用过PhotoView,效果一致。 移动图片与移动控件的原理一样,都是改变setTranslation的值,不过这里用到了图片矩阵,通过改变Matrix.postTranslate(dx, dy)的值来移动图片。

  4. 移动图片,那就不得不考虑越界问题,请观察下图,这里以上边界为例(左,右,下边界同理)。注意:这里的越界指的不是数组越界,而是图片滑动到边缘继续沿相同方向滑动,图片未铺满控件区域。 在下图中你会发现:图片跟随手指继续滑动,手指滑动的距离越大阻尼越大,手指抬起后图片会回弹到控件顶部。 在这里插入图片描述

  5. 双指挤压图片缩小,扩散图片放大,缩放中心点是双指中点坐标,那么缩放比例怎么计算呢?最开始取的缩放因子ScaleGestureDetector.getScaleFactor() ,出来的效果真的天马行空(~~轻微挤压扩散图片无限放大缩小~~ ),接着给缩放因子加一个比例,效果依旧不行,哦豁。没办法,打印缩放数据,观察数据,寻找规律。几经尝试最后取了缩放因子的偏移量。~~为了写好控件,没什么捷径,只能多观察,多尝试。~~ 在缩小至越界的状态下,手指抬起,图片放大到充满控件;在放大到一定的阈值后放手后,图片回弹到一定的缩放比例。前文提到了在缩小至越界状态下单指滑动图片,根据四周滑动的距离,会出现阻尼效果,在后文会讲解阻尼算法。

  6. 图片在滑动或缩放态下,会出现九宫格白色线条,线条始终平分控件内的图片为九等分,滑动或缩放停止线条消失,再次滑动或缩放线条出现,手指抬起后线条消失。

嗯,整个过程的大致行为就是这样了。

开工写代码咯~

起名字

在开始写代码之前,要先给这个自定义控件起一个名字,又哦豁。。。不会起名字, 就叫:裁剪图片控件(MCropImageView) 吧。不要问我M字母是啥含义,我不会告诉你的。

编写代码

宽高相等矩阵测量

测量比较简单,具体请看相关代码:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        if (widthSize > heightSize) {
           // 取高
            super.onMeasure(heightMeasureSpec, heightMeasureSpec);
        } else {
          // 取宽
            super.onMeasure(widthMeasureSpec, widthMeasureSpec);
        }
    }

铺满居中

铺满的原理上文已经讲到了,对应的公式如下:

控件宽度/图片宽度 = a
控件高度/高度高度 = b 
mBaseScale = Math.max(a,b)
Matrix.postScale(mBaseScale, mBaseScale, 控件宽度/ 2, 控件高度/ 2)

居中的原理上面也提到过了,来看看代码怎么写:

    @Override
    public void onGlobalLayout() {
        mMatrix.reset();
        // 获取控件的宽度和高度
        int viewWidth = getWidth();
        int viewHeight = getHeight();

        // 图片的固定宽度  高度
        // 获取图片的宽度和高度
        Drawable drawable = getDrawable();
        if (null == drawable) {
            return;
        }
        int drawableWidth = drawable.getIntrinsicWidth();
        int drawableHeight = drawable.getIntrinsicHeight();

        // 将图片移动到屏幕的中点位置
        float dx = (viewWidth - drawableWidth) / 2;
        float dy = (viewHeight - drawableHeight) / 2;
        // 取最大值
        mBaseScale = Math.max((float) viewWidth / drawableWidth, (float) viewHeight / drawableHeight);
        // 平移居中
        mMatrix.postTranslate(dx, dy);
        // 缩放
        mMatrix.postScale(mBaseScale, mBaseScale, viewWidth / 2, viewHeight / 2);
        setImageMatrix(mMatrix);
    }

有关Matrix的set 、 pre、post方法调用顺序,这里简单说一下(~~个人理解,有错还望指出~~ ),可以把Matrix的操作看成队列,post方法添加到队列的尾部,pre添加到队列的头部,而set方法则重置队列

看看铺满居中的效果: 在这里插入图片描述

单指滑动

单指滑动,在上文已经讲到GestureDetector.SimpleOnGestureListener内部接口用来处理手势滑动,重写以下接口方法:

    // 处理手指滑动
    private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener = new G

Related Skills

View on GitHub
GitHub Stars151
CategoryDevelopment
Updated5mo ago
Forks23

Languages

Java

Security Score

72/100

Audited on Oct 15, 2025

No findings