This repository has been archived by the owner on Nov 8, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 6.2k
/
SmartSelectSprite.java
550 lines (455 loc) · 20.6 KB
/
SmartSelectSprite.java
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
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.widget;
import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.annotation.ColorInt;
import android.annotation.FloatRange;
import android.annotation.IntDef;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PointF;
import android.graphics.RectF;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.Shape;
import android.text.Layout;
import android.view.animation.AnimationUtils;
import android.view.animation.Interpolator;
import java.lang.annotation.Retention;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
/**
* A utility class for creating and animating the Smart Select animation.
*/
final class SmartSelectSprite {
private static final int EXPAND_DURATION = 200;
private final Interpolator mExpandInterpolator;
private Animator mActiveAnimator = null;
private final Runnable mInvalidator;
@ColorInt
private final int mFillColor;
static final Comparator<RectF> RECTANGLE_COMPARATOR = Comparator
.<RectF>comparingDouble(e -> e.bottom)
.thenComparingDouble(e -> e.left);
private Drawable mExistingDrawable = null;
private RectangleList mExistingRectangleList = null;
static final class RectangleWithTextSelectionLayout {
private final RectF mRectangle;
@Layout.TextSelectionLayout
private final int mTextSelectionLayout;
RectangleWithTextSelectionLayout(RectF rectangle, int textSelectionLayout) {
mRectangle = Objects.requireNonNull(rectangle);
mTextSelectionLayout = textSelectionLayout;
}
public RectF getRectangle() {
return mRectangle;
}
@Layout.TextSelectionLayout
public int getTextSelectionLayout() {
return mTextSelectionLayout;
}
}
/**
* A rounded rectangle with a configurable corner radius and the ability to expand outside of
* its bounding rectangle and clip against it.
*/
private static final class RoundedRectangleShape extends Shape {
private static final String PROPERTY_ROUND_RATIO = "roundRatio";
/**
* The direction in which the rectangle will perform its expansion. A rectangle can expand
* from its left edge, its right edge or from the center (or, more precisely, the user's
* touch point). For example, in left-to-right text, a selection spanning two lines with the
* user's action being on the first line will have the top rectangle and expansion direction
* of CENTER, while the bottom one will have an expansion direction of RIGHT.
*/
@Retention(SOURCE)
@IntDef({ExpansionDirection.LEFT, ExpansionDirection.CENTER, ExpansionDirection.RIGHT})
private @interface ExpansionDirection {
int LEFT = -1;
int CENTER = 0;
int RIGHT = 1;
}
private static @ExpansionDirection int invert(@ExpansionDirection int expansionDirection) {
return expansionDirection * -1;
}
private final RectF mBoundingRectangle;
private float mRoundRatio = 1.0f;
private final @ExpansionDirection int mExpansionDirection;
private final RectF mDrawRect = new RectF();
private final Path mClipPath = new Path();
/** How offset the left edge of the rectangle is from the left side of the bounding box. */
private float mLeftBoundary = 0;
/** How offset the right edge of the rectangle is from the left side of the bounding box. */
private float mRightBoundary = 0;
/** Whether the horizontal bounds are inverted (for RTL scenarios). */
private final boolean mInverted;
private final float mBoundingWidth;
private RoundedRectangleShape(
final RectF boundingRectangle,
final @ExpansionDirection int expansionDirection,
final boolean inverted) {
mBoundingRectangle = new RectF(boundingRectangle);
mBoundingWidth = boundingRectangle.width();
mInverted = inverted && expansionDirection != ExpansionDirection.CENTER;
if (inverted) {
mExpansionDirection = invert(expansionDirection);
} else {
mExpansionDirection = expansionDirection;
}
if (boundingRectangle.height() > boundingRectangle.width()) {
setRoundRatio(0.0f);
} else {
setRoundRatio(1.0f);
}
}
/*
* In order to achieve the "rounded rectangle hits the wall" effect, we draw an expanding
* rounded rectangle that is clipped by the bounding box of the selected text.
*/
@Override
public void draw(Canvas canvas, Paint paint) {
if (mLeftBoundary == mRightBoundary) {
return;
}
final float cornerRadius = getCornerRadius();
final float adjustedCornerRadius = getAdjustedCornerRadius();
mDrawRect.set(mBoundingRectangle);
mDrawRect.left = mBoundingRectangle.left + mLeftBoundary - cornerRadius / 2;
mDrawRect.right = mBoundingRectangle.left + mRightBoundary + cornerRadius / 2;
canvas.save();
mClipPath.reset();
mClipPath.addRoundRect(
mDrawRect,
adjustedCornerRadius,
adjustedCornerRadius,
Path.Direction.CW);
canvas.clipPath(mClipPath);
canvas.drawRect(mBoundingRectangle, paint);
canvas.restore();
}
void setRoundRatio(@FloatRange(from = 0.0, to = 1.0) final float roundRatio) {
mRoundRatio = roundRatio;
}
float getRoundRatio() {
return mRoundRatio;
}
private void setStartBoundary(final float startBoundary) {
if (mInverted) {
mRightBoundary = mBoundingWidth - startBoundary;
} else {
mLeftBoundary = startBoundary;
}
}
private void setEndBoundary(final float endBoundary) {
if (mInverted) {
mLeftBoundary = mBoundingWidth - endBoundary;
} else {
mRightBoundary = endBoundary;
}
}
private float getCornerRadius() {
return Math.min(mBoundingRectangle.width(), mBoundingRectangle.height());
}
private float getAdjustedCornerRadius() {
return (getCornerRadius() * mRoundRatio);
}
private float getBoundingWidth() {
return (int) (mBoundingRectangle.width() + getCornerRadius());
}
}
/**
* A collection of {@link RoundedRectangleShape}s that abstracts them to a single shape whose
* collective left and right boundary can be manipulated.
*/
private static final class RectangleList extends Shape {
@Retention(SOURCE)
@IntDef({DisplayType.RECTANGLES, DisplayType.POLYGON})
private @interface DisplayType {
int RECTANGLES = 0;
int POLYGON = 1;
}
private static final String PROPERTY_RIGHT_BOUNDARY = "rightBoundary";
private static final String PROPERTY_LEFT_BOUNDARY = "leftBoundary";
private final List<RoundedRectangleShape> mRectangles;
private final List<RoundedRectangleShape> mReversedRectangles;
private final Path mOutlinePolygonPath;
private @DisplayType int mDisplayType = DisplayType.RECTANGLES;
private RectangleList(final List<RoundedRectangleShape> rectangles) {
mRectangles = new ArrayList<>(rectangles);
mReversedRectangles = new ArrayList<>(rectangles);
Collections.reverse(mReversedRectangles);
mOutlinePolygonPath = generateOutlinePolygonPath(rectangles);
}
private void setLeftBoundary(final float leftBoundary) {
float boundarySoFar = getTotalWidth();
for (RoundedRectangleShape rectangle : mReversedRectangles) {
final float rectangleLeftBoundary = boundarySoFar - rectangle.getBoundingWidth();
if (leftBoundary < rectangleLeftBoundary) {
rectangle.setStartBoundary(0);
} else if (leftBoundary > boundarySoFar) {
rectangle.setStartBoundary(rectangle.getBoundingWidth());
} else {
rectangle.setStartBoundary(
rectangle.getBoundingWidth() - boundarySoFar + leftBoundary);
}
boundarySoFar = rectangleLeftBoundary;
}
}
private void setRightBoundary(final float rightBoundary) {
float boundarySoFar = 0;
for (RoundedRectangleShape rectangle : mRectangles) {
final float rectangleRightBoundary = rectangle.getBoundingWidth() + boundarySoFar;
if (rectangleRightBoundary < rightBoundary) {
rectangle.setEndBoundary(rectangle.getBoundingWidth());
} else if (boundarySoFar > rightBoundary) {
rectangle.setEndBoundary(0);
} else {
rectangle.setEndBoundary(rightBoundary - boundarySoFar);
}
boundarySoFar = rectangleRightBoundary;
}
}
void setDisplayType(@DisplayType int displayType) {
mDisplayType = displayType;
}
private int getTotalWidth() {
int sum = 0;
for (RoundedRectangleShape rectangle : mRectangles) {
sum += rectangle.getBoundingWidth();
}
return sum;
}
@Override
public void draw(Canvas canvas, Paint paint) {
if (mDisplayType == DisplayType.POLYGON) {
drawPolygon(canvas, paint);
} else {
drawRectangles(canvas, paint);
}
}
private void drawRectangles(final Canvas canvas, final Paint paint) {
for (RoundedRectangleShape rectangle : mRectangles) {
rectangle.draw(canvas, paint);
}
}
private void drawPolygon(final Canvas canvas, final Paint paint) {
canvas.drawPath(mOutlinePolygonPath, paint);
}
private static Path generateOutlinePolygonPath(
final List<RoundedRectangleShape> rectangles) {
final Path path = new Path();
for (final RoundedRectangleShape shape : rectangles) {
final Path rectanglePath = new Path();
rectanglePath.addRect(shape.mBoundingRectangle, Path.Direction.CW);
path.op(rectanglePath, Path.Op.UNION);
}
return path;
}
}
/**
* @param context the {@link Context} in which the animation will run
* @param highlightColor the highlight color of the underlying {@link TextView}
* @param invalidator a {@link Runnable} which will be called every time the animation updates,
* indicating that the view drawing the animation should invalidate itself
*/
SmartSelectSprite(final Context context, @ColorInt int highlightColor,
final Runnable invalidator) {
mExpandInterpolator = AnimationUtils.loadInterpolator(
context,
android.R.interpolator.fast_out_slow_in);
mFillColor = highlightColor;
mInvalidator = Objects.requireNonNull(invalidator);
}
/**
* Performs the Smart Select animation on the view bound to this SmartSelectSprite.
*
* @param start The point from which the animation will start. Must be inside
* destinationRectangles.
* @param destinationRectangles The rectangles which the animation will fill out by its
* "selection" and finally join them into a single polygon. In
* order to get the correct visual behavior, these rectangles
* should be sorted according to {@link #RECTANGLE_COMPARATOR}.
* @param onAnimationEnd the callback which will be invoked once the whole animation
* completes
* @throws IllegalArgumentException if the given start point is not in any of the
* destinationRectangles
* @see #cancelAnimation()
*/
// TODO nullability checks on parameters
public void startAnimation(
final PointF start,
final List<RectangleWithTextSelectionLayout> destinationRectangles,
final Runnable onAnimationEnd) {
cancelAnimation();
final ValueAnimator.AnimatorUpdateListener updateListener =
valueAnimator -> mInvalidator.run();
final int rectangleCount = destinationRectangles.size();
final List<RoundedRectangleShape> shapes = new ArrayList<>(rectangleCount);
RectangleWithTextSelectionLayout centerRectangle = null;
int startingOffset = 0;
for (RectangleWithTextSelectionLayout rectangleWithTextSelectionLayout :
destinationRectangles) {
final RectF rectangle = rectangleWithTextSelectionLayout.getRectangle();
if (contains(rectangle, start)) {
centerRectangle = rectangleWithTextSelectionLayout;
break;
}
startingOffset += rectangle.width();
}
if (centerRectangle == null) {
throw new IllegalArgumentException("Center point is not inside any of the rectangles!");
}
startingOffset += start.x - centerRectangle.getRectangle().left;
final @RoundedRectangleShape.ExpansionDirection int[] expansionDirections =
generateDirections(centerRectangle, destinationRectangles);
for (int index = 0; index < rectangleCount; ++index) {
final RectangleWithTextSelectionLayout rectangleWithTextSelectionLayout =
destinationRectangles.get(index);
final RectF rectangle = rectangleWithTextSelectionLayout.getRectangle();
final RoundedRectangleShape shape = new RoundedRectangleShape(
rectangle,
expansionDirections[index],
rectangleWithTextSelectionLayout.getTextSelectionLayout()
== Layout.TEXT_SELECTION_LAYOUT_RIGHT_TO_LEFT);
shapes.add(shape);
}
final RectangleList rectangleList = new RectangleList(shapes);
final ShapeDrawable shapeDrawable = new ShapeDrawable(rectangleList);
final Paint paint = shapeDrawable.getPaint();
paint.setColor(mFillColor);
paint.setStyle(Paint.Style.FILL);
mExistingRectangleList = rectangleList;
mExistingDrawable = shapeDrawable;
mActiveAnimator = createAnimator(rectangleList, startingOffset, startingOffset,
updateListener, onAnimationEnd);
mActiveAnimator.start();
}
/** Returns whether the sprite is currently animating. */
public boolean isAnimationActive() {
return mActiveAnimator != null && mActiveAnimator.isRunning();
}
private Animator createAnimator(
final RectangleList rectangleList,
final float startingOffsetLeft,
final float startingOffsetRight,
final ValueAnimator.AnimatorUpdateListener updateListener,
final Runnable onAnimationEnd) {
final ObjectAnimator rightBoundaryAnimator = ObjectAnimator.ofFloat(
rectangleList,
RectangleList.PROPERTY_RIGHT_BOUNDARY,
startingOffsetRight,
rectangleList.getTotalWidth());
final ObjectAnimator leftBoundaryAnimator = ObjectAnimator.ofFloat(
rectangleList,
RectangleList.PROPERTY_LEFT_BOUNDARY,
startingOffsetLeft,
0);
rightBoundaryAnimator.setDuration(EXPAND_DURATION);
leftBoundaryAnimator.setDuration(EXPAND_DURATION);
rightBoundaryAnimator.addUpdateListener(updateListener);
leftBoundaryAnimator.addUpdateListener(updateListener);
rightBoundaryAnimator.setInterpolator(mExpandInterpolator);
leftBoundaryAnimator.setInterpolator(mExpandInterpolator);
final AnimatorSet boundaryAnimator = new AnimatorSet();
boundaryAnimator.playTogether(leftBoundaryAnimator, rightBoundaryAnimator);
setUpAnimatorListener(boundaryAnimator, onAnimationEnd);
return boundaryAnimator;
}
private void setUpAnimatorListener(final Animator animator, final Runnable onAnimationEnd) {
animator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {
}
@Override
public void onAnimationEnd(Animator animator) {
mExistingRectangleList.setDisplayType(RectangleList.DisplayType.POLYGON);
mInvalidator.run();
onAnimationEnd.run();
}
@Override
public void onAnimationCancel(Animator animator) {
}
@Override
public void onAnimationRepeat(Animator animator) {
}
});
}
private static @RoundedRectangleShape.ExpansionDirection int[] generateDirections(
final RectangleWithTextSelectionLayout centerRectangle,
final List<RectangleWithTextSelectionLayout> rectangles) {
final @RoundedRectangleShape.ExpansionDirection int[] result = new int[rectangles.size()];
final int centerRectangleIndex = rectangles.indexOf(centerRectangle);
for (int i = 0; i < centerRectangleIndex - 1; ++i) {
result[i] = RoundedRectangleShape.ExpansionDirection.LEFT;
}
if (rectangles.size() == 1) {
result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.CENTER;
} else if (centerRectangleIndex == 0) {
result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.LEFT;
} else if (centerRectangleIndex == rectangles.size() - 1) {
result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.RIGHT;
} else {
result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.CENTER;
}
for (int i = centerRectangleIndex + 1; i < result.length; ++i) {
result[i] = RoundedRectangleShape.ExpansionDirection.RIGHT;
}
return result;
}
/**
* A variant of {@link RectF#contains(float, float)} that also allows the point to reside on
* the right boundary of the rectangle.
*
* @param rectangle the rectangle inside which the point should be to be considered "contained"
* @param point the point which will be tested
* @return whether the point is inside the rectangle (or on it's right boundary)
*/
private static boolean contains(final RectF rectangle, final PointF point) {
final float x = point.x;
final float y = point.y;
return x >= rectangle.left && x <= rectangle.right && y >= rectangle.top
&& y <= rectangle.bottom;
}
private void removeExistingDrawables() {
mExistingDrawable = null;
mExistingRectangleList = null;
mInvalidator.run();
}
/**
* Cancels any active Smart Select animation that might be in progress.
*/
public void cancelAnimation() {
if (mActiveAnimator != null) {
mActiveAnimator.cancel();
mActiveAnimator = null;
removeExistingDrawables();
}
}
public void draw(Canvas canvas) {
if (mExistingDrawable != null) {
mExistingDrawable.draw(canvas);
}
}
}