From e075d9ceb053801d006a677e8b1246520bb04de5 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Fri, 5 Feb 2021 14:22:46 -0500 Subject: [PATCH] Add MagnifierView to long screenshot UI. - Add a listener for CropView so MagnifierView can react to its motion events. - Make CropView track deltas over the entire motion sequence instead of per-input (tiny deltas were causing floating precision drift) - Animate the MagnifierView in and out. Lots more to do including smarter view placement, better handling of display at the edges, etc, but this seems like a good point to pause and check in. Bug: 179499370 Test: Invocation of long screenshot, observe magnification when altering crop boundaries. Change-Id: Iae721afe835882f383b5f46f6159c06c4c2ebca4 --- .../SystemUI/res/layout/long_screenshot.xml | 22 ++- packages/SystemUI/res/values/attrs.xml | 8 + packages/SystemUI/res/values/colors.xml | 3 + packages/SystemUI/res/values/dimens.xml | 1 + .../android/systemui/screenshot/CropView.java | 105 +++++++--- .../systemui/screenshot/MagnifierView.java | 184 ++++++++++++++++++ .../screenshot/ScrollCaptureController.java | 6 +- 7 files changed, 296 insertions(+), 33 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/screenshot/MagnifierView.java diff --git a/packages/SystemUI/res/layout/long_screenshot.xml b/packages/SystemUI/res/layout/long_screenshot.xml index 8f3345f9d85cc..e2f3e2a306e38 100644 --- a/packages/SystemUI/res/layout/long_screenshot.xml +++ b/packages/SystemUI/res/layout/long_screenshot.xml @@ -74,7 +74,7 @@ android:id="@+id/preview" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginBottom="24dp" + android:layout_marginBottom="42dp" android:layout_marginHorizontal="48dp" android:adjustViewBounds="true" app:layout_constrainedHeight="true" @@ -91,19 +91,33 @@ android:id="@+id/crop_view" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginBottom="24dp" + android:layout_marginBottom="42dp" app:layout_constrainedHeight="true" app:layout_constrainedWidth="true" app:layout_constraintTop_toBottomOf="@id/guideline" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toBottomOf="parent" - app:handleThickness="3dp" + app:handleThickness="@dimen/screenshot_crop_handle_thickness" app:handleColor="@*android:color/accent_device_default" - app:scrimColor="#9444" + app:scrimColor="@color/screenshot_crop_scrim" tools:background="?android:colorBackground" tools:minHeight="100dp" tools:minWidth="100dp" /> + + diff --git a/packages/SystemUI/res/values/attrs.xml b/packages/SystemUI/res/values/attrs.xml index 6c55fb62e6388..8166e35d5b6a7 100644 --- a/packages/SystemUI/res/values/attrs.xml +++ b/packages/SystemUI/res/values/attrs.xml @@ -178,6 +178,14 @@ + + + + + + + + diff --git a/packages/SystemUI/res/values/colors.xml b/packages/SystemUI/res/values/colors.xml index a7cf3e91dbbae..a39c47553e876 100644 --- a/packages/SystemUI/res/values/colors.xml +++ b/packages/SystemUI/res/values/colors.xml @@ -197,6 +197,9 @@ @color/GM2_grey_500 #40000000 + + #9444 + #F8F9FA #F1F3F4 diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index d92f4ea653904..1f179f4d06215 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -345,6 +345,7 @@ 16dp 14sp 80dp + 3dp diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/CropView.java b/packages/SystemUI/src/com/android/systemui/screenshot/CropView.java index 8e182b415488a..c8afd0b6cfe93 100644 --- a/packages/SystemUI/src/com/android/systemui/screenshot/CropView.java +++ b/packages/SystemUI/src/com/android/systemui/screenshot/CropView.java @@ -35,7 +35,7 @@ import com.android.systemui.R; * cropped out. */ public class CropView extends View { - private enum CropBoundary { + public enum CropBoundary { NONE, TOP, BOTTOM } @@ -48,8 +48,14 @@ public class CropView extends View { private float mTopCrop = 0f; private float mBottomCrop = 1f; + // When the user is dragging a handle, these variables store the distance between the top/bottom + // crop values and + private float mTopDelta = 0f; + private float mBottomDelta = 0f; + private CropBoundary mCurrentDraggingBoundary = CropBoundary.NONE; - private float mLastY; + private float mStartingY; // y coordinate of ACTION_DOWN + private CropInteractionListener mCropInteractionListener; public CropView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); @@ -73,54 +79,84 @@ public class CropView extends View { @Override public void onDraw(Canvas canvas) { super.onDraw(canvas); - drawShade(canvas, 0, mTopCrop); - drawShade(canvas, mBottomCrop, 1f); - drawHandle(canvas, mTopCrop); - drawHandle(canvas, mBottomCrop); + float top = mTopCrop + mTopDelta; + float bottom = mBottomCrop + mBottomDelta; + drawShade(canvas, 0, top); + drawShade(canvas, bottom, 1f); + drawHandle(canvas, top); + drawHandle(canvas, bottom); } @Override public boolean onTouchEvent(MotionEvent event) { int topPx = fractionToPixels(mTopCrop); int bottomPx = fractionToPixels(mBottomCrop); - if (event.getAction() == MotionEvent.ACTION_DOWN) { - mCurrentDraggingBoundary = nearestBoundary(event, topPx, bottomPx); - if (mCurrentDraggingBoundary != CropBoundary.NONE) { - mLastY = event.getY(); - } - return true; - } - if (event.getAction() == MotionEvent.ACTION_MOVE - && mCurrentDraggingBoundary != CropBoundary.NONE) { - float delta = event.getY() - mLastY; - if (mCurrentDraggingBoundary == CropBoundary.TOP) { - mTopCrop = pixelsToFraction((int) MathUtils.constrain(topPx + delta, 0, - bottomPx - 2 * mCropTouchMargin)); - } else { // Bottom - mBottomCrop = pixelsToFraction((int) MathUtils.constrain(bottomPx + delta, - topPx + 2 * mCropTouchMargin, getMeasuredHeight())); - } - mLastY = event.getY(); - invalidate(); - return true; + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mCurrentDraggingBoundary = nearestBoundary(event, topPx, bottomPx); + if (mCurrentDraggingBoundary != CropBoundary.NONE) { + mStartingY = event.getY(); + updateListener(event); + } + return true; + case MotionEvent.ACTION_MOVE: + if (mCurrentDraggingBoundary != CropBoundary.NONE) { + float delta = event.getY() - mStartingY; + if (mCurrentDraggingBoundary == CropBoundary.TOP) { + mTopDelta = pixelsToFraction((int) MathUtils.constrain(delta, -topPx, + bottomPx - 2 * mCropTouchMargin - topPx)); + } else { // Bottom + mBottomDelta = pixelsToFraction((int) MathUtils.constrain(delta, + topPx + 2 * mCropTouchMargin - bottomPx, + getMeasuredHeight() - bottomPx)); + } + updateListener(event); + invalidate(); + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + if (mCurrentDraggingBoundary != CropBoundary.NONE) { + // Commit the delta to the stored crop values. + mTopCrop += mTopDelta; + mBottomCrop += mBottomDelta; + mTopDelta = 0; + mBottomDelta = 0; + updateListener(event); + } } return super.onTouchEvent(event); } /** - * @return value [0,1] representing the position of the top crop boundary. + * @return value [0,1] representing the position of the top crop boundary. Does not reflect + * changes from any in-progress touch input. */ public float getTopBoundary() { return mTopCrop; } /** - * @return value [0,1] representing the position of the bottom crop boundary. + * @return value [0,1] representing the position of the bottom crop boundary. Does not reflect + * changes from any in-progress touch input. */ public float getBottomBoundary() { return mBottomCrop; } + public void setCropInteractionListener(CropInteractionListener listener) { + mCropInteractionListener = listener; + } + + private void updateListener(MotionEvent event) { + if (mCropInteractionListener != null) { + float boundaryPosition = (mCurrentDraggingBoundary == CropBoundary.TOP) + ? mTopCrop + mTopDelta : mBottomCrop + mBottomDelta; + mCropInteractionListener.onCropMotionEvent(event, mCurrentDraggingBoundary, + boundaryPosition, fractionToPixels(boundaryPosition)); + } + } + private void drawShade(Canvas canvas, float fracStart, float fracEnd) { canvas.drawRect(0, fractionToPixels(fracStart), getMeasuredWidth(), fractionToPixels(fracEnd), mShadePaint); @@ -148,4 +184,17 @@ public class CropView extends View { } return CropBoundary.NONE; } + + /** + * Listen for crop motion events and state. + */ + public interface CropInteractionListener { + /** + * Called whenever CropView has a MotionEvent that can impact the position of the crop + * boundaries. + */ + void onCropMotionEvent(MotionEvent event, CropBoundary boundary, float boundaryPosition, + int boundaryPositionPx); + + } } diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/MagnifierView.java b/packages/SystemUI/src/com/android/systemui/screenshot/MagnifierView.java new file mode 100644 index 0000000000000..f88715164bc78 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/MagnifierView.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2021 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 com.android.systemui.screenshot; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.android.systemui.R; + +/** + * MagnifierView shows a full-res cropped circular display of a given ImageTileSet, contents and + * positioning dereived from events from a CropView to which it listens. + * + * Not meant to be a general-purpose magnifier! + */ +public class MagnifierView extends View implements CropView.CropInteractionListener { + private Drawable mDrawable; + + private final Paint mShadePaint; + private final Paint mHandlePaint; + + private Path mOuterCircle; + private Path mInnerCircle; + + private Path mCheckerboard; + private Paint mCheckerboardPaint; + private final float mBorderPx; + private final int mBorderColor; + private float mCheckerboardBoxSize = 40; + + private float mLastCropPosition; + private CropView.CropBoundary mCropBoundary; + + public MagnifierView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public MagnifierView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + TypedArray t = context.getTheme().obtainStyledAttributes( + attrs, R.styleable.MagnifierView, 0, 0); + mShadePaint = new Paint(); + mShadePaint.setColor(t.getColor(R.styleable.MagnifierView_scrimColor, Color.TRANSPARENT)); + mHandlePaint = new Paint(); + mHandlePaint.setColor(t.getColor(R.styleable.MagnifierView_handleColor, Color.BLACK)); + mHandlePaint.setStrokeWidth( + t.getDimensionPixelSize(R.styleable.MagnifierView_handleThickness, 20)); + mBorderPx = t.getDimensionPixelSize(R.styleable.MagnifierView_borderThickness, 0); + mBorderColor = t.getColor(R.styleable.MagnifierView_borderColor, Color.WHITE); + t.recycle(); + mCheckerboardPaint = new Paint(); + mCheckerboardPaint.setColor(Color.GRAY); + } + + public void setImageTileset(ImageTileSet tiles) { + if (tiles != null) { + mDrawable = tiles.getDrawable(); + mDrawable.setBounds(0, 0, tiles.getWidth(), tiles.getHeight()); + } else { + mDrawable = null; + } + invalidate(); + } + + @Override + public void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + int radius = getWidth() / 2; + mOuterCircle = new Path(); + mOuterCircle.addCircle(radius, radius, radius, Path.Direction.CW); + mInnerCircle = new Path(); + mInnerCircle.addCircle(radius, radius, radius - mBorderPx, Path.Direction.CW); + mCheckerboard = generateCheckerboard(); + } + + @Override + public void onDraw(Canvas canvas) { + super.onDraw(canvas); + + // TODO: just draw a circle at the end instead of clipping like this? + canvas.clipPath(mOuterCircle); + canvas.drawColor(mBorderColor); + canvas.clipPath(mInnerCircle); + + // Draw a checkerboard pattern for out of bounds. + canvas.drawPath(mCheckerboard, mCheckerboardPaint); + + if (mDrawable != null) { + canvas.save(); + // Translate such that the center of this view represents the center of the crop + // boundary. + canvas.translate(-mDrawable.getBounds().width() / 2 + getWidth() / 2, + -mDrawable.getBounds().height() * mLastCropPosition + getHeight() / 2); + mDrawable.draw(canvas); + canvas.restore(); + } + + Rect scrimRect = new Rect(0, 0, getWidth(), getHeight() / 2); + if (mCropBoundary == CropView.CropBoundary.BOTTOM) { + scrimRect.offset(0, getHeight() / 2); + } + canvas.drawRect(scrimRect, mShadePaint); + + canvas.drawLine(0, getHeight() / 2, getWidth(), getHeight() / 2, mHandlePaint); + } + + @Override + public void onCropMotionEvent(MotionEvent event, CropView.CropBoundary boundary, + float cropPosition, int cropPositionPx) { + mCropBoundary = boundary; + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mLastCropPosition = cropPosition; + setTranslationY(cropPositionPx - getHeight() / 2); + setPivotX(getWidth() / 2); + setPivotY(getHeight() / 2); + setScaleX(0.2f); + setScaleY(0.2f); + setAlpha(0f); + setTranslationX((getParentWidth() - getWidth()) / 2); + setVisibility(View.VISIBLE); + animate().alpha(1f).translationX(0).scaleX(1f).scaleY(1f).start(); + break; + case MotionEvent.ACTION_MOVE: + mLastCropPosition = cropPosition; + setTranslationY(cropPositionPx - getHeight() / 2); + invalidate(); + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + animate().alpha(0).translationX((getParentWidth() - getWidth()) / 2).scaleX(0.2f) + .scaleY(0.2f).withEndAction(() -> setVisibility(View.INVISIBLE)).start(); + break; + } + } + + private Path generateCheckerboard() { + Path path = new Path(); + int checkerWidth = (int) Math.ceil(getWidth() / mCheckerboardBoxSize); + int checkerHeight = (int) Math.ceil(getHeight() / mCheckerboardBoxSize); + + for (int row = 0; row < checkerHeight; row++) { + // Alternate starting on the first and second column; + int colStart = (row % 2 == 0) ? 0 : 1; + for (int col = colStart; col < checkerWidth; col += 2) { + path.addRect(col * mCheckerboardBoxSize, + row * mCheckerboardBoxSize, + (col + 1) * mCheckerboardBoxSize, + (row + 1) * mCheckerboardBoxSize, + Path.Direction.CW); + } + } + return path; + } + + private int getParentWidth() { + return ((View) getParent()).getWidth(); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java b/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java index 18c379a4650ff..25438a6f57bae 100644 --- a/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java +++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java @@ -81,6 +81,7 @@ public class ScrollCaptureController implements OnComputeInternalInsetsListener private View mEdit; private View mShare; private CropView mCropView; + private MagnifierView mMagnifierView; public ScrollCaptureController(Context context, Connection connection, Executor uiExecutor, Executor bgExecutor, ImageExporter exporter, UiEventLogger uiEventLogger) { @@ -120,13 +121,14 @@ public class ScrollCaptureController implements OnComputeInternalInsetsListener mEdit = findViewById(R.id.edit); mShare = findViewById(R.id.share); mCropView = findViewById(R.id.crop_view); + mMagnifierView = findViewById(R.id.magnifier); + mCropView.setCropInteractionListener(mMagnifierView); mSave.setOnClickListener(this::onClicked); mCancel.setOnClickListener(this::onClicked); mEdit.setOnClickListener(this::onClicked); mShare.setOnClickListener(this::onClicked); - //mPreview.setImageDrawable(mImageTileSet.getDrawable()); mConnection.start(this::startCapture); } @@ -164,6 +166,7 @@ public class ScrollCaptureController implements OnComputeInternalInsetsListener private void doFinish() { mPreview.setImageDrawable(null); + mMagnifierView.setImageTileset(null); mImageTileSet.clear(); mCallback.onFinish(); mWindow.getDecorView().getViewTreeObserver() @@ -273,6 +276,7 @@ public class ScrollCaptureController implements OnComputeInternalInsetsListener session.end(mCallback::onFinish); } else { mPreview.setImageDrawable(mImageTileSet.getDrawable()); + mMagnifierView.setImageTileset(mImageTileSet); } } }