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

AI原生应用开发指南:从传统App到智能体范式的技术演进 “AI将会取代90%的App。”——这个观点正在从预言变成一种可感知的趋势。作为一名开发者,你可能已经注意到,过去一年里,你的手机里新安装的App数量在减少,而像Nova这样的“All-in-One”AI助手应用,下载量却突破了1亿次。它集成了GPT、Gemini、Claude等多个大模型,能聊天、写作、搜索、生成图片、处理文档,几乎覆盖了十几个独立App的功能。这不禁让人思考:我们是否还需要为每一个细分功能去下载一个独立的App?未来的应用形态,是否真的会从“功能孤岛”走向“智能中枢”? 对于开发者而言, 阅读详情

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

本系列共有两篇,在第二篇会重点讲解与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 GestureDetector.SimpleOnGestureListener() {

        @Override
        public boolean onDown(MotionEvent e) {
           // 消费事件
            return true;
        }

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            // 限定单指
            if (e1.getPointerCount() == e2.getPointerCount() && e1.getPointerCount() == 1) {
               // distanceX 左正右负 所以这里取相反数
                mMatrix.postTranslate(-distanceX, -distanceY);
                setImageMatrix(mMatrix);
                return true;
            }
            return super.onScroll(e1, e2, distanceX, distanceY);
        }
    };

获取到手指滑动的距离,对图片矩阵进行平移Matrix.postTranslate(),但在x轴方向获取到的滑动距离右负左正,y轴方向获取到的滑动距离上正下负,跟实际平移的值相反,那么平移值Matrix.postTranslate(-distanceX, -distanceY)取滑动距离的负数。

单指滑动还有一个效果,越界下的阻尼效果,看看效果图:
在这里插入图片描述
很明显图片跟随手指滑动,距离控件边缘越近,阻尼越大。那么很明显需要获取图片边缘距离控件的距离,然后根据滑动偏移量进行计算。为了获取图片边缘距离控件的距离,就需要获取图片的位置信息。那么怎样才能获取图片位置信息呢?

在ViewGroup的transformPointToViewLocal方法中有这样一段代码:

    if (!child.hasIdentityMatrix()) {
        child.getInverseMatrix().mapPoints(point);
    }

如果child所对应的矩阵发生过旋转、缩放等变化的话(补间动画不算,因为是临时的),会通过矩阵的mapPoints方法来将触摸点转换到矩阵变换后的坐标。

没错,我们也可以用矩阵的mapRect方法来将图片的坐标及尺寸转换一下,就像这样:
在这里插入图片描述
这样就可以获取到图片的矩形区域,相关方法如下:

    // 获取图片矩阵区域
    private RectF getMatrixRectF() {
        RectF rectF = new RectF();
        Drawable drawable = getDrawable();
        if (drawable != null) {
            // 注意set
            rectF.set(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
            mMatrix.mapRect(rectF);
        }
        return rectF;
    }

获取到了图片矩阵,那么图片越界就很容易判定了,先看下面两张越界图:
在这里插入图片描述在这里插入图片描述
图片上边缘距离控件顶部变量为topEdgeDistanceTop,左边缘距离控件左边变量为leftEdgeDistanceLeft,右边缘距离控件右边变量为rightEdgeDistanceRight,下边缘距离控件底部变量为bottomEdgeDistanceBottom,分别对应的代码如下:

   // 获取图片矩阵
   RectF rectF = getMatrixRectF();
   float leftEdgeDistanceLeft = rectF.left;
   float topEdgeDistanceTop = rectF.top;
   //位移 rectF.right - rectF.left 图片宽度   
   float rightEdgeDistanceRight = leftEdgeDistanceLeft + rectF.right - rectF.left - getWidth();
   // rectF.bottom - rectF.top 图片高度
   float bottomEdgeDistanceBottom = topEdgeDistanceTop + rectF.bottom - rectF.top - getHeight();

好了,这样就可以准确判定图片是否越界。接下来我们看看越界状态下的阻尼算法是怎么计算的,有什么规律:

先来观察图片左右越界的情况(上下越界同理),左右越界又分为三种情况,左越界&右不越界(简称左越界),右越界&左不越界(简称右越界),左越界&右越界(简称左右越界) 左越界的情况与右越界类似,那么就只有两种情况:

  1. 左越界
    在这里插入图片描述

可以看到在向左滑动的情况下,图片左侧距离控件左侧距离越大,阻力越大。通俗一点,手指滑动的距离越大,图片跟随手指滑动的距离就越小,那么可以根据以下公式获取阻尼系数:

 最大阻尼数 / 最大偏移量 * leftEdgeDistanceLeft

最大阻尼数默认取值为9,最大偏移量为控件宽度的三分之一,对应的代码如下:

   // 获取图片矩阵
   RectF rectF = getMatrixRectF();
   float leftEdgeDistanceLeft = rectF.left;
   float rightEdgeDistanceRight = leftEdgeDistanceLeft + rectF.right - rectF.left - getWidth();
   
   // MAX_SCROLL_FACTOR = 3
   int maxOffsetWidth = getWidth() / MAX_SCROLL_FACTOR;
   int maxOffsetHeight = getHeight() / MAX_SCROLL_FACTOR;
   // 图片左侧越界并且图片右侧未越界
   if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight > 0) {
       // distanceX < 0 表示继续向右滑动
       if (distanceX < 0) {
           if (leftEdgeDistanceLeft < maxOffsetWidth) {
               // DAMP_FACTOR = 9 系数越大阻尼越大  +1防止ratio为0
               int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * leftEdgeDistanceLeft) + 1;
               distanceX /= ratio;
           } else {
               // 图片向右滑动超过了最大偏移量 图片则不平移
               distanceX = 0;
           }
       }
       // 向左滑动不做处理 默认取值distanceX
   }
  1. 左右越界
    在这里插入图片描述

