blob: 88365617cd6efea5a20dc69611d8a59984200c5b [file] [log] [blame]
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +01001/*
2 * Copyright (C) 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
Andrei Stingaceanud6644dc2017-11-21 14:53:38 +000014 * limitations under the License.
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010015 */
16
Andrei Stingaceanud6644dc2017-11-21 14:53:38 +000017package android.widget;
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010018
19import android.annotation.FloatRange;
20import android.annotation.NonNull;
Mihai Popa137b5842018-01-30 15:03:22 +000021import android.annotation.TestApi;
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010022import android.annotation.UiThread;
23import android.content.Context;
Mihai Popa137b5842018-01-30 15:03:22 +000024import android.content.res.Resources;
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010025import android.graphics.Bitmap;
26import android.graphics.Point;
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +000027import android.graphics.PointF;
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010028import android.graphics.Rect;
29import android.os.Handler;
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010030import android.view.Gravity;
31import android.view.LayoutInflater;
32import android.view.PixelCopy;
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +000033import android.view.Surface;
Mihai Popa3589c2c2018-01-25 19:26:30 +000034import android.view.SurfaceHolder;
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +000035import android.view.SurfaceView;
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010036import android.view.View;
Mihai Popa1d1ed0c2018-01-12 12:38:12 +000037import android.view.ViewParent;
Mihai Popa3589c2c2018-01-25 19:26:30 +000038import android.view.ViewRootImpl;
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010039
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010040import com.android.internal.util.Preconditions;
41
42/**
Andrei Stingaceanud6644dc2017-11-21 14:53:38 +000043 * Android magnifier widget. Can be used by any view which is attached to a window.
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010044 */
Andrei Stingaceanud6644dc2017-11-21 14:53:38 +000045@UiThread
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010046public final class Magnifier {
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +000047 // Use this to specify that a previous configuration value does not exist.
48 private static final int NONEXISTENT_PREVIOUS_CONFIG_VALUE = -1;
49 // The view to which this magnifier is attached.
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010050 private final View mView;
Mihai Popa1d1ed0c2018-01-12 12:38:12 +000051 // The coordinates of the view in the surface.
52 private final int[] mViewCoordinatesInSurface;
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010053 // The window containing the magnifier.
54 private final PopupWindow mWindow;
55 // The center coordinates of the window containing the magnifier.
56 private final Point mWindowCoords = new Point();
57 // The width of the window containing the magnifier.
58 private final int mWindowWidth;
59 // The height of the window containing the magnifier.
60 private final int mWindowHeight;
61 // The bitmap used to display the contents of the magnifier.
62 private final Bitmap mBitmap;
63 // The center coordinates of the content that is to be magnified.
64 private final Point mCenterZoomCoords = new Point();
65 // The callback of the pixel copy request will be invoked on this Handler when
66 // the copy is finished.
67 private final Handler mPixelCopyHandler = Handler.getMain();
Andrei Stingaceanuca189fe2017-10-19 17:02:22 +010068 // Current magnification scale.
Andrei Stingaceanud27c36b2017-10-24 11:17:35 +010069 private final float mZoomScale;
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +000070 // Variables holding previous states, used for detecting redundant calls and invalidation.
71 private final Point mPrevStartCoordsInSurface = new Point(
72 NONEXISTENT_PREVIOUS_CONFIG_VALUE, NONEXISTENT_PREVIOUS_CONFIG_VALUE);
73 private final PointF mPrevPosInView = new PointF(
74 NONEXISTENT_PREVIOUS_CONFIG_VALUE, NONEXISTENT_PREVIOUS_CONFIG_VALUE);
75 private final Rect mPixelCopyRequestRect = new Rect();
Andrei Stingaceanu15af5612017-10-13 12:53:23 +010076
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010077 /**
78 * Initializes a magnifier.
79 *
80 * @param view the view for which this magnifier is attached
81 */
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010082 public Magnifier(@NonNull View view) {
83 mView = Preconditions.checkNotNull(view);
84 final Context context = mView.getContext();
Andrei Stingaceanud6644dc2017-11-21 14:53:38 +000085 final float elevation = context.getResources().getDimension(
86 com.android.internal.R.dimen.magnifier_elevation);
87 final View content = LayoutInflater.from(context).inflate(
88 com.android.internal.R.layout.magnifier, null);
89 content.findViewById(com.android.internal.R.id.magnifier_inner).setClipToOutline(true);
90 mWindowWidth = context.getResources().getDimensionPixelSize(
91 com.android.internal.R.dimen.magnifier_width);
92 mWindowHeight = context.getResources().getDimensionPixelSize(
93 com.android.internal.R.dimen.magnifier_height);
94 mZoomScale = context.getResources().getFloat(
95 com.android.internal.R.dimen.magnifier_zoom_scale);
Mihai Popa1d1ed0c2018-01-12 12:38:12 +000096 // The view's surface coordinates will not be updated until the magnifier is first shown.
97 mViewCoordinatesInSurface = new int[2];
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +010098
99 mWindow = new PopupWindow(context);
100 mWindow.setContentView(content);
101 mWindow.setWidth(mWindowWidth);
102 mWindow.setHeight(mWindowHeight);
103 mWindow.setElevation(elevation);
104 mWindow.setTouchable(false);
105 mWindow.setBackgroundDrawable(null);
106
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000107 final int bitmapWidth = Math.round(mWindowWidth / mZoomScale);
108 final int bitmapHeight = Math.round(mWindowHeight / mZoomScale);
Andrei Stingaceanud27c36b2017-10-24 11:17:35 +0100109 mBitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888);
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +0100110 getImageView().setImageBitmap(mBitmap);
111 }
112
113 /**
114 * Shows the magnifier on the screen.
115 *
Andrei Stingaceanuca189fe2017-10-19 17:02:22 +0100116 * @param xPosInView horizontal coordinate of the center point of the magnifier source relative
Andrei Stingaceanud6644dc2017-11-21 14:53:38 +0000117 * to the view. The lower end is clamped to 0 and the higher end is clamped to the view
118 * width.
Andrei Stingaceanuca189fe2017-10-19 17:02:22 +0100119 * @param yPosInView vertical coordinate of the center point of the magnifier source
Andrei Stingaceanud6644dc2017-11-21 14:53:38 +0000120 * relative to the view. The lower end is clamped to 0 and the higher end is clamped to
121 * the view height.
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +0100122 */
Andrei Stingaceanud6644dc2017-11-21 14:53:38 +0000123 public void show(@FloatRange(from = 0) float xPosInView,
124 @FloatRange(from = 0) float yPosInView) {
125 xPosInView = Math.max(0, Math.min(xPosInView, mView.getWidth()));
126 yPosInView = Math.max(0, Math.min(yPosInView, mView.getHeight()));
Andrei Stingaceanu451f9472017-10-13 16:41:28 +0100127
Andrei Stingaceanuca189fe2017-10-19 17:02:22 +0100128 configureCoordinates(xPosInView, yPosInView);
129
Mihai Popa3589c2c2018-01-25 19:26:30 +0000130 // Clamp the startX value to avoid magnifying content which does not belong to the magnified
131 // view. This will not take into account overlapping views.
Mihai Popa1d1ed0c2018-01-12 12:38:12 +0000132 // For this, we compute:
133 // - zeroScrollXInSurface: this is the start x of mView, where this is not masked by a
134 // potential scrolling container. For example, if mView is a
135 // TextView contained in a HorizontalScrollView,
136 // mViewCoordinatesInSurface will reflect the surface position of
137 // the first text character, rather than the position of the first
138 // visible one. Therefore, we need to add back the amount of
139 // scrolling from the parent containers.
140 // - actualWidth: similarly, the width of a View will be larger than its actually visible
141 // width when it is contained in a scrolling container. We need to use
142 // the minimum width of a scrolling container which contains this view.
143 int zeroScrollXInSurface = mViewCoordinatesInSurface[0];
144 int actualWidth = mView.getWidth();
145 ViewParent viewParent = mView.getParent();
146 while (viewParent instanceof View) {
147 final View container = (View) viewParent;
148 if (container.canScrollHorizontally(-1 /* left scroll */)
149 || container.canScrollHorizontally(1 /* right scroll */)) {
150 zeroScrollXInSurface += container.getScrollX();
151 actualWidth = Math.min(actualWidth, container.getWidth()
152 - container.getPaddingLeft() - container.getPaddingRight());
153 }
154 viewParent = viewParent.getParent();
155 }
156
157 final int startX = Math.max(zeroScrollXInSurface, Math.min(
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000158 mCenterZoomCoords.x - mBitmap.getWidth() / 2,
Mihai Popa1d1ed0c2018-01-12 12:38:12 +0000159 zeroScrollXInSurface + actualWidth - mBitmap.getWidth()));
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000160 final int startY = mCenterZoomCoords.y - mBitmap.getHeight() / 2;
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +0100161
Shimi Zhang29d1e9e2017-12-28 17:33:37 -0800162 if (xPosInView != mPrevPosInView.x || yPosInView != mPrevPosInView.y) {
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000163 performPixelCopy(startX, startY);
164
165 mPrevPosInView.x = xPosInView;
166 mPrevPosInView.y = yPosInView;
167
168 if (mWindow.isShowing()) {
169 mWindow.update(mWindowCoords.x, mWindowCoords.y, mWindow.getWidth(),
170 mWindow.getHeight());
171 } else {
172 mWindow.showAtLocation(mView, Gravity.NO_GRAVITY, mWindowCoords.x, mWindowCoords.y);
173 }
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +0100174 }
175 }
176
177 /**
Andrei Stingaceanud6644dc2017-11-21 14:53:38 +0000178 * Dismisses the magnifier from the screen. Calling this on a dismissed magnifier is a no-op.
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +0100179 */
180 public void dismiss() {
181 mWindow.dismiss();
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000182 }
Andrei Stingaceanu15af5612017-10-13 12:53:23 +0100183
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000184 /**
185 * Forces the magnifier to update its content. It uses the previous coordinates passed to
186 * {@link #show(float, float)}. This only happens if the magnifier is currently showing.
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000187 */
188 public void update() {
189 if (mWindow.isShowing()) {
190 // Update the contents shown in the magnifier.
191 performPixelCopy(mPrevStartCoordsInSurface.x, mPrevStartCoordsInSurface.y);
Andrei Stingaceanu15af5612017-10-13 12:53:23 +0100192 }
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +0100193 }
194
Mihai Popa4b6ef992018-01-24 16:40:47 +0000195 private void configureCoordinates(final float xPosInView, final float yPosInView) {
196 // Compute the coordinates of the center of the content going to be displayed in the
197 // magnifier. These are relative to the surface the content is copied from.
198 final float contentPosX;
199 final float contentPosY;
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000200 if (mView instanceof SurfaceView) {
201 // No offset required if the backing Surface matches the size of the SurfaceView.
Mihai Popa4b6ef992018-01-24 16:40:47 +0000202 contentPosX = xPosInView;
203 contentPosY = yPosInView;
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000204 } else {
Mihai Popa1d1ed0c2018-01-12 12:38:12 +0000205 mView.getLocationInSurface(mViewCoordinatesInSurface);
Mihai Popa4b6ef992018-01-24 16:40:47 +0000206 contentPosX = xPosInView + mViewCoordinatesInSurface[0];
207 contentPosY = yPosInView + mViewCoordinatesInSurface[1];
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000208 }
Mihai Popa4b6ef992018-01-24 16:40:47 +0000209 mCenterZoomCoords.x = Math.round(contentPosX);
210 mCenterZoomCoords.y = Math.round(contentPosY);
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000211
Mihai Popa4b6ef992018-01-24 16:40:47 +0000212 // Compute the position of the magnifier window. These have to be relative to the window
213 // of the view the magnifier is attached to, as the magnifier popup is a panel window
214 // attached to that window.
215 final int[] viewCoordinatesInWindow = new int[2];
216 mView.getLocationInWindow(viewCoordinatesInWindow);
217 final int verticalOffset = mView.getContext().getResources().getDimensionPixelSize(
Andrei Stingaceanud6644dc2017-11-21 14:53:38 +0000218 com.android.internal.R.dimen.magnifier_offset);
Mihai Popa4b6ef992018-01-24 16:40:47 +0000219 final float magnifierPosX = xPosInView + viewCoordinatesInWindow[0];
220 final float magnifierPosY = yPosInView + viewCoordinatesInWindow[1] - verticalOffset;
221 mWindowCoords.x = Math.round(magnifierPosX - mWindowWidth / 2);
222 mWindowCoords.y = Math.round(magnifierPosY - mWindowHeight / 2);
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +0100223 }
224
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000225 private void performPixelCopy(final int startXInSurface, final int startYInSurface) {
Mihai Popa3589c2c2018-01-25 19:26:30 +0000226 // Get the view surface where the content will be copied from.
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000227 final Surface surface;
Mihai Popa3589c2c2018-01-25 19:26:30 +0000228 final int surfaceWidth;
229 final int surfaceHeight;
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000230 if (mView instanceof SurfaceView) {
Mihai Popa3589c2c2018-01-25 19:26:30 +0000231 final SurfaceHolder surfaceHolder = ((SurfaceView) mView).getHolder();
232 surface = surfaceHolder.getSurface();
233 surfaceWidth = surfaceHolder.getSurfaceFrame().right;
234 surfaceHeight = surfaceHolder.getSurfaceFrame().bottom;
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000235 } else if (mView.getViewRootImpl() != null) {
Mihai Popa3589c2c2018-01-25 19:26:30 +0000236 final ViewRootImpl viewRootImpl = mView.getViewRootImpl();
237 surface = viewRootImpl.mSurface;
238 surfaceWidth = viewRootImpl.getWidth();
239 surfaceHeight = viewRootImpl.getHeight();
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000240 } else {
241 surface = null;
Mihai Popa3589c2c2018-01-25 19:26:30 +0000242 surfaceWidth = NONEXISTENT_PREVIOUS_CONFIG_VALUE;
243 surfaceHeight = NONEXISTENT_PREVIOUS_CONFIG_VALUE;
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000244 }
245
Mihai Popa3589c2c2018-01-25 19:26:30 +0000246 if (surface == null || !surface.isValid()) {
247 return;
248 }
249
250 // Clamp copy coordinates inside the surface to avoid displaying distorted content.
251 final int clampedStartXInSurface = Math.max(0,
252 Math.min(startXInSurface, surfaceWidth - mWindowWidth));
253 final int clampedStartYInSurface = Math.max(0,
254 Math.min(startYInSurface, surfaceHeight - mWindowHeight));
255
256 // Perform the pixel copy.
257 mPixelCopyRequestRect.set(clampedStartXInSurface,
258 clampedStartYInSurface,
259 clampedStartXInSurface + mBitmap.getWidth(),
260 clampedStartYInSurface + mBitmap.getHeight());
261 PixelCopy.request(surface, mPixelCopyRequestRect, mBitmap,
262 result -> {
263 getImageView().invalidate();
264 mPrevStartCoordsInSurface.x = startXInSurface;
265 mPrevStartCoordsInSurface.y = startYInSurface;
266 },
267 mPixelCopyHandler);
Andrei Stingaceanu41589fa2017-11-02 13:54:10 +0000268 }
269
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +0100270 private ImageView getImageView() {
Andrei Stingaceanud6644dc2017-11-21 14:53:38 +0000271 return mWindow.getContentView().findViewById(
272 com.android.internal.R.id.magnifier_image);
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +0100273 }
Mihai Popa137b5842018-01-30 15:03:22 +0000274
275 /**
276 * @return the content being currently displayed in the magnifier, as bitmap
277 *
278 * @hide
279 */
280 @TestApi
281 public Bitmap getContent() {
282 return mBitmap;
283 }
284
285 /**
286 * @return the position of the magnifier window relative to the screen
287 *
288 * @hide
289 */
290 @TestApi
291 public Rect getWindowPositionOnScreen() {
292 final int[] viewLocationOnScreen = new int[2];
293 mView.getLocationOnScreen(viewLocationOnScreen);
294 final int[] viewLocationInSurface = new int[2];
295 mView.getLocationInSurface(viewLocationInSurface);
296
297 final int left = mWindowCoords.x + viewLocationOnScreen[0] - viewLocationInSurface[0];
298 final int top = mWindowCoords.y + viewLocationOnScreen[1] - viewLocationInSurface[1];
299 return new Rect(left, top, left + mWindow.getWidth(), top + mWindow.getHeight());
300 }
301
302 /**
303 * @return the size of the magnifier window in dp
304 *
305 * @hide
306 */
307 @TestApi
308 public static PointF getMagnifierDefaultSize() {
309 final Resources resources = Resources.getSystem();
310 final float density = resources.getDisplayMetrics().density;
311 final PointF size = new PointF();
312 size.x = resources.getDimension(com.android.internal.R.dimen.magnifier_width) / density;
313 size.y = resources.getDimension(com.android.internal.R.dimen.magnifier_height) / density;
314 return size;
315 }
Andrei Stingaceanud2eadfa2017-09-22 15:32:13 +0100316}