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