左右越界的情况与左越界的情况正好相反,距离控件边缘越近,图片阻力越大。那么怎么判定图片距离控件边缘越近,这里分两种情况,图片中点在控件中点左侧以及图片中点在控件中点右侧。第一种情况图片中点在控件中点左侧,向左滑动阻力越大,向右滑动阻力为0;第二种情况图片中点在控件中点的右侧,向右滑动阻力越大,向左滑动阻力为0。

来看看代码怎么写:

    // 图片左侧越界并且图片右侧越界
    if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight < 0) {
        // 控件宽度的一半
        int halfWidth = getWidth() / 2;
        // 获取图片中点x坐标
        float centerX = (rectF.right - rectF.left) / 2 + rectF.left;
        // 图片中点x坐标是否右侧偏移
        boolean rightOffsetCenterX = centerX >= halfWidth;
        // 右侧偏移并且向右滑动
        if (distanceX < 0 && rightOffsetCenterX) {
            // centerX - halfWidth 图片右侧偏移量
            int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * (centerX - halfWidth)) + 1;
            distanceX /= ratio;
        }
        // 左侧偏移并且向左滑动
        else if (distanceX > 0 && !rightOffsetCenterX) {
            // halfWidth - centerX 左侧的偏移量
            int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * (halfWidth - centerX)) + 1;
            distanceX /= ratio;
        }
    }

