自定义手势缩放的Recyclerview--漫画组件

自定义手势缩放的Recyclerview

最近做了一个类似腾讯动漫的漫画的阅读器,用Recyclerview作为基础的控件展示漫画。因为漫画需要支持手势缩放,但是原生Recyclerview并不支持,而且开源的缩放Recyclerview也没有找到,只能自己造一个轮子。这篇文章记录了一些思路。

效果预览图:https://github.com/PortgasAce/ZoomRecyclerView

基本原理

通过重写Recyclerview的dispatchDraw()方法,操作canvas缩放和平移实现手势缩放功能。如果对矩阵熟练的话,可以给canvas设置矩阵实现,但是我不熟练。。所以只能通过最基本的canvas平移和缩放实现。

1
2
3
4
5
6
7
8
9
10
protected void dispatchDraw(@NonNull Canvas canvas) {

canvas.save();
canvas.translate(mTranX, mTranY);
canvas.scale(mScaleFactor, mScaleFactor);

// 所有子view都会缩放和平移
super.dispatchDraw(canvas);
canvas.restore();
}

计算过程

通过上面的代码片段可知,只需要x,y方向的偏移量(mTranx,mTranY)和缩放系数(mScaleFactor)就可以实现缩放。

偏移量的计算

偏移量与另一个值相关,就是缩放中心(双击的触摸点 或者 是双指触摸的中心)。设想一下双击屏幕的的左上角和右下角,缩放系数的值相同,但是偏移量不同,双击左上角的偏移量为(0,0),而右下角的偏移量则为(-MaxTranX,-MaxTranY)。

双击屏幕上一点的示例图如下:

demo


总偏移量:
MaxTranX = X1+X2 = W1(S2-S1)
MaxTranY = Y1+Y2 = H1(S2-S1)
X方向偏移量比总偏移量 等于 缩放中心比屏幕宽度
X1/MaxTranX = X1/W1(S2-S1) = Tx/W1
Y1/MaxTranY = Y1/H1(S2-S1) = Ty/H1

X1 = W1(S2-S1)(Tx/W1) = (S2-S1)Tx
Y1 = H1(S2-21)(Ty/H1) = (S2-S1)Ty
最终A2点的坐标因为坐标系的原因需要加一个负号:
A2 = (-X1,-Y1) = (-(S2-S1)Tx,-(S2-S1)Ty)

缩放中心

双击缩放通过GestureDetector实现,缩放中心在onDoubleTap()方法中直接通过MotionEvent的getX()和getY()获取。

双指缩放通过ScaleDetector实现,缩放中心通过ScaleGestureDetector的getFocusX()和getFocusY()获取。

缩放系数

双击缩放时,如果当前的缩放系数不等于1则缩放系数为1,如果当前缩放系数为1,则缩放系数等于最大缩放系数。

