NotificationMenuRow.java revision aa94a61c56b8f493fe77fe52b09d0439e7889bc7
1/*
2 * Copyright (C) 2016 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
17package com.android.systemui.statusbar;
18
19import static com.android.systemui.SwipeHelper.SWIPED_FAR_ENOUGH_SIZE_FRACTION;
20
21import java.util.ArrayList;
22
23import com.android.systemui.Interpolators;
24import com.android.systemui.R;
25import com.android.systemui.plugins.statusbar.NotificationMenuRowPlugin;
26import com.android.systemui.plugins.statusbar.NotificationSwipeActionHelper;
27import com.android.systemui.statusbar.NotificationGuts.GutsContent;
28import com.android.systemui.statusbar.stack.NotificationStackScrollLayout;
29
30import android.animation.Animator;
31import android.animation.AnimatorListenerAdapter;
32import android.animation.ValueAnimator;
33import android.app.Notification;
34import android.content.Context;
35import android.content.res.Resources;
36import android.graphics.drawable.Drawable;
37import android.os.Handler;
38import android.util.Log;
39import android.service.notification.StatusBarNotification;
40import android.view.LayoutInflater;
41import android.view.MotionEvent;
42import android.view.View;
43import android.view.ViewGroup;
44import android.widget.FrameLayout;
45import android.widget.FrameLayout.LayoutParams;
46
47public class NotificationMenuRow implements NotificationMenuRowPlugin, View.OnClickListener {
48
49    private static final boolean DEBUG = false;
50    private static final String TAG = "swipe";
51
52    private static final int ICON_ALPHA_ANIM_DURATION = 200;
53    private static final long SHOW_MENU_DELAY = 60;
54    private static final long SWIPE_MENU_TIMING = 200;
55
56    // Notification must be swiped at least this fraction of a single menu item to show menu
57    private static final float SWIPED_FAR_ENOUGH_MENU_FRACTION = 0.25f;
58    private static final float SWIPED_FAR_ENOUGH_MENU_UNCLEARABLE_FRACTION = 0.15f;
59
60    // When the menu is displayed, the notification must be swiped within this fraction of a single
61    // menu item to snap back to menu (else it will cover the menu or it'll be dismissed)
62    private static final float SWIPED_BACK_ENOUGH_TO_COVER_FRACTION = 0.2f;
63
64    private ExpandableNotificationRow mParent;
65
66    private Context mContext;
67    private FrameLayout mMenuContainer;
68    private MenuItem mSnoozeItem;
69    private MenuItem mInfoItem;
70    private ArrayList<MenuItem> mMenuItems;
71    private OnMenuEventListener mMenuListener;
72
73    private ValueAnimator mFadeAnimator;
74    private boolean mAnimating;
75    private boolean mMenuFadedIn;
76
77    private boolean mOnLeft;
78    private boolean mIconsPlaced;
79
80    private boolean mDismissing;
81    private boolean mSnapping;
82    private float mTranslation;
83
84    private int[] mIconLocation = new int[2];
85    private int[] mParentLocation = new int[2];
86
87    private float mHorizSpaceForIcon;
88    private int mVertSpaceForIcons;
89    private int mIconPadding;
90
91    private float mAlpha = 0f;
92    private float mPrevX;
93
94    private CheckForDrag mCheckForDrag;
95    private Handler mHandler;
96
97    private boolean mMenuSnappedTo;
98    private boolean mMenuSnappedOnLeft;
99    private boolean mShouldShowMenu;
100
101    private NotificationSwipeActionHelper mSwipeHelper;
102
103    public NotificationMenuRow(Context context) {
104        mContext = context;
105        final Resources res = context.getResources();
106        mShouldShowMenu = res.getBoolean(R.bool.config_showNotificationGear);
107        mHorizSpaceForIcon = res.getDimensionPixelSize(R.dimen.notification_menu_icon_size);
108        mVertSpaceForIcons = res.getDimensionPixelSize(R.dimen.notification_min_height);
109        mIconPadding = res.getDimensionPixelSize(R.dimen.notification_menu_icon_padding);
110        mHandler = new Handler();
111        mMenuItems = new ArrayList<>();
112        mSnoozeItem = createSnoozeItem(context);
113        mInfoItem = createInfoItem(context);
114        mMenuItems.add(mSnoozeItem);
115        mMenuItems.add(mInfoItem);
116    }
117
118    @Override
119    public ArrayList<MenuItem> getMenuItems(Context context) {
120        return mMenuItems;
121    }
122
123    @Override
124    public MenuItem getLongpressMenuItem(Context context) {
125        return mInfoItem;
126    }
127
128    @Override
129    public void setSwipeActionHelper(NotificationSwipeActionHelper helper) {
130        mSwipeHelper = helper;
131    }
132
133    @Override
134    public void setMenuClickListener(OnMenuEventListener listener) {
135        mMenuListener = listener;
136    }
137
138    @Override
139    public void createMenu(ViewGroup parent) {
140        mParent = (ExpandableNotificationRow) parent;
141        createMenuViews();
142    }
143
144    @Override
145    public boolean isMenuVisible() {
146        return mAlpha > 0;
147    }
148
149    @Override
150    public View getMenuView() {
151        return mMenuContainer;
152    }
153
154    @Override
155    public void resetMenu() {
156        resetState(true);
157    }
158
159    @Override
160    public void onNotificationUpdated() {
161        if (mMenuContainer == null) {
162            // Menu hasn't been created yet, no need to do anything.
163            return;
164        }
165        createMenuViews();
166    }
167
168    private void createMenuViews() {
169        // Filter the menu items based on the notification
170        if (mParent != null && mParent.getStatusBarNotification() != null) {
171            int flags = mParent.getStatusBarNotification().getNotification().flags;
172            boolean isForeground = (flags & Notification.FLAG_FOREGROUND_SERVICE) != 0;
173            if (isForeground) {
174                // Don't show snooze for foreground services
175                mMenuItems.remove(mSnoozeItem);
176            } else if (!mMenuItems.contains(mSnoozeItem)) {
177                // Was a foreground service but is no longer, add snooze back
178                mMenuItems.add(mSnoozeItem);
179            }
180        }
181        // Recreate the menu
182        if (mMenuContainer != null) {
183            mMenuContainer.removeAllViews();
184        } else {
185            mMenuContainer = new FrameLayout(mContext);
186        }
187        for (int i = 0; i < mMenuItems.size(); i++) {
188            addMenuView(mMenuItems.get(i), mMenuContainer);
189        }
190        resetState(false /* notify */);
191    }
192
193    private void resetState(boolean notify) {
194        setMenuAlpha(0f);
195        mIconsPlaced = false;
196        mMenuFadedIn = false;
197        mAnimating = false;
198        mSnapping = false;
199        mDismissing = false;
200        mMenuSnappedTo = false;
201        setMenuLocation();
202        if (mMenuListener != null && notify) {
203            mMenuListener.onMenuReset(mParent);
204        }
205    }
206
207    @Override
208    public boolean onTouchEvent(View view, MotionEvent ev, float velocity) {
209        final int action = ev.getActionMasked();
210        switch (action) {
211            case MotionEvent.ACTION_DOWN:
212                mSnapping = false;
213                if (mFadeAnimator != null) {
214                    mFadeAnimator.cancel();
215                }
216                mHandler.removeCallbacks(mCheckForDrag);
217                mCheckForDrag = null;
218                mPrevX = ev.getRawX();
219                break;
220
221            case MotionEvent.ACTION_MOVE:
222                mSnapping = false;
223                float diffX = ev.getRawX() - mPrevX;
224                mPrevX = ev.getRawX();
225                if (!isTowardsMenu(diffX) && isMenuLocationChange()) {
226                    // Don't consider it "snapped" if location has changed.
227                    mMenuSnappedTo = false;
228
229                    // Changed directions, make sure we check to fade in icon again.
230                    if (!mHandler.hasCallbacks(mCheckForDrag)) {
231                        // No check scheduled, set null to schedule a new one.
232                        mCheckForDrag = null;
233                    } else {
234                        // Check scheduled, reset alpha and update location; check will fade it in
235                        setMenuAlpha(0f);
236                        setMenuLocation();
237                    }
238                }
239                if (mShouldShowMenu
240                        && !NotificationStackScrollLayout.isPinnedHeadsUp(view)
241                        && !mParent.areGutsExposed()
242                        && !mParent.isDark()
243                        && (mCheckForDrag == null || !mHandler.hasCallbacks(mCheckForDrag))) {
244                    // Only show the menu if we're not a heads up view and guts aren't exposed.
245                    mCheckForDrag = new CheckForDrag();
246                    mHandler.postDelayed(mCheckForDrag, SHOW_MENU_DELAY);
247                }
248                break;
249
250            case MotionEvent.ACTION_UP:
251                return handleUpEvent(ev, view, velocity);
252        }
253        return false;
254    }
255
256    private boolean handleUpEvent(MotionEvent ev, View animView, float velocity) {
257        // If the menu should not be shown, then there is no need to check if the a swipe
258        // should result in a snapping to the menu. As a result, just check if the swipe
259        // was enough to dismiss the notification.
260        if (!mShouldShowMenu) {
261            if (mSwipeHelper.isDismissGesture(ev)) {
262                dismiss(animView, velocity);
263            } else {
264                snapBack(animView, velocity);
265            }
266            return true;
267        }
268
269        final boolean gestureTowardsMenu = isTowardsMenu(velocity);
270        final boolean gestureFastEnough =
271                mSwipeHelper.getMinDismissVelocity() <= Math.abs(velocity);
272        final boolean gestureFarEnough =
273                mSwipeHelper.swipedFarEnough(mTranslation, mParent.getWidth());
274        final double timeForGesture = ev.getEventTime() - ev.getDownTime();
275        final boolean showMenuForSlowOnGoing = !mParent.canViewBeDismissed()
276                && timeForGesture >= SWIPE_MENU_TIMING;
277        final float menuSnapTarget = mOnLeft ? getSpaceForMenu() : -getSpaceForMenu();
278
279        if (DEBUG) {
280            Log.d(TAG, "mTranslation= " + mTranslation
281                    + " mAlpha= " + mAlpha
282                    + " velocity= " + velocity
283                    + " mMenuSnappedTo= " + mMenuSnappedTo
284                    + " mMenuSnappedOnLeft= " + mMenuSnappedOnLeft
285                    + " mOnLeft= " + mOnLeft
286                    + " minDismissVel= " + mSwipeHelper.getMinDismissVelocity()
287                    + " isDismissGesture= " + mSwipeHelper.isDismissGesture(ev)
288                    + " gestureTowardsMenu= " + gestureTowardsMenu
289                    + " gestureFastEnough= " + gestureFastEnough
290                    + " gestureFarEnough= " + gestureFarEnough);
291        }
292
293        if (mMenuSnappedTo && isMenuVisible() && mMenuSnappedOnLeft == mOnLeft) {
294            // Menu was snapped to previously and we're on the same side, figure out if
295            // we should stick to the menu, snap back into place, or dismiss
296            final float maximumSwipeDistance = mHorizSpaceForIcon
297                    * SWIPED_BACK_ENOUGH_TO_COVER_FRACTION;
298            final float targetLeft = getSpaceForMenu() - maximumSwipeDistance;
299            final float targetRight = mParent.getWidth() * SWIPED_FAR_ENOUGH_SIZE_FRACTION;
300            boolean withinSnapMenuThreshold = mOnLeft
301                    ? mTranslation > targetLeft && mTranslation < targetRight
302                    : mTranslation < -targetLeft && mTranslation > -targetRight;
303            boolean shouldSnapTo = mOnLeft ? mTranslation < targetLeft : mTranslation > -targetLeft;
304            if (DEBUG) {
305                Log.d(TAG, "   withinSnapMenuThreshold= " + withinSnapMenuThreshold
306                        + "   shouldSnapTo= " + shouldSnapTo
307                        + "   targetLeft= " + targetLeft
308                        + "   targetRight= " + targetRight);
309            }
310            if (withinSnapMenuThreshold && !mSwipeHelper.isDismissGesture(ev)) {
311                // Haven't moved enough to unsnap from the menu
312                showMenu(animView, menuSnapTarget, velocity);
313            } else if (mSwipeHelper.isDismissGesture(ev) && !shouldSnapTo) {
314                // Only dismiss if we're not moving towards the menu
315                dismiss(animView, velocity);
316            } else {
317                snapBack(animView, velocity);
318            }
319        } else if ((swipedEnoughToShowMenu() && (!gestureFastEnough || showMenuForSlowOnGoing))
320                || (gestureTowardsMenu && !mSwipeHelper.isDismissGesture(ev))) {
321            // Menu has not been snapped to previously and this is menu revealing gesture
322            showMenu(animView, menuSnapTarget, velocity);
323        } else if (mSwipeHelper.isDismissGesture(ev) && !gestureTowardsMenu) {
324            dismiss(animView, velocity);
325        } else {
326            snapBack(animView, velocity);
327        }
328        return true;
329    }
330
331    private void showMenu(View animView, float targetLeft, float velocity) {
332        mMenuSnappedTo = true;
333        mMenuSnappedOnLeft = mOnLeft;
334        mMenuListener.onMenuShown(animView);
335        mSwipeHelper.snap(animView, targetLeft, velocity);
336    }
337
338    private void snapBack(View animView, float velocity) {
339        if (mFadeAnimator != null) {
340            mFadeAnimator.cancel();
341        }
342        mHandler.removeCallbacks(mCheckForDrag);
343        mMenuSnappedTo = false;
344        mSnapping = true;
345        mSwipeHelper.snap(animView, 0 /* leftTarget */, velocity);
346    }
347
348    private void dismiss(View animView, float velocity) {
349        if (mFadeAnimator != null) {
350            mFadeAnimator.cancel();
351        }
352        mHandler.removeCallbacks(mCheckForDrag);
353        mMenuSnappedTo = false;
354        mDismissing = true;
355        mSwipeHelper.dismiss(animView, velocity);
356    }
357
358    /**
359     * @return whether the notification has been translated enough to show the menu and not enough
360     *         to be dismissed.
361     */
362    private boolean swipedEnoughToShowMenu() {
363        final float multiplier = mParent.canViewBeDismissed()
364                ? SWIPED_FAR_ENOUGH_MENU_FRACTION
365                : SWIPED_FAR_ENOUGH_MENU_UNCLEARABLE_FRACTION;
366        final float minimumSwipeDistance = mHorizSpaceForIcon * multiplier;
367        return !mSwipeHelper.swipedFarEnough(0, 0) && isMenuVisible()
368                && (mOnLeft ? mTranslation > minimumSwipeDistance
369                        : mTranslation < -minimumSwipeDistance);
370    }
371
372    /**
373     * Returns whether the gesture is towards the menu location or not.
374     */
375    private boolean isTowardsMenu(float movement) {
376        return isMenuVisible()
377                && ((mOnLeft && movement <= 0)
378                        || (!mOnLeft && movement >= 0));
379    }
380
381    @Override
382    public void setAppName(String appName) {
383        if (appName == null) {
384            return;
385        }
386        Resources res = mContext.getResources();
387        final int count = mMenuItems.size();
388        for (int i = 0; i < count; i++) {
389            MenuItem item = mMenuItems.get(i);
390            String description = String.format(
391                    res.getString(R.string.notification_menu_accessibility),
392                    appName, item.getContentDescription());
393            View menuView = item.getMenuView();
394            if (menuView != null) {
395                menuView.setContentDescription(description);
396            }
397        }
398    }
399
400    @Override
401    public void onHeightUpdate() {
402        if (mParent == null || mMenuItems.size() == 0) {
403            return;
404        }
405        int parentHeight = mParent.getCollapsedHeight();
406        float translationY;
407        if (parentHeight < mVertSpaceForIcons) {
408            translationY = (parentHeight / 2) - (mHorizSpaceForIcon / 2);
409        } else {
410            translationY = (mVertSpaceForIcons - mHorizSpaceForIcon) / 2;
411        }
412        mMenuContainer.setTranslationY(translationY);
413    }
414
415    @Override
416    public void onTranslationUpdate(float translation) {
417        mTranslation = translation;
418        if (mAnimating || !mMenuFadedIn) {
419            // Don't adjust when animating, or if the menu hasn't been shown yet.
420            return;
421        }
422        final float fadeThreshold = mParent.getWidth() * 0.3f;
423        final float absTrans = Math.abs(translation);
424        float desiredAlpha = 0;
425        if (absTrans == 0) {
426            desiredAlpha = 0;
427        } else if (absTrans <= fadeThreshold) {
428            desiredAlpha = 1;
429        } else {
430            desiredAlpha = 1 - ((absTrans - fadeThreshold) / (mParent.getWidth() - fadeThreshold));
431        }
432        setMenuAlpha(desiredAlpha);
433    }
434
435    @Override
436    public void onClick(View v) {
437        if (mMenuListener == null) {
438            // Nothing to do
439            return;
440        }
441        v.getLocationOnScreen(mIconLocation);
442        mParent.getLocationOnScreen(mParentLocation);
443        final int centerX = (int) (mHorizSpaceForIcon / 2);
444        final int centerY = v.getHeight() / 2;
445        final int x = mIconLocation[0] - mParentLocation[0] + centerX;
446        final int y = mIconLocation[1] - mParentLocation[1] + centerY;
447        final int index = mMenuContainer.indexOfChild(v);
448        mMenuListener.onMenuClicked(mParent, x, y, mMenuItems.get(index));
449    }
450
451    private boolean isMenuLocationChange() {
452        boolean onLeft = mTranslation > mIconPadding;
453        boolean onRight = mTranslation < -mIconPadding;
454        if ((mOnLeft && onRight) || (!mOnLeft && onLeft)) {
455            return true;
456        }
457        return false;
458    }
459
460    private void setMenuLocation() {
461        boolean showOnLeft = mTranslation > 0;
462        if ((mIconsPlaced && showOnLeft == mOnLeft) || mSnapping || mParent == null) {
463            // Do nothing
464            return;
465        }
466        final boolean isRtl = mParent.isLayoutRtl();
467        final int count = mMenuContainer.getChildCount();
468        final int width = mParent.getWidth();
469        for (int i = 0; i < count; i++) {
470            final View v = mMenuContainer.getChildAt(i);
471            final float left = isRtl
472                    ? -(width - mHorizSpaceForIcon * (i + 1))
473                    : i * mHorizSpaceForIcon;
474            final float right = isRtl
475                    ? -i * mHorizSpaceForIcon
476                    : width - (mHorizSpaceForIcon * (i + 1));
477            v.setTranslationX(showOnLeft ? left : right);
478        }
479        mOnLeft = showOnLeft;
480        mIconsPlaced = true;
481    }
482
483    private void setMenuAlpha(float alpha) {
484        mAlpha = alpha;
485        if (mMenuContainer == null) {
486            return;
487        }
488        if (alpha == 0) {
489            mMenuFadedIn = false; // Can fade in again once it's gone.
490            mMenuContainer.setVisibility(View.INVISIBLE);
491        } else {
492            mMenuContainer.setVisibility(View.VISIBLE);
493        }
494        final int count = mMenuContainer.getChildCount();
495        for (int i = 0; i < count; i++) {
496            mMenuContainer.getChildAt(i).setAlpha(mAlpha);
497        }
498    }
499
500    /**
501     * Returns the horizontal space in pixels required to display the menu.
502     */
503    private float getSpaceForMenu() {
504        return mHorizSpaceForIcon * mMenuContainer.getChildCount();
505    }
506
507    private final class CheckForDrag implements Runnable {
508        @Override
509        public void run() {
510            final float absTransX = Math.abs(mTranslation);
511            final float bounceBackToMenuWidth = getSpaceForMenu();
512            final float notiThreshold = mParent.getWidth() * 0.4f;
513            if ((!isMenuVisible() || isMenuLocationChange())
514                    && absTransX >= bounceBackToMenuWidth * 0.4
515                    && absTransX < notiThreshold) {
516                fadeInMenu(notiThreshold);
517            }
518        }
519    }
520
521    private void fadeInMenu(final float notiThreshold) {
522        if (mDismissing || mAnimating) {
523            return;
524        }
525        if (isMenuLocationChange()) {
526            setMenuAlpha(0f);
527        }
528        final float transX = mTranslation;
529        final boolean fromLeft = mTranslation > 0;
530        setMenuLocation();
531        mFadeAnimator = ValueAnimator.ofFloat(mAlpha, 1);
532        mFadeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
533            @Override
534            public void onAnimationUpdate(ValueAnimator animation) {
535                final float absTrans = Math.abs(transX);
536
537                boolean pastMenu = (fromLeft && transX <= notiThreshold)
538                        || (!fromLeft && absTrans <= notiThreshold);
539                if (pastMenu && !mMenuFadedIn) {
540                    setMenuAlpha((float) animation.getAnimatedValue());
541                }
542            }
543        });
544        mFadeAnimator.addListener(new AnimatorListenerAdapter() {
545            @Override
546            public void onAnimationStart(Animator animation) {
547                mAnimating = true;
548            }
549
550            @Override
551            public void onAnimationCancel(Animator animation) {
552                // TODO should animate back to 0f from current alpha
553                setMenuAlpha(0f);
554            }
555
556            @Override
557            public void onAnimationEnd(Animator animation) {
558                mAnimating = false;
559                mMenuFadedIn = mAlpha == 1;
560            }
561        });
562        mFadeAnimator.setInterpolator(Interpolators.ALPHA_IN);
563        mFadeAnimator.setDuration(ICON_ALPHA_ANIM_DURATION);
564        mFadeAnimator.start();
565    }
566
567    @Override
568    public void setMenuItems(ArrayList<MenuItem> items) {
569        // Do nothing we use our own for now.
570        // TODO -- handle / allow custom menu items!
571    }
572
573    public static MenuItem createSnoozeItem(Context context) {
574        Resources res = context.getResources();
575        NotificationSnooze content = (NotificationSnooze) LayoutInflater.from(context)
576                .inflate(R.layout.notification_snooze, null, false);
577        String snoozeDescription = res.getString(R.string.notification_menu_snooze_description);
578        MenuItem snooze = new NotificationMenuItem(context, snoozeDescription, content,
579                R.drawable.ic_snooze);
580        return snooze;
581    }
582
583    public static MenuItem createInfoItem(Context context) {
584        Resources res = context.getResources();
585        String infoDescription = res.getString(R.string.notification_menu_gear_description);
586        NotificationInfo infoContent = (NotificationInfo) LayoutInflater.from(context).inflate(
587                R.layout.notification_info, null, false);
588        MenuItem info = new NotificationMenuItem(context, infoDescription, infoContent,
589                R.drawable.ic_settings);
590        return info;
591    }
592
593    private void addMenuView(MenuItem item, ViewGroup parent) {
594        View menuView = item.getMenuView();
595        if (menuView != null) {
596            parent.addView(menuView);
597            menuView.setOnClickListener(this);
598            FrameLayout.LayoutParams lp = (LayoutParams) menuView.getLayoutParams();
599            lp.width = (int) mHorizSpaceForIcon;
600            lp.height = (int) mHorizSpaceForIcon;
601            menuView.setLayoutParams(lp);
602        }
603    }
604
605    public static class NotificationMenuItem implements MenuItem {
606        View mMenuView;
607        GutsContent mGutsContent;
608        String mContentDescription;
609
610        public NotificationMenuItem(Context context, String s, GutsContent content, int iconResId) {
611            Resources res = context.getResources();
612            int padding = res.getDimensionPixelSize(R.dimen.notification_menu_icon_padding);
613            int tint = res.getColor(R.color.notification_gear_color);
614            AlphaOptimizedImageView iv = new AlphaOptimizedImageView(context);
615            iv.setPadding(padding, padding, padding, padding);
616            Drawable icon = context.getResources().getDrawable(iconResId);
617            iv.setImageDrawable(icon);
618            iv.setColorFilter(tint);
619            iv.setAlpha(1f);
620            mMenuView = iv;
621            mContentDescription = s;
622            mGutsContent = content;
623        }
624
625        @Override
626        public View getMenuView() {
627            return mMenuView;
628        }
629
630        @Override
631        public View getGutsView() {
632            return mGutsContent.getContentView();
633        }
634
635        @Override
636        public String getContentDescription() {
637            return mContentDescription;
638        }
639    }
640}
641