好了,左右越界就讲到这里,上下越界同理,越界的整体代码如下:

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            if (e1.getPointerCount() == e2.getPointerCount() && e1.getPointerCount() == 1) {
                // 获取图片矩阵
                RectF rectF = getMatrixRectF();

                float leftEdgeDistanceLeft = rectF.left;
                float topEdgeDistanceTop = rectF.top;

                float rightEdgeDistanceRight = leftEdgeDistanceLeft + rectF.right - rectF.left - getWidth();
                float bottomEdgeDistanceBottom = topEdgeDistanceTop + rectF.bottom - rectF.top - getHeight();

                // MAX_SCROLL_FACTOR = 3
                int maxOffsetWidth = getWidth() / MAX_SCROLL_FACTOR;
                int maxOffsetHeight = getHeight() / MAX_SCROLL_FACTOR;

                // 图片左侧越界并且图片右侧未越界
                if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight > 0) {
                    // distanceX < 0 表示继续向右滑动
                    if (distanceX < 0) {
                        if (leftEdgeDistanceLeft < maxOffsetWidth) {
                            // DAMP_FACTOR = 9 系数越大阻尼越大  +1防止ratio为0
                            int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * leftEdgeDistanceLeft) + 1;
                            distanceX /= ratio;
                        } else {
                            // 图片向右滑动超过了最大偏移量 图片则不平移
                            distanceX = 0;
                        }
                    }
                    // 向左滑动不做处理 默认取值distanceX
                }
                // 图片右侧越界并且图片左侧未越界 (同上处理)
                else if (rightEdgeDistanceRight < 0 && leftEdgeDistanceLeft < 0) {
                    // distanceX > 0 表示继续向左滑动
                    if (distanceX > 0) {
                        if (rightEdgeDistanceRight > -maxOffsetWidth) {
                            int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * -rightEdgeDistanceRight) + 1;
                            distanceX /= ratio;
                        } else {
                            // 图片右侧距离控件右侧超过最大偏移量 图片则不平移
                            distanceX = 0;
                        }
                    }
                }
                // 图片左侧越界并且图片右侧越界
                else if (leftEdgeDistanceLeft > 0 && rightEdgeDistanceRight < 0) {
                    // 控件宽度的一半
                    int halfWidth = getWidth() / 2;
                    // 获取图片中点x坐标
                    float centerX = (rectF.right - rectF.left) / 2 + rectF.left;
                    // 图片中点x坐标是否右侧偏移
                    boolean rightOffsetCenterX = centerX >= halfWidth;
                    // 右侧偏移并且向右滑动
                    if (distanceX < 0 && rightOffsetCenterX) {
                        // centerX - halfWidth 图片右侧偏移量
                        int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * (centerX - halfWidth)) + 1;
                        distanceX /= ratio;
                    }
                    // 左侧偏移并且向左滑动
                    else if (distanceX > 0 && !rightOffsetCenterX) {
                        // halfWidth - centerX 左侧的偏移量
                        int ratio = (int) (DAMP_FACTOR / maxOffsetWidth * (halfWidth - centerX)) + 1;
                        distanceX /= ratio;
                    }
                }

                // 上下越界 处理方式同左右处理方式一样 本可以提成一个方法但为了方便理解先这样了
                // 图片上侧越界并且图片下侧未越界
                if (topEdgeDistanceTop > 0 && bottomEdgeDistanceBottom > 0) {
                    // distanceY < 0 表示图片继续向下滑动
                    if (distanceY < 0) {
                        if (topEdgeDistanceTop < maxOffsetHeight) {
                            // 获取阻尼比例
                            int ratio = (int) (DAMP_FACTOR / maxOffsetHeight * topEdgeDistanceTop) + 1;
                            distanceY /= ratio;
                        } else {
                            // 向下滑动超过了最大偏移量 则图片不滑动
                            distanceY = 0;
                        }
                    }
                }
                // 图片下侧越界并且图片上侧未越界
                else if (bottomEdgeDistanceBottom < 0 && topEdgeDistanceTop < 0) {
                    if (distanceY > 0) {
                        if (bottomEdgeDistanceBottom > -maxOffsetHeight) {
                            int ratio = (int) (DAMP_FACTOR / maxOffsetHeight * -bottomEdgeDistanceBottom) + 1;
                            distanceY /= ratio;
                        } else {
                            // 向上滑动超过了最大偏移量 则图片不滑动
                            distanceY = 0;
                        }
                    }
                } else if (topEdgeDistanceTop > 0 && bottomEdgeDistanceBottom < 0) {
                    int halfHeight = getHeight() / 2;
                    // 获取图片中点y坐标
                    float centerY = (rectF.bottom - rectF.top) / 2 + rectF.top;
                    // 图片中点y坐标是否向下偏移
                    boolean bottomOffsetCenterY = centerY >= halfHeight;
                    // 向下偏移并且向下移动
                    if (distanceY < 0 && bottomOffsetCenterY) {
                        // centerY - halfHeight 图片偏移量
                        int ratio = (int) (DAMP_FACTOR / maxOffsetHeight * (centerY - halfHeight)) + 1;
                        distanceY /= ratio;
                    } else if (distanceY > 0 && !bottomOffsetCenterY) { // 向上偏移并且向上移动
                        int ratio = (int) (DAMP_FACTOR / maxOffsetHeight * (halfHeight - centerY)) + 1;
                        distanceY /= ratio;
                    }
                }

                mMatrix.postTranslate(-distanceX, -distanceY);
                setImageMatrix(mMatrix);
                return true;
            }
            return super.onScroll(e1, e2, distanceX, distanceY);
        }
双指缩放

双指缩放的原理在上文已经提及过了,重写ScaleGestureDetector.OnScaleGestureListener缩放手势类接口方法:

    // 处理双指的缩放
    private ScaleGestureDetector.OnScaleGestureListener mOnScaleGestureListener = new ScaleGestureDetector.OnScaleGestureListener() {
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            if (null == getDrawable() || mMatrix == null) {
                // 如果返回true那么detector就会重置缩放事件
                return true;
            }
            // 缩放因子,缩小小于1,放大大于1
            float scaleFactor = mScaleGestureDetector.getScaleFactor();

            // 缩放因子偏移量
            float deltaFactor = scaleFactor - mPreScaleFactor;

            if (scaleFactor != 1.0F && deltaFactor != 0F) {
                mMatrix.postScale(deltaFactor + 1F, deltaFactor + 1F, mScaleGestureDetector.getFocusX(),
                        mScaleGestureDetector.getFocusY());
                setImageMatrix(mMatrix);
            }
            mPreScaleFactor = scaleFactor;
            return false;
        }

        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            // 注意返回true
            return true;
        }

        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {
        }
    };
回弹

在手指抬起时,图片在某种状态下会出现回弹动效,这里某种状态指的是越界&图片的缩放比例大于一定的阈值&图片的缩放比例小于一定的阈值三种状态,回弹无非改变图片矩阵的setTranslation,setScale值。当我们需要监听手指抬起的状态时,都是直接重写onTouchEvent去实现:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 防止父类拦截事件
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                float scale = getScale();
                if (scale > mMaxScale) {
                    // 缩小
                } else if (scale < mBaseScale) {
                    // 放大
                } else {
                    // 平移
                }
               getParent().requestDisallowInterceptTouchEvent(false);
                break;
        }
        return true;
    }