双指缩放时,缩放系数为当前缩放系数 乘 onScale回调中detector.getScaleFactor()。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
/**
* 默认缩放比只能为1
* 缩放动画时长暂时没有根据缩放比例改动
*/
@SuppressWarnings("UnnecessaryLocalVariable")
@SuppressLint("ClickableViewAccessibility")
public class ZoomRecyclerView extends RecyclerView {

private static final String TAG = "999";

// constant
private static final int DEFAULT_SCALE_DURATION = 300;
private static final float DEFAULT_SCALE_FACTOR = 1.f;
private static final float DEFAULT_MAX_SCALE_FACTOR = 2.0f;
private static final float DEFAULT_MIN_SCALE_FACTOR = 0.5f;
private static final String PROPERTY_SCALE = "scale";
private static final String PROPERTY_TRANX = "tranX";
private static final String PROPERTY_TRANY = "tranY";
private static final float INVALID_TOUCH_POSITION = -1;

// touch detector
ScaleGestureDetector mScaleDetector;
GestureDetectorCompat mGestureDetector;

// draw param
float mViewWidth; // 宽度
float mViewHeight; // 高度
float mTranX; // x偏移量
float mTranY; // y偏移量
float mScaleFactor; // 缩放系数

// touch param
int mActivePointerId = INVALID_POINTER_ID; // 有效的手指id
float mLastTouchX; // 上一次触摸位置 X
float mLastTouchY; // 上一次触摸位置 Y

// control param
boolean isScaling = false; // 是否正在缩放
boolean isEnableScale = false;// 是否支持缩放

// zoom param
ValueAnimator mScaleAnimator; //缩放动画
float mScaleCenterX; // 缩放中心 X
float mScaleCenterY; // 缩放中心 Y
float mMaxTranX; // 当前缩放系数下最大的X偏移量
float mMaxTranY; // 当前缩放系数下最大的Y偏移量

// config param
float mMaxScaleFactor; // 最大缩放系数
float mMinScaleFactor; // 最小缩放系数
float mDefaultScaleFactor; // 默认缩放系数 双击缩小后的缩放系数 暂不支持小于1
int mScaleDuration; // 缩放时间 ms

public ZoomRecyclerView(Context context) {
super(context);
init(null);
}

public ZoomRecyclerView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}

public ZoomRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(attrs);
}

private void init(AttributeSet attr) {
mScaleDetector = new ScaleGestureDetector(getContext(), new ScaleListener());
mGestureDetector = new GestureDetectorCompat(getContext(), new GestureListener());

if (attr != null) {
TypedArray a = getContext()
.obtainStyledAttributes(attr, R.styleable.ZoomRecyclerView, 0, 0);
mMinScaleFactor =
a.getFloat(R.styleable.ZoomRecyclerView_min_scale, DEFAULT_MIN_SCALE_FACTOR);
mMaxScaleFactor =
a.getFloat(R.styleable.ZoomRecyclerView_max_scale, DEFAULT_MAX_SCALE_FACTOR);
mDefaultScaleFactor = a
.getFloat(R.styleable.ZoomRecyclerView_default_scale, DEFAULT_SCALE_FACTOR);
mScaleFactor = mDefaultScaleFactor;
mScaleDuration = a.getInteger(R.styleable.ZoomRecyclerView_zoom_duration,
DEFAULT_SCALE_DURATION);
a.recycle();
} else {
//init param with default
mMaxScaleFactor = DEFAULT_MAX_SCALE_FACTOR;
mMinScaleFactor = DEFAULT_MIN_SCALE_FACTOR;
mDefaultScaleFactor = DEFAULT_SCALE_FACTOR;
mScaleFactor = mDefaultScaleFactor;
mScaleDuration = DEFAULT_SCALE_DURATION;
}
}

@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
mViewWidth = MeasureSpec.getSize(widthMeasureSpec);
mViewHeight = MeasureSpec.getSize(heightMeasureSpec);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

