MCropImageView
炫酷的小红书图片裁剪控件
Install / Use
/learn @HpWens/MCropImageViewREADME
第一站小红书图片裁剪控件,深度解析大厂炫酷控件
先来看两张效果图:


哈哈,就是这样了。效果差了一些,感兴趣的小伙伴们可以运行代码感受丝滑与弹性。前段时间在竞品小红书上看到了这样的效果:图片可以跟随手指移动,双指可以(无限)放大,缩小,还可以挤压,手指抬起后还有一个有趣的效果,图片回弹。。。一直想撸一个手势的控件,正好可以模仿小红书图片裁剪控件,话不多说,撸起袖子就是干。
本系列共有两篇,在第二篇会重点讲解与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来显示图片。
通过观察小红书,我们会发现:
-
图片显示区域为宽高相等的矩形,那么在测量onMeasure的时候需要保证宽高一致,左下角小按钮的状态切换先不考虑,后面会重点讲解。
-
图片默认会充满整个控件并居中对齐,那么怎么保证图片充满控件,最常规的做法就是:取控件的宽高与图片的宽高比的最大值缩放
Math.max(控件宽度/图片宽度,控件高度/图片高度);同理,取控件宽高与图片宽高的偏移量的一半来平移图片保证居中对齐。 -
在2的基础上,非宽高相等的图片有一部分会显示在控件区域之外,可以通过手指滑动来显示,相信大家都用过PhotoView,效果一致。 移动图片与移动控件的原理一样,都是改变setTranslation的值,不过这里用到了图片矩阵,通过改变Matrix.postTranslate(dx, dy)的值来移动图片。
-
移动图片,那就不得不考虑越界问题,请观察下图,这里以上边界为例(左,右,下边界同理)。注意:这里的越界指的不是数组越界,而是图片滑动到边缘继续沿相同方向滑动,图片未铺满控件区域。 在下图中你会发现:图片跟随手指继续滑动,手指滑动的距离越大阻尼越大,手指抬起后图片会回弹到控件顶部。

-
双指挤压图片缩小,扩散图片放大,缩放中心点是双指中点坐标,那么缩放比例怎么计算呢?最开始取的
缩放因子ScaleGestureDetector.getScaleFactor(),出来的效果真的天马行空(~~轻微挤压扩散图片无限放大缩小~~ ),接着给缩放因子加一个比例,效果依旧不行,哦豁。没办法,打印缩放数据,观察数据,寻找规律。几经尝试最后取了缩放因子的偏移量。~~为了写好控件,没什么捷径,只能多观察,多尝试。~~ 在缩小至越界的状态下,手指抬起,图片放大到充满控件;在放大到一定的阈值后放手后,图片回弹到一定的缩放比例。前文提到了在缩小至越界状态下单指滑动图片,根据四周滑动的距离,会出现阻尼效果,在后文会讲解阻尼算法。 -
图片在滑动或缩放态下,会出现九宫格白色线条,线条始终平分控件内的图片为九等分,滑动或缩放停止线条消失,再次滑动或缩放线条出现,手指抬起后线条消失。
嗯,整个过程的大致行为就是这样了。
开工写代码咯~
起名字
在开始写代码之前,要先给这个自定义控件起一个名字,又哦豁。。。不会起名字, 就叫:裁剪图片控件(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
node-connect
330.3kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
81.3kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
330.3kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
commit-push-pr
81.3kCommit, push, and open a PR