为了防止父类拦截事件,一般会在手指按下,抬起调用requestDisallowInterceptTouchEvent方法来避免事件冲突。 getScale方法如下,获取图片矩阵的缩放比例:

    private float getScale() {
        float[] values = new float[9];
        mMatrix.getValues(values);
        return values[Matrix.MSCALE_X];
    }

缩小放大的动画怎么实现呢?知道了开始与结束的缩放比例,在动画回调接口中动态设置 mMatrix.setValues(values)来实现缩小放大的效果,可现实很骨感,效果相去甚远,缩放中心点PivotX和PivotY始终在图片原点,同时Matrix并没有提供设置缩放中心点的方法。看来只能老老实实的使用Matrix.postScale(float sx, float sy, float px, float py)方法,同时设置缩放中心点为双指的中点坐标ScaleGestureDetector.getFocusX()。注意:sx,sx是相对值,相对上一个终点的缩放值。

相对值,多缩放一次与少缩放一次图片的状态完全不一样,那么必须控制缩放次数,由于ValueAnimator回调次数在不同的机型上并不一样,那么就不能用ValueAnimator的回调来实现动画,那么怎么做呢?

emmmm,你一定会想到Handler,既可以控制次数还可以控制消息延时。知道了开始与结束缩放点,也知道了缩放次数,那么怎么获取缩放相对值呢,利用Math.pow数学公式:

      /**
     * 计算d的1/count次幂
     *
     * @param d
     * @param count 开根的次数
     * @return 相对值
     */
    private static float getRelativeValue(double d, double count) {
        if (count == 0) {
            return 1F;
        }
        count = 1 / count;
        return (float) Math.pow(d, count);
    }

接下来就是发送消息与接收消息:

    /**
     * 发送消息
     *
     * @param relativeScale
     * @param what
     * @param delayMillis
     */
    private void sendMessage(float relativeScale, int what, long delayMillis) {
        Message mes = new Message();
        mes.obj = relativeScale;
        mes.what = what;
        mHandler.sendMessageDelayed(mes, delayMillis);
    }
   
   // 调用 省略前面 ...   
    case MotionEvent.ACTION_UP:
       float scale = getScale();
       if (scale > mMaxScale) {
           // 缩小 SCALE_ANIM_COUNT = 10  ZOOM_OUT_ANIM_WHIT = 0 
           sendMessage(getRelativeValue(mMaxScale / scale, SCALE_ANIM_COUNT), ZOOM_OUT_ANIM_WHIT, 0);
       } else if (scale < mBaseScale) {
           // 放大 ZOOM_ANIM_WHIT = 1 
           sendMessage(getRelativeValue(mMaxScale / scale, SCALE_ANIM_COUNT), ZOOM_ANIM_WHIT, 0);
       } else {
           // 平移
           boundCheck();
       }

接收并处理消息:

    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            if (msg != null) {
                if (mCurrentScaleAnimCount < SCALE_ANIM_COUNT) {
                    float obj = (float) msg.obj;
                    mMatrix.postScale(obj, obj, mLastFocusX, mLastFocusY);
                    setImageMatrix(mMatrix);
                    mCurrentScaleAnimCount++;
                    // what scale > mMaxScale 取0 不然取 1
                    sendScaleMessage(obj, msg.what, SCALE_ANIM_COUNT);
                } else if (mCurrentScaleAnimCount >= SCALE_ANIM_COUNT) {
                    float[] values = new float[9];
                    mMatrix.getValues(values);
                    if (msg.what == ZOOM_OUT_ANIM_WHIT) {
                        values[Matrix.MSCALE_X] = mMaxScale;
                        values[Matrix.MSCALE_Y] = mMaxScale;
                    } else if (msg.what == ZOOM_ANIM_WHIT) {
                        values[Matrix.MSCALE_X] = mBaseScale;
                        values[Matrix.MSCALE_Y] = mBaseScale;
                    }
                    mMatrix.setValues(values);
                    setImageMatrix(mMatrix);

                    // 边界检测
                    boundCheck();
                }
            }
        }
    };

缩小放大的效果如下:
在这里插入图片描述在这里插入图片描述
为了防止Handler泄露,清空队列:

    @Override
    protected void onDetachedFromWindow() {
        if (mHandler != null) {
            // 防止内存泄露
            mHandler.removeCallbacksAndMessages(null);
        }
        super.onDetachedFromWindow();
    }

回弹还剩最后一种情况越界,在上文中已经提到了越界的四种(上下左右)情况,手指抬起后图片平移到控件边缘。所谓的平移,就是从一点平移到另一点,那么怎么获取起点与结束点呢?

首先需要判定越界,根据getMatrixRectF图片矩阵,代码已经很清晰:

    // 边界检测
    private void boundCheck() {
        // 获取图片矩阵
        RectF rectF = getMatrixRectF();

        if (rectF.left >= 0) {
            // 左越界
        }

        if (rectF.top >= 0) {
            // 上越界
        }

        if (rectF.right <= getWidth()) {
            // 右越界
        }

        if (rectF.bottom <= getHeight()) {
            // 下越界
        }
    }

