1 /* 2 * Copyright (C) 2022 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.clipboardoverlay; 18 19 import static android.content.res.Configuration.ORIENTATION_PORTRAIT; 20 21 import android.animation.Animator; 22 import android.animation.AnimatorListenerAdapter; 23 import android.animation.AnimatorSet; 24 import android.animation.ObjectAnimator; 25 import android.animation.TimeInterpolator; 26 import android.animation.ValueAnimator; 27 import android.annotation.Nullable; 28 import android.app.RemoteAction; 29 import android.content.Context; 30 import android.content.res.Resources; 31 import android.graphics.Bitmap; 32 import android.graphics.Insets; 33 import android.graphics.Paint; 34 import android.graphics.Rect; 35 import android.graphics.Region; 36 import android.graphics.drawable.Icon; 37 import android.util.AttributeSet; 38 import android.util.DisplayMetrics; 39 import android.util.MathUtils; 40 import android.util.TypedValue; 41 import android.view.DisplayCutout; 42 import android.view.Gravity; 43 import android.view.LayoutInflater; 44 import android.view.View; 45 import android.view.WindowInsets; 46 import android.view.accessibility.AccessibilityManager; 47 import android.view.animation.LinearInterpolator; 48 import android.view.animation.PathInterpolator; 49 import android.widget.FrameLayout; 50 import android.widget.ImageView; 51 import android.widget.LinearLayout; 52 import android.widget.TextView; 53 54 import androidx.core.view.ViewCompat; 55 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; 56 57 import com.android.systemui.R; 58 import com.android.systemui.screenshot.DraggableConstraintLayout; 59 import com.android.systemui.screenshot.FloatingWindowUtil; 60 import com.android.systemui.screenshot.OverlayActionChip; 61 62 import java.util.ArrayList; 63 64 /** 65 * Handles the visual elements and animations for the clipboard overlay. 66 */ 67 public class ClipboardOverlayView extends DraggableConstraintLayout { 68 69 interface ClipboardOverlayCallbacks extends SwipeDismissCallbacks { onDismissButtonTapped()70 void onDismissButtonTapped(); 71 onRemoteCopyButtonTapped()72 void onRemoteCopyButtonTapped(); 73 onShareButtonTapped()74 void onShareButtonTapped(); 75 onPreviewTapped()76 void onPreviewTapped(); 77 onMinimizedViewTapped()78 void onMinimizedViewTapped(); 79 } 80 81 private static final String TAG = "ClipboardView"; 82 83 private static final int SWIPE_PADDING_DP = 12; // extra padding around views to allow swipe 84 private static final int FONT_SEARCH_STEP_PX = 4; 85 86 private final DisplayMetrics mDisplayMetrics; 87 private final AccessibilityManager mAccessibilityManager; 88 private final ArrayList<OverlayActionChip> mActionChips = new ArrayList<>(); 89 90 private View mClipboardPreview; 91 private ImageView mImagePreview; 92 private TextView mTextPreview; 93 private TextView mHiddenPreview; 94 private LinearLayout mMinimizedPreview; 95 private View mPreviewBorder; 96 private OverlayActionChip mShareChip; 97 private OverlayActionChip mRemoteCopyChip; 98 private View mActionContainerBackground; 99 private View mDismissButton; 100 private LinearLayout mActionContainer; 101 ClipboardOverlayView(Context context)102 public ClipboardOverlayView(Context context) { 103 this(context, null); 104 } 105 ClipboardOverlayView(Context context, AttributeSet attrs)106 public ClipboardOverlayView(Context context, AttributeSet attrs) { 107 this(context, attrs, 0); 108 } 109 ClipboardOverlayView(Context context, AttributeSet attrs, int defStyleAttr)110 public ClipboardOverlayView(Context context, AttributeSet attrs, int defStyleAttr) { 111 super(context, attrs, defStyleAttr); 112 mDisplayMetrics = new DisplayMetrics(); 113 mContext.getDisplay().getRealMetrics(mDisplayMetrics); 114 mAccessibilityManager = AccessibilityManager.getInstance(mContext); 115 } 116 117 @Override onFinishInflate()118 protected void onFinishInflate() { 119 mActionContainerBackground = requireViewById(R.id.actions_container_background); 120 mActionContainer = requireViewById(R.id.actions); 121 mClipboardPreview = requireViewById(R.id.clipboard_preview); 122 mPreviewBorder = requireViewById(R.id.preview_border); 123 mImagePreview = requireViewById(R.id.image_preview); 124 mTextPreview = requireViewById(R.id.text_preview); 125 mHiddenPreview = requireViewById(R.id.hidden_preview); 126 mMinimizedPreview = requireViewById(R.id.minimized_preview); 127 mShareChip = requireViewById(R.id.share_chip); 128 mRemoteCopyChip = requireViewById(R.id.remote_copy_chip); 129 mDismissButton = requireViewById(R.id.dismiss_button); 130 131 mShareChip.setAlpha(1); 132 mRemoteCopyChip.setAlpha(1); 133 mShareChip.setContentDescription(mContext.getString(com.android.internal.R.string.share)); 134 135 mRemoteCopyChip.setIcon( 136 Icon.createWithResource(mContext, R.drawable.ic_baseline_devices_24), true); 137 mShareChip.setIcon( 138 Icon.createWithResource(mContext, R.drawable.ic_screenshot_share), true); 139 140 mRemoteCopyChip.setContentDescription( 141 mContext.getString(R.string.clipboard_send_nearby_description)); 142 143 mTextPreview.getViewTreeObserver().addOnPreDrawListener(() -> { 144 int availableHeight = mTextPreview.getHeight() 145 - (mTextPreview.getPaddingTop() + mTextPreview.getPaddingBottom()); 146 mTextPreview.setMaxLines(availableHeight / mTextPreview.getLineHeight()); 147 return true; 148 }); 149 super.onFinishInflate(); 150 } 151 152 @Override setCallbacks(SwipeDismissCallbacks callbacks)153 public void setCallbacks(SwipeDismissCallbacks callbacks) { 154 super.setCallbacks(callbacks); 155 ClipboardOverlayCallbacks clipboardCallbacks = (ClipboardOverlayCallbacks) callbacks; 156 mShareChip.setOnClickListener(v -> clipboardCallbacks.onShareButtonTapped()); 157 mDismissButton.setOnClickListener(v -> clipboardCallbacks.onDismissButtonTapped()); 158 mRemoteCopyChip.setOnClickListener(v -> clipboardCallbacks.onRemoteCopyButtonTapped()); 159 mClipboardPreview.setOnClickListener(v -> clipboardCallbacks.onPreviewTapped()); 160 mMinimizedPreview.setOnClickListener(v -> clipboardCallbacks.onMinimizedViewTapped()); 161 } 162 setEditAccessibilityAction(boolean editable)163 void setEditAccessibilityAction(boolean editable) { 164 if (editable) { 165 ViewCompat.replaceAccessibilityAction(mClipboardPreview, 166 AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK, 167 mContext.getString(R.string.clipboard_edit), null); 168 } else { 169 ViewCompat.replaceAccessibilityAction(mClipboardPreview, 170 AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK, 171 null, null); 172 } 173 } 174 setMinimized(boolean minimized)175 void setMinimized(boolean minimized) { 176 if (minimized) { 177 mMinimizedPreview.setVisibility(View.VISIBLE); 178 mClipboardPreview.setVisibility(View.GONE); 179 mPreviewBorder.setVisibility(View.GONE); 180 mActionContainer.setVisibility(View.GONE); 181 mActionContainerBackground.setVisibility(View.GONE); 182 } else { 183 mMinimizedPreview.setVisibility(View.GONE); 184 mClipboardPreview.setVisibility(View.VISIBLE); 185 mPreviewBorder.setVisibility(View.VISIBLE); 186 mActionContainer.setVisibility(View.VISIBLE); 187 } 188 } 189 setInsets(WindowInsets insets, int orientation)190 void setInsets(WindowInsets insets, int orientation) { 191 FrameLayout.LayoutParams p = (FrameLayout.LayoutParams) getLayoutParams(); 192 if (p == null) { 193 return; 194 } 195 Rect margins = computeMargins(insets, orientation); 196 197 p.setMargins(margins.left, margins.top, margins.right, margins.bottom); 198 setLayoutParams(p); 199 requestLayout(); 200 } 201 isInTouchRegion(int x, int y)202 boolean isInTouchRegion(int x, int y) { 203 Region touchRegion = new Region(); 204 final Rect tmpRect = new Rect(); 205 206 mPreviewBorder.getBoundsOnScreen(tmpRect); 207 tmpRect.inset( 208 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP), 209 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP)); 210 touchRegion.op(tmpRect, Region.Op.UNION); 211 212 mActionContainerBackground.getBoundsOnScreen(tmpRect); 213 tmpRect.inset( 214 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP), 215 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP)); 216 touchRegion.op(tmpRect, Region.Op.UNION); 217 218 mMinimizedPreview.getBoundsOnScreen(tmpRect); 219 tmpRect.inset( 220 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP), 221 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP)); 222 touchRegion.op(tmpRect, Region.Op.UNION); 223 224 mDismissButton.getBoundsOnScreen(tmpRect); 225 touchRegion.op(tmpRect, Region.Op.UNION); 226 227 return touchRegion.contains(x, y); 228 } 229 setRemoteCopyVisibility(boolean visible)230 void setRemoteCopyVisibility(boolean visible) { 231 if (visible) { 232 mRemoteCopyChip.setVisibility(View.VISIBLE); 233 mActionContainerBackground.setVisibility(View.VISIBLE); 234 } else { 235 mRemoteCopyChip.setVisibility(View.GONE); 236 } 237 } 238 showDefaultTextPreview()239 void showDefaultTextPreview() { 240 String copied = mContext.getString(R.string.clipboard_overlay_text_copied); 241 showTextPreview(copied, false); 242 } 243 showTextPreview(CharSequence text, boolean hidden)244 void showTextPreview(CharSequence text, boolean hidden) { 245 TextView textView = hidden ? mHiddenPreview : mTextPreview; 246 showSinglePreview(textView); 247 textView.setText(text.subSequence(0, Math.min(500, text.length()))); 248 updateTextSize(text, textView); 249 textView.addOnLayoutChangeListener( 250 (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { 251 if (right - left != oldRight - oldLeft) { 252 updateTextSize(text, textView); 253 } 254 }); 255 } 256 showImagePreview(@ullable Bitmap thumbnail)257 void showImagePreview(@Nullable Bitmap thumbnail) { 258 if (thumbnail == null) { 259 mHiddenPreview.setText(mContext.getString(R.string.clipboard_text_hidden)); 260 showSinglePreview(mHiddenPreview); 261 } else { 262 mImagePreview.setImageBitmap(thumbnail); 263 showSinglePreview(mImagePreview); 264 } 265 } 266 showShareChip()267 void showShareChip() { 268 mShareChip.setVisibility(View.VISIBLE); 269 mActionContainerBackground.setVisibility(View.VISIBLE); 270 } 271 reset()272 void reset() { 273 setTranslationX(0); 274 setAlpha(0); 275 mActionContainerBackground.setVisibility(View.GONE); 276 mDismissButton.setVisibility(View.GONE); 277 mShareChip.setVisibility(View.GONE); 278 mRemoteCopyChip.setVisibility(View.GONE); 279 setEditAccessibilityAction(false); 280 resetActionChips(); 281 } 282 resetActionChips()283 void resetActionChips() { 284 for (OverlayActionChip chip : mActionChips) { 285 mActionContainer.removeView(chip); 286 } 287 mActionChips.clear(); 288 } 289 getMinimizedFadeoutAnimation()290 Animator getMinimizedFadeoutAnimation() { 291 ObjectAnimator anim = ObjectAnimator.ofFloat(mMinimizedPreview, "alpha", 1, 0); 292 anim.setDuration(66); 293 anim.addListener(new AnimatorListenerAdapter() { 294 @Override 295 public void onAnimationEnd(Animator animation) { 296 super.onAnimationEnd(animation); 297 mMinimizedPreview.setVisibility(View.GONE); 298 mMinimizedPreview.setAlpha(1); 299 } 300 }); 301 return anim; 302 } 303 getEnterAnimation()304 Animator getEnterAnimation() { 305 if (mAccessibilityManager.isEnabled()) { 306 mDismissButton.setVisibility(View.VISIBLE); 307 } 308 TimeInterpolator linearInterpolator = new LinearInterpolator(); 309 TimeInterpolator scaleInterpolator = new PathInterpolator(0, 0, 0, 1f); 310 AnimatorSet enterAnim = new AnimatorSet(); 311 312 ValueAnimator rootAnim = ValueAnimator.ofFloat(0, 1); 313 rootAnim.setInterpolator(linearInterpolator); 314 rootAnim.setDuration(66); 315 rootAnim.addUpdateListener(animation -> { 316 setAlpha(animation.getAnimatedFraction()); 317 }); 318 319 ValueAnimator scaleAnim = ValueAnimator.ofFloat(0, 1); 320 scaleAnim.setInterpolator(scaleInterpolator); 321 scaleAnim.setDuration(333); 322 scaleAnim.addUpdateListener(animation -> { 323 float previewScale = MathUtils.lerp(.9f, 1f, animation.getAnimatedFraction()); 324 mMinimizedPreview.setScaleX(previewScale); 325 mMinimizedPreview.setScaleY(previewScale); 326 mClipboardPreview.setScaleX(previewScale); 327 mClipboardPreview.setScaleY(previewScale); 328 mPreviewBorder.setScaleX(previewScale); 329 mPreviewBorder.setScaleY(previewScale); 330 331 float pivotX = mClipboardPreview.getWidth() / 2f + mClipboardPreview.getX(); 332 mActionContainerBackground.setPivotX(pivotX - mActionContainerBackground.getX()); 333 mActionContainer.setPivotX(pivotX - ((View) mActionContainer.getParent()).getX()); 334 float actionsScaleX = MathUtils.lerp(.7f, 1f, animation.getAnimatedFraction()); 335 float actionsScaleY = MathUtils.lerp(.9f, 1f, animation.getAnimatedFraction()); 336 mActionContainer.setScaleX(actionsScaleX); 337 mActionContainer.setScaleY(actionsScaleY); 338 mActionContainerBackground.setScaleX(actionsScaleX); 339 mActionContainerBackground.setScaleY(actionsScaleY); 340 }); 341 342 ValueAnimator alphaAnim = ValueAnimator.ofFloat(0, 1); 343 alphaAnim.setInterpolator(linearInterpolator); 344 alphaAnim.setDuration(283); 345 alphaAnim.addUpdateListener(animation -> { 346 float alpha = animation.getAnimatedFraction(); 347 mMinimizedPreview.setAlpha(alpha); 348 mClipboardPreview.setAlpha(alpha); 349 mPreviewBorder.setAlpha(alpha); 350 mDismissButton.setAlpha(alpha); 351 mActionContainer.setAlpha(alpha); 352 }); 353 354 mMinimizedPreview.setAlpha(0); 355 mActionContainer.setAlpha(0); 356 mPreviewBorder.setAlpha(0); 357 mClipboardPreview.setAlpha(0); 358 enterAnim.play(rootAnim).with(scaleAnim); 359 enterAnim.play(alphaAnim).after(50).after(rootAnim); 360 361 enterAnim.addListener(new AnimatorListenerAdapter() { 362 @Override 363 public void onAnimationEnd(Animator animation) { 364 super.onAnimationEnd(animation); 365 setAlpha(1); 366 } 367 }); 368 return enterAnim; 369 } 370 getExitAnimation()371 Animator getExitAnimation() { 372 TimeInterpolator linearInterpolator = new LinearInterpolator(); 373 TimeInterpolator scaleInterpolator = new PathInterpolator(.3f, 0, 1f, 1f); 374 AnimatorSet exitAnim = new AnimatorSet(); 375 376 ValueAnimator rootAnim = ValueAnimator.ofFloat(0, 1); 377 rootAnim.setInterpolator(linearInterpolator); 378 rootAnim.setDuration(100); 379 rootAnim.addUpdateListener(anim -> setAlpha(1 - anim.getAnimatedFraction())); 380 381 ValueAnimator scaleAnim = ValueAnimator.ofFloat(0, 1); 382 scaleAnim.setInterpolator(scaleInterpolator); 383 scaleAnim.setDuration(250); 384 scaleAnim.addUpdateListener(animation -> { 385 float previewScale = MathUtils.lerp(1f, .9f, animation.getAnimatedFraction()); 386 mMinimizedPreview.setScaleX(previewScale); 387 mMinimizedPreview.setScaleY(previewScale); 388 mClipboardPreview.setScaleX(previewScale); 389 mClipboardPreview.setScaleY(previewScale); 390 mPreviewBorder.setScaleX(previewScale); 391 mPreviewBorder.setScaleY(previewScale); 392 393 float pivotX = mClipboardPreview.getWidth() / 2f + mClipboardPreview.getX(); 394 mActionContainerBackground.setPivotX(pivotX - mActionContainerBackground.getX()); 395 mActionContainer.setPivotX(pivotX - ((View) mActionContainer.getParent()).getX()); 396 float actionScaleX = MathUtils.lerp(1f, .8f, animation.getAnimatedFraction()); 397 float actionScaleY = MathUtils.lerp(1f, .9f, animation.getAnimatedFraction()); 398 mActionContainer.setScaleX(actionScaleX); 399 mActionContainer.setScaleY(actionScaleY); 400 mActionContainerBackground.setScaleX(actionScaleX); 401 mActionContainerBackground.setScaleY(actionScaleY); 402 }); 403 404 ValueAnimator alphaAnim = ValueAnimator.ofFloat(0, 1); 405 alphaAnim.setInterpolator(linearInterpolator); 406 alphaAnim.setDuration(166); 407 alphaAnim.addUpdateListener(animation -> { 408 float alpha = 1 - animation.getAnimatedFraction(); 409 mMinimizedPreview.setAlpha(alpha); 410 mClipboardPreview.setAlpha(alpha); 411 mPreviewBorder.setAlpha(alpha); 412 mDismissButton.setAlpha(alpha); 413 mActionContainer.setAlpha(alpha); 414 }); 415 416 exitAnim.play(alphaAnim).with(scaleAnim); 417 exitAnim.play(rootAnim).after(150).after(alphaAnim); 418 return exitAnim; 419 } 420 setActionChip(RemoteAction action, Runnable onFinish)421 void setActionChip(RemoteAction action, Runnable onFinish) { 422 mActionContainerBackground.setVisibility(View.VISIBLE); 423 OverlayActionChip chip = constructActionChip(action, onFinish); 424 mActionContainer.addView(chip); 425 mActionChips.add(chip); 426 } 427 showSinglePreview(View v)428 private void showSinglePreview(View v) { 429 mTextPreview.setVisibility(View.GONE); 430 mImagePreview.setVisibility(View.GONE); 431 mHiddenPreview.setVisibility(View.GONE); 432 mMinimizedPreview.setVisibility(View.GONE); 433 v.setVisibility(View.VISIBLE); 434 } 435 constructActionChip(RemoteAction action, Runnable onFinish)436 private OverlayActionChip constructActionChip(RemoteAction action, Runnable onFinish) { 437 OverlayActionChip chip = (OverlayActionChip) LayoutInflater.from(mContext).inflate( 438 R.layout.overlay_action_chip, mActionContainer, false); 439 chip.setText(action.getTitle()); 440 chip.setContentDescription(action.getTitle()); 441 chip.setIcon(action.getIcon(), false); 442 chip.setPendingIntent(action.getActionIntent(), onFinish); 443 chip.setAlpha(1); 444 return chip; 445 } 446 updateTextSize(CharSequence text, TextView textView)447 private static void updateTextSize(CharSequence text, TextView textView) { 448 Paint paint = new Paint(textView.getPaint()); 449 Resources res = textView.getResources(); 450 float minFontSize = res.getDimensionPixelSize(R.dimen.clipboard_overlay_min_font); 451 float maxFontSize = res.getDimensionPixelSize(R.dimen.clipboard_overlay_max_font); 452 if (isOneWord(text) && fitsInView(text, textView, paint, minFontSize)) { 453 // If the text is a single word and would fit within the TextView at the min font size, 454 // find the biggest font size that will fit. 455 float fontSizePx = minFontSize; 456 while (fontSizePx + FONT_SEARCH_STEP_PX < maxFontSize 457 && fitsInView(text, textView, paint, fontSizePx + FONT_SEARCH_STEP_PX)) { 458 fontSizePx += FONT_SEARCH_STEP_PX; 459 } 460 // Need to turn off autosizing, otherwise setTextSize is a no-op. 461 textView.setAutoSizeTextTypeWithDefaults(TextView.AUTO_SIZE_TEXT_TYPE_NONE); 462 // It's possible to hit the max font size and not fill the width, so centering 463 // horizontally looks better in this case. 464 textView.setGravity(Gravity.CENTER); 465 textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, (int) fontSizePx); 466 } else { 467 // Otherwise just stick with autosize. 468 textView.setAutoSizeTextTypeUniformWithConfiguration((int) minFontSize, 469 (int) maxFontSize, FONT_SEARCH_STEP_PX, TypedValue.COMPLEX_UNIT_PX); 470 textView.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); 471 } 472 } 473 fitsInView(CharSequence text, TextView textView, Paint paint, float fontSizePx)474 private static boolean fitsInView(CharSequence text, TextView textView, Paint paint, 475 float fontSizePx) { 476 paint.setTextSize(fontSizePx); 477 float size = paint.measureText(text.toString()); 478 float availableWidth = textView.getWidth() - textView.getPaddingLeft() 479 - textView.getPaddingRight(); 480 return size < availableWidth; 481 } 482 isOneWord(CharSequence text)483 private static boolean isOneWord(CharSequence text) { 484 return text.toString().split("\\s+", 2).length == 1; 485 } 486 computeMargins(WindowInsets insets, int orientation)487 private static Rect computeMargins(WindowInsets insets, int orientation) { 488 DisplayCutout cutout = insets.getDisplayCutout(); 489 Insets navBarInsets = insets.getInsets(WindowInsets.Type.navigationBars()); 490 Insets imeInsets = insets.getInsets(WindowInsets.Type.ime()); 491 if (cutout == null) { 492 return new Rect(0, 0, 0, Math.max(imeInsets.bottom, navBarInsets.bottom)); 493 } else { 494 Insets waterfall = cutout.getWaterfallInsets(); 495 if (orientation == ORIENTATION_PORTRAIT) { 496 return new Rect( 497 waterfall.left, 498 Math.max(cutout.getSafeInsetTop(), waterfall.top), 499 waterfall.right, 500 Math.max(imeInsets.bottom, 501 Math.max(cutout.getSafeInsetBottom(), 502 Math.max(navBarInsets.bottom, waterfall.bottom)))); 503 } else { 504 return new Rect( 505 waterfall.left, 506 waterfall.top, 507 waterfall.right, 508 Math.max(imeInsets.bottom, 509 Math.max(navBarInsets.bottom, waterfall.bottom))); 510 } 511 } 512 } 513 } 514