/* * Copyright (C) 2018 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.launcher3.popup; import static com.android.launcher3.anim.Interpolators.ACCEL_DEACCEL; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.TimeInterpolator; import android.animation.ValueAnimator; import android.content.Context; import android.content.res.Resources; import android.graphics.CornerPathEffect; import android.graphics.Outline; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.drawable.ShapeDrawable; import android.util.AttributeSet; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewOutlineProvider; import android.widget.FrameLayout; import com.android.launcher3.AbstractFloatingView; import com.android.launcher3.InsettableFrameLayout; import com.android.launcher3.Launcher; import com.android.launcher3.LauncherAnimUtils; import com.android.launcher3.R; import com.android.launcher3.Utilities; import com.android.launcher3.anim.RevealOutlineAnimation; import com.android.launcher3.anim.RoundedRectRevealOutlineProvider; import com.android.launcher3.dragndrop.DragLayer; import com.android.launcher3.graphics.TriangleShape; import com.android.launcher3.util.Themes; import com.android.launcher3.views.BaseDragLayer; import java.util.ArrayList; import java.util.Collections; /** * A container for shortcuts to deep links and notifications associated with an app. */ public abstract class ArrowPopup extends AbstractFloatingView { private final Rect mTempRect = new Rect(); protected final LayoutInflater mInflater; private final float mOutlineRadius; protected final Launcher mLauncher; protected final boolean mIsRtl; private final int mArrowOffset; private final View mArrow; protected boolean mIsLeftAligned; protected boolean mIsAboveIcon; private int mGravity; protected Animator mOpenCloseAnimator; protected boolean mDeferContainerRemoval; private final Rect mStartRect = new Rect(); private final Rect mEndRect = new Rect(); public ArrowPopup(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); mInflater = LayoutInflater.from(context); mOutlineRadius = Themes.getDialogCornerRadius(context); mLauncher = Launcher.getLauncher(context); mIsRtl = Utilities.isRtl(getResources()); setClipToOutline(true); setOutlineProvider(new ViewOutlineProvider() { @Override public void getOutline(View view, Outline outline) { outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mOutlineRadius); } }); // Initialize arrow view final Resources resources = getResources(); final int arrowWidth = resources.getDimensionPixelSize(R.dimen.popup_arrow_width); final int arrowHeight = resources.getDimensionPixelSize(R.dimen.popup_arrow_height); mArrow = new View(context); mArrow.setLayoutParams(new DragLayer.LayoutParams(arrowWidth, arrowHeight)); mArrowOffset = resources.getDimensionPixelSize(R.dimen.popup_arrow_vertical_offset); } public ArrowPopup(Context context, AttributeSet attrs) { this(context, attrs, 0); } public ArrowPopup(Context context) { this(context, null, 0); } @Override protected void handleClose(boolean animate) { if (animate) { animateClose(); } else { closeComplete(); } } public T inflateAndAdd(int resId, ViewGroup container) { View view = mInflater.inflate(resId, container, false); container.addView(view); return (T) view; } public T inflateAndAdd(int resId, ViewGroup container, int index) { View view = mInflater.inflate(resId, container, false); container.addView(view, index); return (T) view; } /** * Called when all view inflation and reordering in complete. */ protected void onInflationComplete(boolean isReversed) { } /** * Shows the popup at the desired location, optionally reversing the children. * @param viewsToFlip number of views from the top to to flip in case of reverse order */ protected void reorderAndShow(int viewsToFlip) { setVisibility(View.INVISIBLE); mIsOpen = true; getPopupContainer().addView(this); orientAboutObject(); boolean reverseOrder = mIsAboveIcon; if (reverseOrder) { int count = getChildCount(); ArrayList allViews = new ArrayList<>(count); for (int i = 0; i < count; i++) { if (i == viewsToFlip) { Collections.reverse(allViews); } allViews.add(getChildAt(i)); } Collections.reverse(allViews); removeAllViews(); for (int i = 0; i < count; i++) { addView(allViews.get(i)); } orientAboutObject(); } onInflationComplete(reverseOrder); // Add the arrow. final Resources res = getResources(); final int arrowCenterOffset = res.getDimensionPixelSize(isAlignedWithStart() ? R.dimen.popup_arrow_horizontal_center_start : R.dimen.popup_arrow_horizontal_center_end); final int halfArrowWidth = res.getDimensionPixelSize(R.dimen.popup_arrow_width) / 2; getPopupContainer().addView(mArrow); DragLayer.LayoutParams arrowLp = (DragLayer.LayoutParams) mArrow.getLayoutParams(); if (mIsLeftAligned) { mArrow.setX(getX() + arrowCenterOffset - halfArrowWidth); } else { mArrow.setX(getX() + getMeasuredWidth() - arrowCenterOffset - halfArrowWidth); } if (Gravity.isVertical(mGravity)) { // This is only true if there wasn't room for the container next to the icon, // so we centered it instead. In that case we don't want to showDefaultOptions the arrow. mArrow.setVisibility(INVISIBLE); } else { ShapeDrawable arrowDrawable = new ShapeDrawable(TriangleShape.create( arrowLp.width, arrowLp.height, !mIsAboveIcon)); Paint arrowPaint = arrowDrawable.getPaint(); arrowPaint.setColor(Themes.getAttrColor(getContext(), R.attr.popupColorPrimary)); // The corner path effect won't be reflected in the shadow, but shouldn't be noticeable. int radius = getResources().getDimensionPixelSize(R.dimen.popup_arrow_corner_radius); arrowPaint.setPathEffect(new CornerPathEffect(radius)); mArrow.setBackground(arrowDrawable); // Clip off the part of the arrow that is underneath the popup. if (mIsAboveIcon) { mArrow.setClipBounds(new Rect(0, -mArrowOffset, arrowLp.width, arrowLp.height)); } else { mArrow.setClipBounds(new Rect(0, 0, arrowLp.width, arrowLp.height + mArrowOffset)); } mArrow.setElevation(getElevation()); } mArrow.setPivotX(arrowLp.width / 2); mArrow.setPivotY(mIsAboveIcon ? arrowLp.height : 0); animateOpen(); } protected boolean isAlignedWithStart() { return mIsLeftAligned && !mIsRtl || !mIsLeftAligned && mIsRtl; } /** * Provide the location of the target object relative to the dragLayer. */ protected abstract void getTargetObjectLocation(Rect outPos); /** * Orients this container above or below the given icon, aligning with the left or right. * * These are the preferred orientations, in order (RTL prefers right-aligned over left): * - Above and left-aligned * - Above and right-aligned * - Below and left-aligned * - Below and right-aligned * * So we always align left if there is enough horizontal space * and align above if there is enough vertical space. */ protected void orientAboutObject() { measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); int width = getMeasuredWidth(); int extraVerticalSpace = mArrow.getLayoutParams().height + mArrowOffset + getResources().getDimensionPixelSize(R.dimen.popup_vertical_padding); int height = getMeasuredHeight() + extraVerticalSpace; getTargetObjectLocation(mTempRect); InsettableFrameLayout dragLayer = getPopupContainer(); Rect insets = dragLayer.getInsets(); // Align left (right in RTL) if there is room. int leftAlignedX = mTempRect.left; int rightAlignedX = mTempRect.right - width; int x = leftAlignedX; boolean canBeLeftAligned = leftAlignedX + width + insets.left < dragLayer.getRight() - insets.right; boolean canBeRightAligned = rightAlignedX > dragLayer.getLeft() + insets.left; if (!canBeLeftAligned || (mIsRtl && canBeRightAligned)) { x = rightAlignedX; } mIsLeftAligned = x == leftAlignedX; // Offset x so that the arrow and shortcut icons are center-aligned with the original icon. int iconWidth = mTempRect.width(); Resources resources = getResources(); int xOffset; if (isAlignedWithStart()) { // Aligning with the shortcut icon. int shortcutIconWidth = resources.getDimensionPixelSize(R.dimen.deep_shortcut_icon_size); int shortcutPaddingStart = resources.getDimensionPixelSize( R.dimen.popup_padding_start); xOffset = iconWidth / 2 - shortcutIconWidth / 2 - shortcutPaddingStart; } else { // Aligning with the drag handle. int shortcutDragHandleWidth = resources.getDimensionPixelSize( R.dimen.deep_shortcut_drag_handle_size); int shortcutPaddingEnd = resources.getDimensionPixelSize( R.dimen.popup_padding_end); xOffset = iconWidth / 2 - shortcutDragHandleWidth / 2 - shortcutPaddingEnd; } x += mIsLeftAligned ? xOffset : -xOffset; // Open above icon if there is room. int iconHeight = mTempRect.height(); int y = mTempRect.top - height; mIsAboveIcon = y > dragLayer.getTop() + insets.top; if (!mIsAboveIcon) { y = mTempRect.top + iconHeight + extraVerticalSpace; } // Insets are added later, so subtract them now. x -= insets.left; y -= insets.top; mGravity = 0; if (y + height > dragLayer.getBottom() - insets.bottom) { // The container is opening off the screen, so just center it in the drag layer instead. mGravity = Gravity.CENTER_VERTICAL; // Put the container next to the icon, preferring the right side in ltr (left in rtl). int rightSide = leftAlignedX + iconWidth - insets.left; int leftSide = rightAlignedX - iconWidth - insets.left; if (!mIsRtl) { if (rightSide + width < dragLayer.getRight()) { x = rightSide; mIsLeftAligned = true; } else { x = leftSide; mIsLeftAligned = false; } } else { if (leftSide > dragLayer.getLeft()) { x = leftSide; mIsLeftAligned = false; } else { x = rightSide; mIsLeftAligned = true; } } mIsAboveIcon = true; } setX(x); if (Gravity.isVertical(mGravity)) { return; } FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) getLayoutParams(); FrameLayout.LayoutParams arrowLp = (FrameLayout.LayoutParams) mArrow.getLayoutParams(); if (mIsAboveIcon) { arrowLp.gravity = lp.gravity = Gravity.BOTTOM; lp.bottomMargin = getPopupContainer().getHeight() - y - getMeasuredHeight() - insets.top; arrowLp.bottomMargin = lp.bottomMargin - arrowLp.height - mArrowOffset - insets.bottom; } else { arrowLp.gravity = lp.gravity = Gravity.TOP; lp.topMargin = y + insets.top; arrowLp.topMargin = lp.topMargin - insets.top - arrowLp.height - mArrowOffset; } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { super.onLayout(changed, l, t, r, b); // enforce contained is within screen ViewGroup dragLayer = getPopupContainer(); if (getTranslationX() + l < 0 || getTranslationX() + r > dragLayer.getWidth()) { // If we are still off screen, center horizontally too. mGravity |= Gravity.CENTER_HORIZONTAL; } if (Gravity.isHorizontal(mGravity)) { setX(dragLayer.getWidth() / 2 - getMeasuredWidth() / 2); mArrow.setVisibility(INVISIBLE); } if (Gravity.isVertical(mGravity)) { setY(dragLayer.getHeight() / 2 - getMeasuredHeight() / 2); } } private void animateOpen() { setVisibility(View.VISIBLE); final AnimatorSet openAnim = new AnimatorSet(); final Resources res = getResources(); final long revealDuration = (long) res.getInteger(R.integer.config_popupOpenCloseDuration); final long arrowDuration = res.getInteger(R.integer.config_popupArrowOpenCloseDuration); final TimeInterpolator revealInterpolator = ACCEL_DEACCEL; // Rectangular reveal. mEndRect.set(0, 0, getMeasuredWidth(), getMeasuredHeight()); final ValueAnimator revealAnim = createOpenCloseOutlineProvider() .createRevealAnimator(this, false); revealAnim.setDuration(revealDuration); revealAnim.setInterpolator(revealInterpolator); // Clip the popup to the initial outline while the notification dot and arrow animate. revealAnim.start(); revealAnim.pause(); ValueAnimator fadeIn = ValueAnimator.ofFloat(0, 1); fadeIn.setDuration(revealDuration + arrowDuration); fadeIn.setInterpolator(revealInterpolator); fadeIn.addUpdateListener(anim -> { float alpha = (float) anim.getAnimatedValue(); mArrow.setAlpha(alpha); setAlpha(revealAnim.isStarted() ? alpha : 0); }); openAnim.play(fadeIn); // Animate the arrow. mArrow.setScaleX(0); mArrow.setScaleY(0); Animator arrowScale = ObjectAnimator.ofFloat(mArrow, LauncherAnimUtils.SCALE_PROPERTY, 1) .setDuration(arrowDuration); openAnim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { setAlpha(1f); announceAccessibilityChanges(); mOpenCloseAnimator = null; } }); mOpenCloseAnimator = openAnim; openAnim.playSequentially(arrowScale, revealAnim); openAnim.start(); } protected void animateClose() { if (!mIsOpen) { return; } if (getOutlineProvider() instanceof RevealOutlineAnimation) { ((RevealOutlineAnimation) getOutlineProvider()).getOutline(mEndRect); } if (mOpenCloseAnimator != null) { mOpenCloseAnimator.cancel(); } mIsOpen = false; final AnimatorSet closeAnim = new AnimatorSet(); final Resources res = getResources(); final TimeInterpolator revealInterpolator = ACCEL_DEACCEL; final long revealDuration = res.getInteger(R.integer.config_popupOpenCloseDuration); final long arrowDuration = res.getInteger(R.integer.config_popupArrowOpenCloseDuration); // Hide the arrow Animator scaleArrow = ObjectAnimator.ofFloat(mArrow, LauncherAnimUtils.SCALE_PROPERTY, 0) .setDuration(arrowDuration); // Rectangular reveal (reversed). final ValueAnimator revealAnim = createOpenCloseOutlineProvider() .createRevealAnimator(this, true); revealAnim.setDuration(revealDuration); revealAnim.setInterpolator(revealInterpolator); closeAnim.playSequentially(revealAnim, scaleArrow); ValueAnimator fadeOut = ValueAnimator.ofFloat(getAlpha(), 0); fadeOut.setDuration(revealDuration + arrowDuration); fadeOut.setInterpolator(revealInterpolator); fadeOut.addUpdateListener(anim -> { float alpha = (float) anim.getAnimatedValue(); mArrow.setAlpha(alpha); setAlpha(scaleArrow.isStarted() ? 0 : alpha); }); closeAnim.play(fadeOut); onCreateCloseAnimation(closeAnim); closeAnim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { mOpenCloseAnimator = null; if (mDeferContainerRemoval) { setVisibility(INVISIBLE); } else { closeComplete(); } } }); mOpenCloseAnimator = closeAnim; closeAnim.start(); } /** * Called when creating the close transition allowing subclass can add additional animations. */ protected void onCreateCloseAnimation(AnimatorSet anim) { } private RoundedRectRevealOutlineProvider createOpenCloseOutlineProvider() { Resources res = getResources(); int arrowCenterX = res.getDimensionPixelSize(mIsLeftAligned ^ mIsRtl ? R.dimen.popup_arrow_horizontal_center_start: R.dimen.popup_arrow_horizontal_center_end); int halfArrowWidth = res.getDimensionPixelSize(R.dimen.popup_arrow_width) / 2; float arrowCornerRadius = res.getDimension(R.dimen.popup_arrow_corner_radius); if (!mIsLeftAligned) { arrowCenterX = getMeasuredWidth() - arrowCenterX; } int arrowCenterY = mIsAboveIcon ? getMeasuredHeight() : 0; mStartRect.set(arrowCenterX - halfArrowWidth, arrowCenterY, arrowCenterX + halfArrowWidth, arrowCenterY); return new RoundedRectRevealOutlineProvider (arrowCornerRadius, mOutlineRadius, mStartRect, mEndRect); } /** * Closes the popup without animation. */ protected void closeComplete() { if (mOpenCloseAnimator != null) { mOpenCloseAnimator.cancel(); mOpenCloseAnimator = null; } mIsOpen = false; mDeferContainerRemoval = false; getPopupContainer().removeView(this); getPopupContainer().removeView(mArrow); } protected BaseDragLayer getPopupContainer() { return mLauncher.getDragLayer(); } }