在左越界的情况下,起点为rectF.left,结束点为0;同理上越界的起点rectF.top,结束点0;那么右越界起点与结束点呢?有小伙伴会说那还不简单,不就是rectF.right,getWidth()吗?

很遗憾,你又哦豁了,不得不提一下,图片的矩阵的平移是以左上角为基点,那么右越界的起点同样为rectF.left,结束点为:

    起点 + 图片右侧距离控件右侧的距离

图片右侧距离控件右侧的距离为getWidth() - rectF.right,那么结束点的坐标为rectF.left + getWidth() - rectF.right;同理下越界的起点为rectF.top,结束点getHeight() - rectF.bottom + rectF.top。有了起点与结束点,那么平移就很容易了:

    /**
     * 开始越界动画
     *
     * @param start      开始点坐标
     * @param end        结束点坐标
     * @param horizontal 是否水平动画  true 水平动画 false 垂直动画
     */
    private void startBoundAnimator(float start, float end, final boolean horizontal) {
        boundAnimator = ValueAnimator.ofFloat(start, end);
        boundAnimator.setDuration(200);
        boundAnimator.setInterpolator(new LinearInterpolator());
        boundAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float v = (float) animation.getAnimatedValue();

                float[] values = new float[9];
                mMatrix.getValues(values);
                values[horizontal ? Matrix.MTRANS_X : Matrix.MTRANS_Y] = v;

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

好了,看看效果:
在这里插入图片描述

九宫线条

在上文已经提到九宫线条的规律: 图片在滑动或缩放态下,会出现九宫格白色线条,线条始终平分控件内的图片为九等分,滑动或缩放停止线条消失,再次滑动或缩放线条出现,手指抬起后线条消失。那么从这句话中我们可以得出以下结论:

  1. 有关绘制涉及到onDraw()方法的重写

  2. 线条的显示区域为图片与控件的交集

  3. 控制线条的显示与消失(是否绘制)

怎么取交集记住一个原则:上左取大,右下取小 八字真言,就像这样:

    // 开始点
    float startX = 0;
    float startY = 0;
    // 结束点
    float endX = 0;
    float endY = 0;
    RectF rectF = getMatrixRectF();
    // 上左取大 右下取小
    startX = rectF.left <= 0 ? 0 : rectF.left;
    startY = rectF.top <= 0 ? 0 : rectF.top;
    
    endX = rectF.right >= getWidth() ? getWidth() : rectF.right;
    endY = rectF.bottom >= getHeight() ? getHeight() : rectF.bottom;

获取到线条绘制的区域,那么怎么绘制线条?绘制多少线条?就比较容易了:

        float lineWidth = 0;
        float lineHeight = 0;

        lineWidth = endX - startX;
        lineHeight = endY - startY;

        // LINE_ROW_NUMBER = 3 表示多少行
        for (int i = 1; i < LINE_ROW_NUMBER; i++) {
            canvas.drawLine(startX + 0, startY + lineHeight / LINE_ROW_NUMBER * i, endX, startY + lineHeight / LINE_ROW_NUMBER * i, mLinePaint);
        }

        // LINE_COLUMN_NUMBER = 3 表示多少列
        for (int i = 1; i < LINE_COLUMN_NUMBER; i++) {
            canvas.drawLine(startX + lineWidth / LINE_COLUMN_NUMBER * i, startY, startX + lineWidth / LINE_COLUMN_NUMBER * i, endY, mLinePaint);
        }

怎么控制线条的显示消失,注意显示消失的规则,缩放或滑动停止线条消失,再次滑动或缩放线条显示,以此类推,绝大部分人会想到怎么判定滑动或缩放停止?

写控件很多时候就是这样,不知不觉就入坑了,一头扎进里面,茶不思饭不想。。。然而这一切并没有什么用,最后还得换方案。

说下为什么不行,你会在手势MotionEvent.ACTION_MOVE事件判定滑动或缩放停止,但同时GestureDetector与ScaleGestureDetector也在消费滑动事件,导致判定不准确。那么怎么解决呢?

还记得Android源码长按事件的处理方式吗?相关代码如下:

case MotionEvent.ACTION_DOWN:
        ......省略代码
        if (mIsLongpressEnabled) {
            mHandler.removeMessages(LONG_PRESS);
            // 延迟时长为500毫秒
            mHandler.sendEmptyMessageAtTime(LONG_PRESS,
                    mCurrentDownEvent.getDownTime() + LONGPRESS_TIMEOUT);
        }
 case MotionEvent.ACTION_MOVE:
       int distance = (deltaX * deltaX) + (deltaY * deltaY);
       int slopSquare = isGeneratedGesture ? 0 : mTouchSlopSquare;
       if (distance > slopSquare) {
           ......省略代码
           mHandler.removeMessages(LONG_PRESS);
       }

在事件ACTION_DOWN延时发送长按事件,在延迟周期内,如果发生滑动,则移除长按事件,反之未发生滑动则触发长按事件。

借鉴长按事件的处理方式:

    // 绘制九宫线条
    private void drawLine(Canvas canvas) {
        // 省略中间代码
        mHandler.removeCallbacks(lineRunnable);
        mHandler.postDelayed(lineRunnable, 400);
    }
    
    private Runnable lineRunnable = new Runnable() {
        @Override
        public void run() {
            mIsDragging = false;
            invalidate();
        }
    };
    
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (mIsDragging) {
            canvas.save();
            drawLine(canvas);
            canvas.restore();
        }
    }

