1 /*
2  * Copyright (C) 2024 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
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.screenshot.scroll;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorListenerAdapter;
21 import android.annotation.NonNull;
22 import android.content.Context;
23 import android.content.res.TypedArray;
24 import android.graphics.Canvas;
25 import android.graphics.Color;
26 import android.graphics.Paint;
27 import android.graphics.Path;
28 import android.graphics.Rect;
29 import android.graphics.drawable.Drawable;
30 import android.util.AttributeSet;
31 import android.view.View;
32 import android.view.ViewPropertyAnimator;
33 
34 import androidx.annotation.Nullable;
35 
36 import com.android.internal.graphics.ColorUtils;
37 import com.android.systemui.res.R;
38 
39 /**
40  * MagnifierView shows a full-res cropped circular display of a given ImageTileSet, contents and
41  * positioning derived from events from a CropView to which it listens.
42  *
43  * Not meant to be a general-purpose magnifier!
44  */
45 public class MagnifierView extends View implements CropView.CropInteractionListener {
46     private Drawable mDrawable;
47 
48     private final Paint mShadePaint;
49     private final Paint mHandlePaint;
50 
51     private Path mOuterCircle;
52     private Path mInnerCircle;
53 
54     private Path mCheckerboard;
55     private Paint mCheckerboardPaint;
56     private final float mBorderPx;
57     private final int mBorderColor;
58     private float mCheckerboardBoxSize = 40;
59 
60     private float mLastCropPosition;
61     private float mLastCenter = 0.5f;
62     private CropView.CropBoundary mCropBoundary;
63 
64     private ViewPropertyAnimator mTranslationAnimator;
65     private final Animator.AnimatorListener mTranslationAnimatorListener =
66             new AnimatorListenerAdapter() {
67                 @Override
68                 public void onAnimationCancel(Animator animation) {
69                     mTranslationAnimator = null;
70                 }
71 
72                 @Override
73                 public void onAnimationEnd(Animator animation) {
74                     mTranslationAnimator = null;
75                 }
76             };
77 
MagnifierView(Context context, @Nullable AttributeSet attrs)78     public MagnifierView(Context context, @Nullable AttributeSet attrs) {
79         this(context, attrs, 0);
80     }
81 
MagnifierView(Context context, @Nullable AttributeSet attrs, int defStyleAttr)82     public MagnifierView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
83         super(context, attrs, defStyleAttr);
84         TypedArray t = context.getTheme().obtainStyledAttributes(
85                 attrs, R.styleable.MagnifierView, 0, 0);
86         mShadePaint = new Paint();
87         int alpha = t.getInteger(R.styleable.MagnifierView_scrimAlpha, 255);
88         int scrimColor = t.getColor(R.styleable.MagnifierView_scrimColor, Color.TRANSPARENT);
89         mShadePaint.setColor(ColorUtils.setAlphaComponent(scrimColor, alpha));
90         mHandlePaint = new Paint();
91         mHandlePaint.setColor(t.getColor(R.styleable.MagnifierView_handleColor, Color.BLACK));
92         mHandlePaint.setStrokeWidth(
93                 t.getDimensionPixelSize(R.styleable.MagnifierView_handleThickness, 20));
94         mBorderPx = t.getDimensionPixelSize(R.styleable.MagnifierView_borderThickness, 0);
95         mBorderColor = t.getColor(R.styleable.MagnifierView_borderColor, Color.WHITE);
96         t.recycle();
97         mCheckerboardPaint = new Paint();
98         mCheckerboardPaint.setColor(Color.GRAY);
99     }
100 
101     /**
102      * Set the drawable to be displayed by the magnifier.
103      */
setDrawable(@onNull Drawable drawable, int width, int height)104     public void setDrawable(@NonNull Drawable drawable, int width, int height) {
105         mDrawable = drawable;
106         mDrawable.setBounds(0, 0, width, height);
107         invalidate();
108     }
109 
110     @Override
onLayout(boolean changed, int left, int top, int right, int bottom)111     public void onLayout(boolean changed, int left, int top, int right, int bottom) {
112         super.onLayout(changed, left, top, right, bottom);
113         int radius = getWidth() / 2;
114         mOuterCircle = new Path();
115         mOuterCircle.addCircle(radius, radius, radius, Path.Direction.CW);
116         mInnerCircle = new Path();
117         mInnerCircle.addCircle(radius, radius, radius - mBorderPx, Path.Direction.CW);
118         mCheckerboard = generateCheckerboard();
119     }
120 
121     @Override
onDraw(Canvas canvas)122     public void onDraw(Canvas canvas) {
123         super.onDraw(canvas);
124 
125         // TODO: just draw a circle at the end instead of clipping like this?
126         canvas.clipPath(mOuterCircle);
127         canvas.drawColor(mBorderColor);
128         canvas.clipPath(mInnerCircle);
129 
130         // Draw a checkerboard pattern for out of bounds.
131         canvas.drawPath(mCheckerboard, mCheckerboardPaint);
132 
133         if (mDrawable != null) {
134             canvas.save();
135             // Translate such that the center of this view represents the center of the crop
136             // boundary.
137             canvas.translate(-mDrawable.getBounds().width() * mLastCenter + getWidth() / 2,
138                     -mDrawable.getBounds().height() * mLastCropPosition + getHeight() / 2);
139             mDrawable.draw(canvas);
140             canvas.restore();
141         }
142 
143         Rect scrimRect = new Rect(0, 0, getWidth(), getHeight() / 2);
144         if (mCropBoundary == CropView.CropBoundary.BOTTOM) {
145             scrimRect.offset(0, getHeight() / 2);
146         }
147         canvas.drawRect(scrimRect, mShadePaint);
148 
149         canvas.drawLine(0, getHeight() / 2, getWidth(), getHeight() / 2, mHandlePaint);
150     }
151 
152     @Override
onCropDragStarted(CropView.CropBoundary boundary, float boundaryPosition, int boundaryPositionPx, float horizontalCenter, float x)153     public void onCropDragStarted(CropView.CropBoundary boundary, float boundaryPosition,
154             int boundaryPositionPx, float horizontalCenter, float x) {
155         mCropBoundary = boundary;
156         mLastCenter = horizontalCenter;
157         boolean touchOnRight = x > getParentWidth() / 2;
158         float translateXTarget = touchOnRight ? 0 : getParentWidth() - getWidth();
159         mLastCropPosition = boundaryPosition;
160         setTranslationY(boundaryPositionPx - getHeight() / 2);
161         setPivotX(getWidth() / 2);
162         setPivotY(getHeight() / 2);
163         setScaleX(0.2f);
164         setScaleY(0.2f);
165         setAlpha(0f);
166         setTranslationX((getParentWidth() - getWidth()) / 2);
167         setVisibility(View.VISIBLE);
168         mTranslationAnimator =
169                 animate().alpha(1f).translationX(translateXTarget).scaleX(1f).scaleY(1f);
170         mTranslationAnimator.setListener(mTranslationAnimatorListener);
171         mTranslationAnimator.start();
172     }
173 
174     @Override
onCropDragMoved(CropView.CropBoundary boundary, float boundaryPosition, int boundaryPositionPx, float horizontalCenter, float x)175     public void onCropDragMoved(CropView.CropBoundary boundary, float boundaryPosition,
176             int boundaryPositionPx, float horizontalCenter, float x) {
177         boolean touchOnRight = x > getParentWidth() / 2;
178         float translateXTarget = touchOnRight ? 0 : getParentWidth() - getWidth();
179         // The touch is near the middle if it's within 10% of the center point.
180         // We don't want to animate horizontally if the touch is near the middle.
181         boolean nearMiddle = Math.abs(x - getParentWidth() / 2)
182                 < getParentWidth() / 10f;
183         boolean viewOnLeft = getTranslationX() < (getParentWidth() - getWidth()) / 2;
184         if (!nearMiddle && viewOnLeft != touchOnRight && mTranslationAnimator == null) {
185             mTranslationAnimator = animate().translationX(translateXTarget);
186             mTranslationAnimator.setListener(mTranslationAnimatorListener);
187             mTranslationAnimator.start();
188         }
189         mLastCropPosition = boundaryPosition;
190         setTranslationY(boundaryPositionPx - getHeight() / 2);
191         invalidate();
192     }
193 
194     @Override
195     public void onCropDragComplete() {
196         animate().alpha(0).translationX((getParentWidth() - getWidth()) / 2).scaleX(0.2f)
197                 .scaleY(0.2f).withEndAction(() -> setVisibility(View.INVISIBLE)).start();
198     }
199 
200     private Path generateCheckerboard() {
201         Path path = new Path();
202         int checkerWidth = (int) Math.ceil(getWidth() / mCheckerboardBoxSize);
203         int checkerHeight = (int) Math.ceil(getHeight() / mCheckerboardBoxSize);
204 
205         for (int row = 0; row < checkerHeight; row++) {
206             // Alternate starting on the first and second column;
207             int colStart = (row % 2 == 0) ? 0 : 1;
208             for (int col = colStart; col < checkerWidth; col += 2) {
209                 path.addRect(col * mCheckerboardBoxSize,
210                         row * mCheckerboardBoxSize,
211                         (col + 1) * mCheckerboardBoxSize,
212                         (row + 1) * mCheckerboardBoxSize,
213                         Path.Direction.CW);
214             }
215         }
216         return path;
217     }
218 
219     private int getParentWidth() {
220         return ((View) getParent()).getWidth();
221     }
222 }
223