@Override
public boolean onTouchEvent(@NonNull MotionEvent ev) {

if (!isEnableScale) {
return super.onTouchEvent(ev);
}

boolean retVal = mScaleDetector.onTouchEvent(ev);
retVal = mGestureDetector.onTouchEvent(ev) || retVal;

int action = ev.getActionMasked();

switch (action) {
case ACTION_DOWN: {
final int pointerIndex = ev.getActionIndex();
final float x = ev.getX(pointerIndex);
final float y = ev.getY(pointerIndex);
// Remember where we started (for dragging)
mLastTouchX = x;
mLastTouchY = y;
// Save the ID of this pointer (for dragging)
mActivePointerId = ev.getPointerId(0);
break;
}
case ACTION_MOVE: {
try {
// Find the index of the active pointer and fetch its position
final int pointerIndex = ev.findPointerIndex(mActivePointerId);

final float x = ev.getX(pointerIndex);
final float y = ev.getY(pointerIndex);

if (!isScaling && mScaleFactor > 1) { // 缩放时不做处理
// Calculate the distance moved
final float dx = x - mLastTouchX;
final float dy = y - mLastTouchY;

setTranslateXY(mTranX + dx, mTranY + dy);
correctTranslateXY();
}

invalidate();
// Remember this touch position for the next move event
mLastTouchX = x;
mLastTouchY = y;
} catch (Exception e) {
final float x = ev.getX();
final float y = ev.getY();

if (!isScaling && mScaleFactor > 1 && mLastTouchX != INVALID_TOUCH_POSITION) { // 缩放时不做处理
// Calculate the distance moved
final float dx = x - mLastTouchX;
final float dy = y - mLastTouchY;

setTranslateXY(mTranX + dx, mTranY + dy);
correctTranslateXY();
}

invalidate();
// Remember this touch position for the next move event
mLastTouchX = x;
mLastTouchY = y;
}
break;
}
case ACTION_UP:
case ACTION_CANCEL:
mActivePointerId = INVALID_POINTER_ID;
mLastTouchX = INVALID_TOUCH_POSITION;
mLastTouchY = INVALID_TOUCH_POSITION;
break;
case ACTION_POINTER_UP: {
final int pointerIndex = ev.getActionIndex();
final int pointerId = ev.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
// This was our active pointer going up. Choose a new
// active pointer and adjust accordingly.
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mLastTouchX = ev.getX(newPointerIndex);
mLastTouchY = ev.getY(newPointerIndex);
mActivePointerId = ev.getPointerId(newPointerIndex);
}
break;
}
}

return super.onTouchEvent(ev) || retVal;
}

@SuppressLint("WrongConstant")
@Override
protected void dispatchDraw(@NonNull Canvas canvas) {

canvas.save();
canvas.translate(mTranX, mTranY);
canvas.scale(mScaleFactor, mScaleFactor);

// 所有子view都会缩放和平移
super.dispatchDraw(canvas);
canvas.restore();
}

private void setTranslateXY(float tranX, float tranY) {
mTranX = tranX;
mTranY = tranY;
}

//当scale 大于 1 时修正action move的位置
private void correctTranslateXY() {
float[] correctXY = correctTranslateXY(mTranX, mTranY);
mTranX = correctXY[0];
mTranY = correctXY[1];
}

private float[] correctTranslateXY(float x, float y) {
if (mScaleFactor <= 1) {
return new float[]{x, y};
}

if (x > 0.0f) {
x = 0.0f;
} else if (x < mMaxTranX) {
x = mMaxTranX;
}

if (y > 0.0f) {
y = 0.0f;
} else if (y < mMaxTranY) {
y = mMaxTranY;
}
return new float[]{x, y};
}

private void zoom(float startVal, float endVal) {
if (mScaleAnimator == null) {
newZoomAnimation();
}

if (mScaleAnimator.isRunning()) {
return;
}

//set Value
mMaxTranX = mViewWidth - (mViewWidth * endVal);
mMaxTranY = mViewHeight - (mViewHeight * endVal);

float startTranX = mTranX;
float startTranY = mTranY;
float endTranX = mTranX - (endVal - startVal) * mScaleCenterX;
float endTranY = mTranY - (endVal - startVal) * mScaleCenterY;
float[] correct = correctTranslateXY(endTranX, endTranY);
endTranX = correct[0];
endTranY = correct[1];

PropertyValuesHolder scaleHolder = PropertyValuesHolder
.ofFloat(PROPERTY_SCALE, startVal, endVal);
PropertyValuesHolder tranXHolder = PropertyValuesHolder
.ofFloat(PROPERTY_TRANX, startTranX, endTranX);
PropertyValuesHolder tranYHolder = PropertyValuesHolder
.ofFloat(PROPERTY_TRANY, startTranY, endTranY);
mScaleAnimator.setValues(scaleHolder, tranXHolder, tranYHolder);
mScaleAnimator.setDuration(mScaleDuration);
mScaleAnimator.start();
}