效果就像这样:
在这里插入图片描述
哈哈哈~,小红书的图片裁剪控件喜欢吗?想看更多炫酷控件,请搜索关注公众号:控件人生

在这里插入图片描述
你可以留言,告诉小编想实现什么样的炫酷控件?小编会每周选取炫酷的控件进行讲解。

由于篇幅原因,文章到这里就差不多了,有关左下角留白,填充效果,以及联动效果,将在下一篇讲解,打造属于你自己的CoordinatorLayout效果,喜欢的小伙伴被忘记关注控件人生(新公众号),同大家一起成长。

Github地址:https://github.com/HpWens/MCropImageView 欢迎Star

炫酷控件集:https://github.com/HpWens/MeiWidgetView 欢迎Star

小红书无水印原图保存实战:手机电脑全方案与URL参数解析 在内容创作与素材收集中,图片的清晰度与完整性直接影响使用体验。许多平台会在图片展示层动态叠加水印,导致直接下载的图片带有标识或画质受损。理解图片URL中的处理参数,是获取原始资源的关键。通过浏览器开发者工具查看网络请求,可以定位到包含原始标识的图片直链,再配合快捷指令、链接参数替换或批处理脚本,即可在手机与电脑端高效保存无水印原图。这种方法不依赖第三方应用,操作透明且安全性高,适用于设计参考、高清壁纸收藏及批量素材整理等场景。掌握URL参数与请求过滤逻辑,不仅解决当前需求,也能应对平台页面更新带来的变化,为 阅读详情

相关推荐

小红书无水印图片获取全指南:合规路径与工具避坑

在内容平台生态中,图片水印既是品牌标识,也是版权追溯机制,然而用户对干净素材的需求始终存在。理解水印的动态叠加原理,是判断各类下载方案是否可行的基础。正规渠道下,创作者可通过小红书创作者中心或App自有入口下载原图;普通用户则需尊重版权,善用平台保存功能,或与作者沟通获取授权。与此同时,市面上流行的去水印工具多依赖接口模拟,不仅成功率低,更潜藏账号安全和数据泄露风险。掌握合规下载路径、规避工具陷阱,才能真正高效地完成图片素材的收藏与复用。围绕实际场景展开,面向不同身份与内容形态,梳理安全的图片保存方案,在规

weixin_33326218的博客 227

Flutter图片裁剪

做app登陆的时候,需要用到图片裁剪功能,由于刚接触Flutter不久,所以就萌生了自己写一个的想法,说干就干!让我们站在巨人的肩膀上前进吧。 flutter中图片裁剪主要采用drawImageRect(Image image, Rect src, Rect dst, Paint paint)方法来从图片上抠图,介绍如下: /// Draws the subset of the given ima...

qq627578198的博客 7190

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

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

Flutter UI - 打造一个圆形滑块(Slider)

原文地址:How to build a circular slider in Flutter 原文作者:David Anaya 译文出自:掘金翻译计划 本文永久链接:github.com/xitu/gold-m… 译者:DevMcryYu 校对者:MollyAredtana,JasonLinkinBright

迦南的专栏 1739

Android 仿小红书发布界面

Android 仿小红书发布界面。 仿小红书发布界面,三个界面,部分参考 小红书 发布 本地图片选择

从SAR信号到洪涝地图:基于Sentinel-1数据的水体快速提取实战

本文详细介绍了如何利用Sentinel-1 SAR数据快速提取水体信息并生成洪涝地图的实战方法。通过预处理五部曲和SDWI指数计算,结合具体案例展示了SAR技术在洪涝灾害应急监测中的高效性和准确性,为救援决策提供关键支持。

weixin_29201313的博客 457

手势识别官方教程(6)识别拖拽手势用GestureDetector.SimpleOnGestureListener和onTouchEvent...

三种现实drag方式  1,在3.0以后可以直接用 View.OnDragListener (在onTouchEvent中调用某个view的startDrag())  2,onTouchEvent()  3,GestureDetector.SimpleOnGestureListener 的onScroll Dragging   This lesson describes ...

banyinlve3147的博客 341

英特尔® NUC迷你电脑实现无人值守自动开机配置指南

