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