private void newZoomAnimation() {
mScaleAnimator = new ValueAnimator();
mScaleAnimator.setInterpolator(new DecelerateInterpolator());
mScaleAnimator.addUpdateListener(new AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//update scaleFactor & tranX & tranY
mScaleFactor = (float) animation.getAnimatedValue(PROPERTY_SCALE);
setTranslateXY(
(float) animation.getAnimatedValue(PROPERTY_TRANX),
(float) animation.getAnimatedValue(PROPERTY_TRANY)
);
invalidate();
}
});

// set listener to update scale flag
mScaleAnimator.addListener(new AnimatorListenerAdapter() {

@Override
public void onAnimationStart(Animator animation) {
isScaling = true;
}

@Override
public void onAnimationEnd(Animator animation) {
isScaling = false;
}

@Override
public void onAnimationCancel(Animator animation) {
isScaling = false;
}

});

}

// handle scale event
private class ScaleListener implements OnScaleGestureListener {

@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
return true;
}

@Override
public boolean onScale(ScaleGestureDetector detector) {
final float mLastScale = mScaleFactor;
mScaleFactor *= detector.getScaleFactor();
//修正scaleFactor
mScaleFactor = Math.max(mMinScaleFactor, Math.min(mScaleFactor, mMaxScaleFactor));

mMaxTranX = mViewWidth - (mViewWidth * mScaleFactor);
mMaxTranY = mViewHeight - (mViewHeight * mScaleFactor);

mScaleCenterX = detector.getFocusX();
mScaleCenterY = detector.getFocusY();

float offsetX = mScaleCenterX * (mLastScale - mScaleFactor);
float offsetY = mScaleCenterY * (mLastScale - mScaleFactor);

setTranslateXY(mTranX + offsetX, mTranY + offsetY);

isScaling = true;
invalidate();
return true;
}

@Override
public void onScaleEnd(ScaleGestureDetector detector) {
if (mScaleFactor <= mDefaultScaleFactor) {
mScaleCenterX = -mTranX / (mScaleFactor - 1);
mScaleCenterY = -mTranY / (mScaleFactor - 1);
mScaleCenterX = Float.isNaN(mScaleCenterX) ? 0 : mScaleCenterX;
mScaleCenterY = Float.isNaN(mScaleCenterY) ? 0 : mScaleCenterY;
zoom(mScaleFactor, mDefaultScaleFactor);
}
isScaling = false;
}
}

private class GestureListener extends GestureDetector.SimpleOnGestureListener {

@Override
public boolean onDoubleTap(MotionEvent e) {
float startFactor = mScaleFactor;
float endFactor;

if (mScaleFactor == mDefaultScaleFactor) {
mScaleCenterX = e.getX();
mScaleCenterY = e.getY();
endFactor = mMaxScaleFactor;
} else {
mScaleCenterX = mScaleFactor == 1 ? e.getX() : -mTranX / (mScaleFactor - 1);
mScaleCenterY = mScaleFactor == 1 ? e.getY() : -mTranY / (mScaleFactor - 1);
endFactor = mDefaultScaleFactor;
}
zoom(startFactor, endFactor);
boolean retVal = super.onDoubleTap(e);
return retVal;
}
}

// public method
public void setEnableScale(boolean enable) {
if (isEnableScale == enable) {
return;
}
this.isEnableScale = enable;
// 禁用了 恢复比例1
if (!isEnableScale && mScaleFactor != 1) {
zoom(mScaleFactor, 1);
}
}

public boolean isEnableScale() {
return isEnableScale;
}

}

Github:
https://github.com/PortgasAce/ZoomRecyclerView

以上。

参考

google developer scale
google developer scroll

赞赏还是要有的QAQ
100斤 WeChat Pay

微信打赏

100斤 Alipay

支付宝打赏