本文详细介绍了如何配置英特尔® NUC迷你电脑实现无人值守自动开机功能。从BIOS设置到操作系统级配置,涵盖硬件兼容性确认、电源恢复功能设置及常见问题解决,帮助用户轻松实现设备断电后自动恢复运行,适用于家庭服务器、企业终端等多种场景。

weixin_42638178的博客 466

小红书在线去水印工具:一键获取高清无水印图片与视频

本工具专为小红书用户设计,支持通过分享链接一键去除图片与视频中的水印,还原清晰原画质内容。无需安装软件,无需注册登录,打开网页即可使用。支持图片与视频去水印保持原始画质,高清下载完全在线操作,无需安装操作简单,快速解析。

hellpikafenger的博客 2154

用Python实现四叉树(quad tree)

import numpy as np #正方形类 class square: #lx、ly为左上角坐标(x对应行号,y对应列号),rx、ry为右下角坐标 def __init__(self,lx,ly,rx,ry): self.lx=lx self.ly=ly self.rx=rx self.ry=ry #检查该矩阵中是否满足条件 #参数:原矩阵,阈值 #返回值:布尔类型 def check(s

weixin_44164489的博客 5865

[arcgis插件]在批量出图时,如何把图层属性表以动态表格的形式插入到布局页面

在批量出图时,如何把图层属性表以动态表格的形式插入到布局页面。

坐听风雨 4545

2026小红书无水印保存图片视频方法,官方与工具教程

日常使用小红书时,无论是个人作品备份,还是合规整理已授权的优质素材,保存图片、视频时自带的平台水印,往往会影响素材观感与使用体验。2026年小红书平台规则已完成多轮更新,旧的截图、简单裁剪去水印方法大多失效,很多用户难以找到稳定、高清、无水印的保存方式。本篇教程整合、手机端、电脑端全渠道实操方案,涵盖各类的使用技巧,汇总2026年实测有效的开篇先明确核心规则:小红书的水印机制分为两种场景,本人发布作品可通过官方设置直接无水印保存,他人作品官方无原生无水印保存通道,只能通过合规工具解析获取高清无水印素材。

GYRYIJF的博客 81

统计学习导论(ISLR) 第四章分类算法课后习题

统计学习导论(ISLR) 参考资料: The Elements of Statistical Learning An Introduction to Statistical Learning 统计学习导论(ISLR)(二):统计学习概述 统计学习导论(ISLR)(三):线性回归 统计学习导论(ISLR)(四):分类 ISLR统计学习导论之R语言应用(二):R语言基础 ISLR统计学习导论之R语言应用(三):线性回归R语言代码实战 ISLR统计学习导论之R语言应用(四):分类算法R语言代码实战 统计学习导论

阿里云专家博主、数据科学领域优质创作者、统计er在读 2万+

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

