1 | // Copyright (c) 2012 The Chromium Authors. All rights reserved. |
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | package org.chromium.content.browser; |
6 | |
7 | import android.content.Context; |
8 | import android.content.res.Resources; |
9 | import android.graphics.Bitmap; |
10 | import android.graphics.Canvas; |
11 | import android.graphics.Color; |
12 | import android.graphics.Paint; |
13 | import android.graphics.Path; |
14 | import android.graphics.Path.Direction; |
15 | import android.graphics.PointF; |
16 | import android.graphics.PorterDuff.Mode; |
17 | import android.graphics.PorterDuffXfermode; |
18 | import android.graphics.Rect; |
19 | import android.graphics.RectF; |
20 | import android.graphics.Region.Op; |
21 | import android.graphics.drawable.ColorDrawable; |
22 | import android.graphics.drawable.Drawable; |
23 | import android.os.SystemClock; |
24 | import android.util.Log; |
25 | import android.view.GestureDetector; |
26 | import android.view.MotionEvent; |
27 | import android.view.View; |
28 | import android.view.animation.Interpolator; |
29 | import android.view.animation.OvershootInterpolator; |
30 | |
31 | import org.chromium.content.R; |
32 | |
33 | /** |
34 | * PopupZoomer is used to show the on-demand link zooming popup. It handles manipulation of the |
35 | * canvas and touch events to display the on-demand zoom magnifier. |
36 | */ |
37 | class PopupZoomer extends View { |
38 | private static String LOGTAG = "PopupZoomer"; |
39 | |
40 | // The padding between the edges of the view and the popup. Note that there is a mirror |
41 | // constant in content/renderer/render_view_impl.cc which should be kept in sync if |
42 | // this is changed. |
43 | private static final int ZOOM_BOUNDS_MARGIN = 25; |
44 | // Time it takes for the animation to finish in ms. |
45 | private static final long ANIMATION_DURATION = 300; |
46 | |
47 | /** |
48 | * Interface to be implemented to listen for touch events inside the zoomed area. |
49 | * The MotionEvent coordinates correspond to original unzoomed view. |
50 | */ |
51 | public static interface OnTapListener { |
52 | public boolean onSingleTap(View v, MotionEvent event); |
53 | public boolean onLongPress(View v, MotionEvent event); |
54 | } |
55 | |
56 | private OnTapListener mOnTapListener = null; |
57 | |
58 | /** |
59 | * Interface to be implemented to add and remove PopupZoomer to/from the view hierarchy. |
60 | */ |
61 | public static interface OnVisibilityChangedListener { |
62 | public void onPopupZoomerShown(PopupZoomer zoomer); |
63 | public void onPopupZoomerHidden(PopupZoomer zoomer); |
64 | } |
65 | |
66 | private OnVisibilityChangedListener mOnVisibilityChangedListener = null; |
67 | |
68 | // Cached drawable used to frame the zooming popup. |
69 | // TODO(tonyg): This should be marked purgeable so that if the system wants to recover this |
70 | // memory, we can just reload it from the resource ID next time it is needed. |
71 | // See android.graphics.BitmapFactory.Options#inPurgeable |
72 | private static Drawable sOverlayDrawable; |
73 | // The padding used for drawing the overlay around the content, instead of directly above it. |
74 | private static Rect sOverlayPadding; |
75 | // The radius of the overlay bubble, used for rounding the bitmap to draw underneath it. |
76 | private static float sOverlayCornerRadius; |
77 | |
78 | private final Interpolator mShowInterpolator = new OvershootInterpolator(); |
79 | private final Interpolator mHideInterpolator = new ReverseInterpolator(mShowInterpolator); |
80 | |
81 | private boolean mAnimating = false; |
82 | private boolean mShowing = false; |
83 | private long mAnimationStartTime = 0; |
84 | |
85 | // The time that was left for the outwards animation to finish. |
86 | // This is used in the case that the zoomer is cancelled while it is still animating outwards, |
87 | // to avoid having it jump to full size then animate closed. |
88 | private long mTimeLeft = 0; |
89 | |
90 | // initDimensions() needs to be called in onDraw(). |
91 | private boolean mNeedsToInitDimensions; |
92 | |
93 | // Available view area after accounting for ZOOM_BOUNDS_MARGIN. |
94 | private RectF mViewClipRect; |
95 | |
96 | // The target rect to be zoomed. |
97 | private Rect mTargetBounds; |
98 | |
99 | // The bitmap to hold the zoomed view. |
100 | private Bitmap mZoomedBitmap; |
101 | |
102 | // How far to shift the canvas after all zooming is done, to keep it inside the bounds of the |
103 | // view (including margin). |
104 | private float mShiftX = 0, mShiftY = 0; |
105 | // The magnification factor of the popup. It is recomputed once we have mTargetBounds and |
106 | // mZoomedBitmap. |
107 | private float mScale = 1.0f; |
108 | // The bounds representing the actual zoomed popup. |
109 | private RectF mClipRect; |
110 | // The extrusion values are how far the zoomed area (mClipRect) extends from the touch point. |
111 | // These values to used to animate the popup. |
112 | private float mLeftExtrusion, mTopExtrusion, mRightExtrusion, mBottomExtrusion; |
113 | // The last touch point, where the animation will start from. |
114 | private final PointF mTouch = new PointF(); |
115 | |
116 | // Since we sometimes overflow the bounds of the mViewClipRect, we need to allow scrolling. |
117 | // Current scroll position. |
118 | private float mPopupScrollX, mPopupScrollY; |
119 | // Scroll bounds. |
120 | private float mMinScrollX, mMaxScrollX; |
121 | private float mMinScrollY, mMaxScrollY; |
122 | |
123 | private GestureDetector mGestureDetector; |
124 | |
125 | private static float getOverlayCornerRadius(Context context) { |
126 | if (sOverlayCornerRadius == 0) { |
127 | try { |
128 | sOverlayCornerRadius = context.getResources().getDimension( |
129 | R.dimen.link_preview_overlay_radius); |
130 | } catch (Resources.NotFoundException e) { |
131 | Log.w(LOGTAG, "No corner radius resource for PopupZoomer overlay found."); |
132 | sOverlayCornerRadius = 1.0f; |
133 | } |
134 | } |
135 | return sOverlayCornerRadius; |
136 | } |
137 | |
138 | /** |
139 | * Gets the drawable that should be used to frame the zooming popup, loading |
140 | * it from the resource bundle if not already cached. |
141 | */ |
142 | private static Drawable getOverlayDrawable(Context context) { |
143 | if (sOverlayDrawable == null) { |
144 | try { |
145 | sOverlayDrawable = context.getResources().getDrawable( |
146 | R.drawable.ondemand_overlay); |
147 | } catch (Resources.NotFoundException e) { |
148 | Log.w(LOGTAG, "No drawable resource for PopupZoomer overlay found."); |
149 | sOverlayDrawable = new ColorDrawable(); |
150 | } |
151 | sOverlayPadding = new Rect(); |
152 | sOverlayDrawable.getPadding(sOverlayPadding); |
153 | } |
154 | return sOverlayDrawable; |
155 | } |
156 | |
157 | private static float constrain(float amount, float low, float high) { |
158 | return amount < low ? low : (amount > high ? high : amount); |
159 | } |
160 | |
161 | private static int constrain(int amount, int low, int high) { |
162 | return amount < low ? low : (amount > high ? high : amount); |
163 | } |
164 | |
165 | /** |
166 | * Creates Popupzoomer. |
167 | * @param context Context to be used. |
168 | * @param overlayRadiusDimensoinResId Resource to be used to get overlay corner radius. |
169 | */ |
170 | public PopupZoomer(Context context) { |
171 | super(context); |
172 | |
173 | setVisibility(INVISIBLE); |
174 | setFocusable(true); |
175 | setFocusableInTouchMode(true); |
176 | |
177 | GestureDetector.SimpleOnGestureListener listener = |
178 | new GestureDetector.SimpleOnGestureListener() { |
179 | @Override |
180 | public boolean onScroll(MotionEvent e1, MotionEvent e2, |
181 | float distanceX, float distanceY) { |
182 | if (mAnimating) return true; |
183 | |
184 | if (isTouchOutsideArea(e1.getX(), e1.getY())) { |
185 | hide(true); |
186 | } else { |
187 | scroll(distanceX, distanceY); |
188 | } |
189 | return true; |
190 | } |
191 | |
192 | @Override |
193 | public boolean onSingleTapUp(MotionEvent e) { |
194 | return handleTapOrPress(e, false); |
195 | } |
196 | |
197 | @Override |
198 | public void onLongPress(MotionEvent e) { |
199 | handleTapOrPress(e, true); |
200 | } |
201 | |
202 | private boolean handleTapOrPress(MotionEvent e, boolean isLongPress) { |
203 | if (mAnimating) return true; |
204 | |
205 | float x = e.getX(); |
206 | float y = e.getY(); |
207 | if (isTouchOutsideArea(x, y)) { |
208 | // User clicked on area outside the popup. |
209 | hide(true); |
210 | } else if (mOnTapListener != null) { |
211 | PointF converted = convertTouchPoint(x, y); |
212 | MotionEvent event = MotionEvent.obtainNoHistory(e); |
213 | event.setLocation(converted.x, converted.y); |
214 | if (isLongPress) { |
215 | mOnTapListener.onLongPress(PopupZoomer.this, event); |
216 | } else { |
217 | mOnTapListener.onSingleTap(PopupZoomer.this, event); |
218 | } |
219 | hide(true); |
220 | } |
221 | return true; |
222 | } |
223 | }; |
224 | mGestureDetector = new GestureDetector(context, listener); |
225 | } |
226 | |
227 | /** |
228 | * Sets the OnTapListener. |
229 | */ |
230 | public void setOnTapListener(OnTapListener listener) { |
231 | mOnTapListener = listener; |
232 | } |
233 | |
234 | /** |
235 | * Sets the OnVisibilityChangedListener. |
236 | */ |
237 | public void setOnVisibilityChangedListener(OnVisibilityChangedListener listener) { |
238 | mOnVisibilityChangedListener = listener; |
239 | } |
240 | |
241 | /** |
242 | * Sets the bitmap to be used for the zoomed view. |
243 | */ |
244 | public void setBitmap(Bitmap bitmap) { |
245 | if (mZoomedBitmap != null) { |
246 | mZoomedBitmap.recycle(); |
247 | mZoomedBitmap = null; |
248 | } |
249 | mZoomedBitmap = bitmap; |
250 | |
251 | // Round the corners of the bitmap so it doesn't stick out around the overlay. |
252 | Canvas canvas = new Canvas(mZoomedBitmap); |
253 | Path path = new Path(); |
254 | RectF canvasRect = new RectF(0, 0, canvas.getWidth(), canvas.getHeight()); |
255 | float overlayCornerRadius = getOverlayCornerRadius(getContext()); |
256 | path.addRoundRect(canvasRect, overlayCornerRadius, overlayCornerRadius, Direction.CCW); |
257 | canvas.clipPath(path, Op.XOR); |
258 | Paint clearPaint = new Paint(); |
259 | clearPaint.setXfermode(new PorterDuffXfermode(Mode.SRC)); |
260 | clearPaint.setColor(Color.TRANSPARENT); |
261 | canvas.drawPaint(clearPaint); |
262 | } |
263 | |
264 | private void scroll(float x, float y) { |
265 | mPopupScrollX = constrain(mPopupScrollX - x, mMinScrollX, mMaxScrollX); |
266 | mPopupScrollY = constrain(mPopupScrollY - y, mMinScrollY, mMaxScrollY); |
267 | invalidate(); |
268 | } |
269 | |
270 | private void startAnimation(boolean show) { |
271 | mAnimating = true; |
272 | mShowing = show; |
273 | mTimeLeft = 0; |
274 | if (show) { |
275 | setVisibility(VISIBLE); |
276 | mNeedsToInitDimensions = true; |
277 | if (mOnVisibilityChangedListener != null) { |
278 | mOnVisibilityChangedListener.onPopupZoomerShown(this); |
279 | } |
280 | } else { |
281 | long endTime = mAnimationStartTime + ANIMATION_DURATION; |
282 | mTimeLeft = endTime - SystemClock.uptimeMillis(); |
283 | if (mTimeLeft < 0) mTimeLeft = 0; |
284 | } |
285 | mAnimationStartTime = SystemClock.uptimeMillis(); |
286 | invalidate(); |
287 | } |
288 | |
289 | private void hideImmediately() { |
290 | mAnimating = false; |
291 | mShowing = false; |
292 | mTimeLeft = 0; |
293 | if (mOnVisibilityChangedListener != null) { |
294 | mOnVisibilityChangedListener.onPopupZoomerHidden(this); |
295 | } |
296 | setVisibility(INVISIBLE); |
297 | mZoomedBitmap.recycle(); |
298 | mZoomedBitmap = null; |
299 | } |
300 | |
301 | /** |
302 | * Returns true if the view is currently being shown (or is animating). |
303 | */ |
304 | public boolean isShowing() { |
305 | return mShowing || mAnimating; |
306 | } |
307 | |
308 | /** |
309 | * Sets the last touch point (on the unzoomed view). |
310 | */ |
311 | public void setLastTouch(float x, float y) { |
312 | mTouch.x = x; |
313 | mTouch.y = y; |
314 | } |
315 | |
316 | private void setTargetBounds(Rect rect) { |
317 | mTargetBounds = rect; |
318 | } |
319 | |
320 | private void initDimensions() { |
321 | if (mTargetBounds == null || mTouch == null) return; |
322 | |
323 | // Compute the final zoom scale. |
324 | mScale = (float) mZoomedBitmap.getWidth() / mTargetBounds.width(); |
325 | |
326 | float l = mTouch.x - mScale * (mTouch.x - mTargetBounds.left); |
327 | float t = mTouch.y - mScale * (mTouch.y - mTargetBounds.top); |
328 | float r = l + mZoomedBitmap.getWidth(); |
329 | float b = t + mZoomedBitmap.getHeight(); |
330 | mClipRect = new RectF(l, t, r, b); |
331 | int width = getWidth(); |
332 | int height = getHeight(); |
333 | |
334 | mViewClipRect = new RectF(ZOOM_BOUNDS_MARGIN, |
335 | ZOOM_BOUNDS_MARGIN, |
336 | width - ZOOM_BOUNDS_MARGIN, |
337 | height - ZOOM_BOUNDS_MARGIN); |
338 | |
339 | // Ensure it stays inside the bounds of the view. First shift it around to see if it |
340 | // can fully fit in the view, then clip it to the padding section of the view to |
341 | // ensure no overflow. |
342 | mShiftX = 0; |
343 | mShiftY = 0; |
344 | |
345 | // Right now this has the happy coincidence of showing the leftmost portion |
346 | // of a scaled up bitmap, which usually has the text in it. When we want to support |
347 | // RTL languages, we can conditionally switch the order of this check to push it |
348 | // to the left instead of right. |
349 | if (mClipRect.left < ZOOM_BOUNDS_MARGIN) { |
350 | mShiftX = ZOOM_BOUNDS_MARGIN - mClipRect.left; |
351 | mClipRect.left += mShiftX; |
352 | mClipRect.right += mShiftX; |
353 | } else if (mClipRect.right > width - ZOOM_BOUNDS_MARGIN) { |
354 | mShiftX = (width - ZOOM_BOUNDS_MARGIN - mClipRect.right); |
355 | mClipRect.right += mShiftX; |
356 | mClipRect.left += mShiftX; |
357 | } |
358 | if (mClipRect.top < ZOOM_BOUNDS_MARGIN) { |
359 | mShiftY = ZOOM_BOUNDS_MARGIN - mClipRect.top; |
360 | mClipRect.top += mShiftY; |
361 | mClipRect.bottom += mShiftY; |
362 | } else if (mClipRect.bottom > height - ZOOM_BOUNDS_MARGIN) { |
363 | mShiftY = height - ZOOM_BOUNDS_MARGIN - mClipRect.bottom; |
364 | mClipRect.bottom += mShiftY; |
365 | mClipRect.top += mShiftY; |
366 | } |
367 | |
368 | // Allow enough scrolling to get to the entire bitmap that may be clipped inside the |
369 | // bounds of the view. |
370 | mMinScrollX = mMaxScrollX = mMinScrollY = mMaxScrollY = 0; |
371 | if (mViewClipRect.right + mShiftX < mClipRect.right) { |
372 | mMinScrollX = mViewClipRect.right - mClipRect.right; |
373 | } |
374 | if (mViewClipRect.left + mShiftX > mClipRect.left) { |
375 | mMaxScrollX = mViewClipRect.left - mClipRect.left; |
376 | } |
377 | if (mViewClipRect.top + mShiftY > mClipRect.top) { |
378 | mMaxScrollY = mViewClipRect.top - mClipRect.top; |
379 | } |
380 | if (mViewClipRect.bottom + mShiftY < mClipRect.bottom) { |
381 | mMinScrollY = mViewClipRect.bottom - mClipRect.bottom; |
382 | } |
383 | // Now that we know how much we need to scroll, we can intersect with mViewClipRect. |
384 | mClipRect.intersect(mViewClipRect); |
385 | |
386 | mLeftExtrusion = mTouch.x - mClipRect.left; |
387 | mRightExtrusion = mClipRect.right - mTouch.x; |
388 | mTopExtrusion = mTouch.y - mClipRect.top; |
389 | mBottomExtrusion = mClipRect.bottom - mTouch.y; |
390 | |
391 | // Set an initial scroll position to take touch point into account. |
392 | float percentX = |
393 | (mTouch.x - mTargetBounds.centerX()) / (mTargetBounds.width() / 2.f) + .5f; |
394 | float percentY = |
395 | (mTouch.y - mTargetBounds.centerY()) / (mTargetBounds.height() / 2.f) + .5f; |
396 | |
397 | float scrollWidth = mMaxScrollX - mMinScrollX; |
398 | float scrollHeight = mMaxScrollY - mMinScrollY; |
399 | mPopupScrollX = scrollWidth * percentX * -1f; |
400 | mPopupScrollY = scrollHeight * percentY * -1f; |
401 | // Constrain initial scroll position within allowed bounds. |
402 | mPopupScrollX = constrain(mPopupScrollX, mMinScrollX, mMaxScrollX); |
403 | mPopupScrollY = constrain(mPopupScrollY, mMinScrollY, mMaxScrollY); |
404 | } |
405 | |
406 | /* |
407 | * Tests override it as the PopupZoomer is never attached to the view hierarchy. |
408 | */ |
409 | protected boolean acceptZeroSizeView() { |
410 | return false; |
411 | } |
412 | |
413 | @Override |
414 | protected void onDraw(Canvas canvas) { |
415 | if (!isShowing() || mZoomedBitmap == null) return; |
416 | if (!acceptZeroSizeView() && (getWidth() == 0 || getHeight() == 0)) return; |
417 | |
418 | if (mNeedsToInitDimensions) { |
419 | mNeedsToInitDimensions = false; |
420 | initDimensions(); |
421 | } |
422 | |
423 | canvas.save(); |
424 | // Calculate the elapsed fraction of animation. |
425 | float time = (SystemClock.uptimeMillis() - mAnimationStartTime + mTimeLeft) / |
426 | ((float) ANIMATION_DURATION); |
427 | time = constrain(time, 0, 1); |
428 | if (time >= 1) { |
429 | mAnimating = false; |
430 | if (!isShowing()) { |
431 | hideImmediately(); |
432 | return; |
433 | } |
434 | } else { |
435 | invalidate(); |
436 | } |
437 | |
438 | // Fraction of the animation to actally show. |
439 | float fractionAnimation; |
440 | if (mShowing) { |
441 | fractionAnimation = mShowInterpolator.getInterpolation(time); |
442 | } else { |
443 | fractionAnimation = mHideInterpolator.getInterpolation(time); |
444 | } |
445 | |
446 | // Draw a faded color over the entire view to fade out the original content, increasing |
447 | // the alpha value as fractionAnimation increases. |
448 | // TODO(nileshagrawal): We should use time here instead of fractionAnimation |
449 | // as fractionAnimaton is interpolated and can go over 1. |
450 | canvas.drawARGB((int) (80 * fractionAnimation), 0, 0, 0); |
451 | canvas.save(); |
452 | |
453 | // Since we want the content to appear directly above its counterpart we need to make |
454 | // sure that it starts out at exactly the same size as it appears in the page, |
455 | // i.e. scale grows from 1/mScale to 1. Note that extrusion values are already zoomed |
456 | // with mScale. |
457 | float scale = fractionAnimation * (mScale - 1.0f) / mScale + 1.0f / mScale; |
458 | |
459 | // Since we want the content to appear directly above its counterpart on the |
460 | // page, we need to remove the mShiftX/Y effect at the beginning of the animation. |
461 | // The unshifting decreases with the animation. |
462 | float unshiftX = - mShiftX * (1.0f - fractionAnimation) / mScale; |
463 | float unshiftY = - mShiftY * (1.0f - fractionAnimation) / mScale; |
464 | |
465 | // Compute the rect to show. |
466 | RectF rect = new RectF(); |
467 | rect.left = mTouch.x - mLeftExtrusion * scale + unshiftX; |
468 | rect.top = mTouch.y - mTopExtrusion * scale + unshiftY; |
469 | rect.right = mTouch.x + mRightExtrusion * scale + unshiftX; |
470 | rect.bottom = mTouch.y + mBottomExtrusion * scale + unshiftY; |
471 | canvas.clipRect(rect); |
472 | |
473 | // Since the canvas transform APIs all pre-concat the transformations, this is done in |
474 | // reverse order. The canvas is first scaled up, then shifted the appropriate amount of |
475 | // pixels. |
476 | canvas.scale(scale, scale, rect.left, rect.top); |
477 | canvas.translate(mPopupScrollX, mPopupScrollY); |
478 | canvas.drawBitmap(mZoomedBitmap, rect.left, rect.top, null); |
479 | canvas.restore(); |
480 | Drawable overlayNineTile = getOverlayDrawable(getContext()); |
481 | overlayNineTile.setBounds((int) rect.left - sOverlayPadding.left, |
482 | (int) rect.top - sOverlayPadding.top, |
483 | (int) rect.right + sOverlayPadding.right, |
484 | (int) rect.bottom + sOverlayPadding.bottom); |
485 | // TODO(nileshagrawal): We should use time here instead of fractionAnimation |
486 | // as fractionAnimaton is interpolated and can go over 1. |
487 | int alpha = constrain((int) (fractionAnimation * 255), 0, 255); |
488 | overlayNineTile.setAlpha(alpha); |
489 | overlayNineTile.draw(canvas); |
490 | canvas.restore(); |
491 | } |
492 | |
493 | /** |
494 | * Show the PopupZoomer view with given target bounds. |
495 | */ |
496 | public void show(Rect rect){ |
497 | if (mShowing || mZoomedBitmap == null) return; |
498 | |
499 | setTargetBounds(rect); |
500 | startAnimation(true); |
501 | } |
502 | |
503 | /** |
504 | * Hide the PopupZoomer view. |
505 | * @param animation true if hide with animation. |
506 | */ |
507 | public void hide(boolean animation){ |
508 | if (!mShowing) return; |
509 | |
510 | if (animation) { |
511 | startAnimation(false); |
512 | } else { |
513 | hideImmediately(); |
514 | } |
515 | } |
516 | |
517 | /** |
518 | * Converts the coordinates to a point on the original un-zoomed view. |
519 | */ |
520 | private PointF convertTouchPoint(float x, float y) { |
521 | x -= mShiftX; |
522 | y -= mShiftY; |
523 | x = mTouch.x + (x - mTouch.x - mPopupScrollX) / mScale; |
524 | y = mTouch.y + (y - mTouch.y - mPopupScrollY) / mScale; |
525 | return new PointF(x, y); |
526 | } |
527 | |
528 | /** |
529 | * Returns true if the point is inside the final drawable area for this popup zoomer. |
530 | */ |
531 | private boolean isTouchOutsideArea(float x, float y) { |
532 | return !mClipRect.contains(x, y); |
533 | } |
534 | |
535 | @Override |
536 | public boolean onTouchEvent(MotionEvent event) { |
537 | mGestureDetector.onTouchEvent(event); |
538 | return true; |
539 | } |
540 | |
541 | private static class ReverseInterpolator implements Interpolator { |
542 | private final Interpolator mInterpolator; |
543 | |
544 | public ReverseInterpolator(Interpolator i) { |
545 | mInterpolator = i; |
546 | } |
547 | |
548 | @Override |
549 | public float getInterpolation(float input) { |
550 | input = 1.0f - input; |
551 | if (mInterpolator == null) return input; |
552 | return mInterpolator.getInterpolation(input); |
553 | } |
554 | } |
555 | } |