先来看两张效果图:哈哈,就是这样了。效果差了一些,感兴趣的小伙伴们可以运行代码感受丝滑与弹性。前段时间在竞品小红书上看到了这样的效果:图片可以跟随手指移动,双指可以(无限...

singwhatiwanna 828

DO-178C详解:机载软件开发、验证与适航标准指南

DO-178C将软件划分为五个等级(A到E),这些等级是基于失效状态对航空器安全的影响而定。

qq_41854911的博客 2837

YOLOv9改进,YOLOv9添加CBAM注意力机制

CBAM是一个简单而有效的注意力模块,用于前馈卷积神经网络。给定一个中间特征图,我们的模块按顺序推断两个独立维度(通道和空间)的注意力图,然后将注意力图乘以输入特征图以进行自适应特征细化。由于 CBAM 是一个轻量级的通用模块,因此它可以无缝集成到任何 CNN 架构中,开销可以忽略不计,并且可以与基础 CNN 一起进行端到端训练。论文链接:(CBAM论文)

在职AI算法工程师,擅长计算机视觉,YOLO目标检测、分割等,擅长web、pyqt界面可视化,好内容持续更新中,来这里跟大家一起学习,共同进步 850

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

本篇续: 第一站小红书图片裁剪控件,深度解析大厂炫酷控件 先来看看几张效果图: emmmm,想感受高清丝滑的动画效果,有以下两种方式: https://github.com/HpWens/MeiWidgetView 欢迎Star https://www.pgyer.com/zKF4 APK地址 在前篇中已经讲了相关手势的处理,本篇重点讲解留白,列表联动效果。 在上一篇中由于篇幅原因,图片左下角裁...

weixin_33937499的博客 623

R语言中的混淆矩阵(Confusion Matrix)是一种常用的评估多分类模型性能的工具

除了准确率,混淆矩阵还提供了其他有用的指标,如灵敏度(Sensitivity)、特异度(Specificity)、精确度(Precision)和召回率(Recall)。混淆矩阵通过比较模型预测结果与实际标签的一致性,帮助我们了解模型的分类准确性和错误类型。此外,混淆矩阵还提供了各种错误类型的统计信息,如假阳性(False Positive)和假阴性(False Negative)等。通过混淆矩阵,我们可以了解模型的整体性能和错误类型,从而对模型进行评估和改进。最后,为了更好地理解混淆矩阵的结果,可以使用。

HackSquad的博客 965

CSDN日报190304——程序员与产品如何从撕逼到友好交流

程序人生 | 程序员如何与产品撕逼 作者:stoneFangCN 本文主要谈谈如何与产品愉快的沟通(撕逼) 点击阅读全文 版权声明:本文为博主原创文章,转载请附上博文链接! 移动开发 |第一站小红书图片裁剪控件,深度解析大厂炫酷控件 作者:文淑 前段时间在竞品小红书上看到了这样的效果:图片可以跟随手指移动,双指可以(无限)放大,缩小,还可以挤压,手指抬起后还有一个有趣的效果,图片回弹。。。...

CSDN 官方博客 3789

TMS_WebGMaps_1.9.8.0_Full_Source

TMS_WebGMaps_1.9.8.0_Full_Source 全源码 delphi cbuilder

老版QQ原生表情gif动态

这是老版的QQ表情,不过全是动态的的,Windows照片查看器是不会动的,可以右击用浏览器打开查看,再说一遍是老版的,只是分享给宁愿用老版也要动态的人。

一键解锁原画质:小红书在线去水印工具全解析

小红书在线去水印”工具在便捷性与内容保护之间找到了一个平衡点,它不鼓励侵权,而是为那些希望更好地欣赏、学习和从优质内容中汲取灵感的用户提供了一个技术解决方案。在合理合法的使用框架内,这类工具能够丰富我们的数字生活,让创意和美感更自由地流动。小红书去水印。

m0_51674941的博客 1430

jre9下载 jdk9

(Java SE Runtime Environment 9.0) 9.0u175 官方最新版 【x86|x64】

ios 保存图片到相册_小红书笔记图片,保存到手相册,小红薯都在用这个!

小红书笔记图片,如何保存到手机相册?案例分享:解析这篇小红书笔记图片和文案,保存到手机。小红书图片解析:小红书文案保存:好喜欢今天这个妆!自己看照片都觉得奶凶奶凶的![偷笑R][偷笑R]姐妹们也化起来吧!日常精致! - ?眼妆用了迷尚新多功能盘,右边三个颜色化了今天的眼影,用细闪橡皮粉色给眼皮整个打底,右侧棕闪那颗晕染眼尾和下眼影后半段!之后用手指蘸取右上角按在眼皮和眼角!这个眼妆可以说...

weixin_28938171的博客 1049

VC中sqlite的使用

在VC中对sqlite的使用,实现数据库大批数据的快速插入,读取数据库中的数据在ListCtrl中显示

如何规避图片检测_小红书上如何写出一篇优质笔记(上)

大家好,我是森哥。这节课森哥给大家讲下:小红书以内容笔记分享为主,我们如何写出一篇优质笔记?1、如何发布笔记?2、笔记规则这里主要讲下利用小红书笔记引流,需要注意的就是你的笔记一定是原创,包括图片和文字,不然是得不到系统推荐的。得不到推荐,就没有曝光,没有流量。很多人做小红书的时候,都是复制别人的内容去发布,结果一个赞,一个评论,一个收藏都没。原因就是因为你的笔记含有其他非原创的内容,所以得不到系...

weixin_42400904的博客 5096

Android Studio安卓开发ZoomImageView图片放大缩小与ViewPager冲突问题

最近有个需求是做一个点击图片放大放小功能,然后参考了自定义ZoomImageView这个类来做,但是做到后面发现缩小之后,滑动的时候会与ViewPager冲突,他会滑到下一页,我肯定是希望缩小滑动的时候,应该靠近边界了再滑动到下一页,网上找了很多方法,有说用getParent().requestDisallowInterceptTouchEvent(true);这个拦截,但我感觉不是明智之举。我自己用的是一MVVM的架构,然后设置适配器,观察数据请求,如果请求到了,就把list集合传到适配器显示就行了。

阿文的博客 1267

小红书去水印代码_小红书无水印图片,无水印视频解析下载,小红书视频图片去水印代码实现...

以从小红书“复制链接”出来的一个地址为例,例如:http://xhslink.com/JDk1s下面用PHP代码来实现提取图集,具体代码如下:$userAgent="Mozilla/5.0(Linux;Android5.0;SM-G900PBuild/LRX21T)AppleWebKit/537.36(KHTML,likeGecko)Chrome/72.0.3626.11...

weixin_39940714的博客 7104
上一篇: 仿豆瓣弹性滑动控件,史上最全方位讲解事件滑动冲突
下一篇: 第一站仿小红书图片裁剪控件,深度解析大厂炫酷控件
u012551350
u012551350 领域专家: 操作系统技术领域 领域专家: 操作系统技术领域
博客等级 码龄13年 514粉丝 105原创
评论 10
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值