1 /*
2  * Copyright (C) 2010 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.internal.view.menu;
18 
19 import android.annotation.AttrRes;
20 import android.annotation.NonNull;
21 import android.annotation.Nullable;
22 import android.annotation.StyleRes;
23 import android.compat.annotation.UnsupportedAppUsage;
24 import android.content.Context;
25 import android.graphics.Rect;
26 import android.os.Build;
27 import android.view.Gravity;
28 import android.view.View;
29 import android.view.WindowManager;
30 import android.widget.PopupWindow.OnDismissListener;
31 
32 import com.android.internal.view.menu.MenuPresenter.Callback;
33 
34 /**
35  * Presents a menu as a small, simple popup anchored to another view.
36  */
37 public class MenuPopupHelper implements MenuHelper {
38     private static final int TOUCH_EPICENTER_SIZE_DP = 48;
39 
40     private final Context mContext;
41 
42     // Immutable cached popup menu properties.
43     private final MenuBuilder mMenu;
44     private final boolean mOverflowOnly;
45     private final int mPopupStyleAttr;
46     private final int mPopupStyleRes;
47 
48     // Mutable cached popup menu properties.
49     private View mAnchorView;
50     private int mDropDownGravity = Gravity.START;
51     @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
52     private boolean mForceShowIcon;
53     private Callback mPresenterCallback;
54 
55     private MenuPopup mPopup;
56     private OnDismissListener mOnDismissListener;
57 
58     @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
MenuPopupHelper(@onNull Context context, @NonNull MenuBuilder menu)59     public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu) {
60         this(context, menu, null, false, com.android.internal.R.attr.popupMenuStyle, 0);
61     }
62 
63     @UnsupportedAppUsage
MenuPopupHelper(@onNull Context context, @NonNull MenuBuilder menu, @NonNull View anchorView)64     public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu,
65             @NonNull View anchorView) {
66         this(context, menu, anchorView, false, com.android.internal.R.attr.popupMenuStyle, 0);
67     }
68 
MenuPopupHelper(@onNull Context context, @NonNull MenuBuilder menu, @NonNull View anchorView, boolean overflowOnly, @AttrRes int popupStyleAttr)69     public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu,
70             @NonNull View anchorView,
71             boolean overflowOnly, @AttrRes int popupStyleAttr) {
72         this(context, menu, anchorView, overflowOnly, popupStyleAttr, 0);
73     }
74 
MenuPopupHelper(@onNull Context context, @NonNull MenuBuilder menu, @NonNull View anchorView, boolean overflowOnly, @AttrRes int popupStyleAttr, @StyleRes int popupStyleRes)75     public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu,
76             @NonNull View anchorView, boolean overflowOnly, @AttrRes int popupStyleAttr,
77             @StyleRes int popupStyleRes) {
78         mContext = context;
79         mMenu = menu;
80         mAnchorView = anchorView;
81         mOverflowOnly = overflowOnly;
82         mPopupStyleAttr = popupStyleAttr;
83         mPopupStyleRes = popupStyleRes;
84     }
85 
setOnDismissListener(@ullable OnDismissListener listener)86     public void setOnDismissListener(@Nullable OnDismissListener listener) {
87         mOnDismissListener = listener;
88     }
89 
90     /**
91       * Sets the view to which the popup window is anchored.
92       * <p>
93       * Changes take effect on the next call to show().
94       *
95       * @param anchor the view to which the popup window should be anchored
96       */
97     @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
setAnchorView(@onNull View anchor)98     public void setAnchorView(@NonNull View anchor) {
99         mAnchorView = anchor;
100     }
101 
102     /**
103      * Sets whether the popup menu's adapter is forced to show icons in the
104      * menu item views.
105      * <p>
106      * Changes take effect on the next call to show().
107      *
108      * This method should not be accessed directly outside the framework, please use
109      * {@link android.widget.PopupMenu#setForceShowIcon(boolean)} instead.
110      *
111      * @param forceShowIcon {@code true} to force icons to be shown, or
112      *                  {@code false} for icons to be optionally shown
113      */
114     @UnsupportedAppUsage
setForceShowIcon(boolean forceShowIcon)115     public void setForceShowIcon(boolean forceShowIcon) {
116         mForceShowIcon = forceShowIcon;
117         if (mPopup != null) {
118             mPopup.setForceShowIcon(forceShowIcon);
119         }
120     }
121 
122     /**
123       * Sets the alignment of the popup window relative to the anchor view.
124       * <p>
125       * Changes take effect on the next call to show().
126       *
127       * @param gravity alignment of the popup relative to the anchor
128       */
129     @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
setGravity(int gravity)130     public void setGravity(int gravity) {
131         mDropDownGravity = gravity;
132     }
133 
134     /**
135      * @return alignment of the popup relative to the anchor
136      */
getGravity()137     public int getGravity() {
138         return mDropDownGravity;
139     }
140 
141     @UnsupportedAppUsage
show()142     public void show() {
143         if (!tryShow()) {
144             throw new IllegalStateException("MenuPopupHelper cannot be used without an anchor");
145         }
146     }
147 
show(int x, int y)148     public void show(int x, int y) {
149         if (!tryShow(x, y)) {
150             throw new IllegalStateException("MenuPopupHelper cannot be used without an anchor");
151         }
152     }
153 
154     @NonNull
155     @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
getPopup()156     public MenuPopup getPopup() {
157         if (mPopup == null) {
158             mPopup = createPopup();
159         }
160         return mPopup;
161     }
162 
163     /**
164      * Attempts to show the popup anchored to the view specified by {@link #setAnchorView(View)}.
165      *
166      * @return {@code true} if the popup was shown or was already showing prior to calling this
167      *         method, {@code false} otherwise
168      */
169     @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
tryShow()170     public boolean tryShow() {
171         if (isShowing()) {
172             return true;
173         }
174 
175         if (mAnchorView == null) {
176             return false;
177         }
178 
179         showPopup(0, 0, false, false);
180         return true;
181     }
182 
183     /**
184      * Shows the popup menu and makes a best-effort to anchor it to the
185      * specified (x,y) coordinate relative to the anchor view.
186      * <p>
187      * Additionally, the popup's transition epicenter (see
188      * {@link android.widget.PopupWindow#setEpicenterBounds(Rect)} will be
189      * centered on the specified coordinate, rather than using the bounds of
190      * the anchor view.
191      * <p>
192      * If the popup's resolved gravity is {@link Gravity#LEFT}, this will
193      * display the popup with its top-left corner at (x,y) relative to the
194      * anchor view. If the resolved gravity is {@link Gravity#RIGHT}, the
195      * popup's top-right corner will be at (x,y).
196      * <p>
197      * If the popup cannot be displayed fully on-screen, this method will
198      * attempt to scroll the anchor view's ancestors and/or offset the popup
199      * such that it may be displayed fully on-screen.
200      *
201      * @param x x coordinate relative to the anchor view
202      * @param y y coordinate relative to the anchor view
203      * @return {@code true} if the popup was shown or was already showing prior
204      *         to calling this method, {@code false} otherwise
205      */
tryShow(int x, int y)206     public boolean tryShow(int x, int y) {
207         if (isShowing()) {
208             return true;
209         }
210 
211         if (mAnchorView == null) {
212             return false;
213         }
214 
215         showPopup(x, y, true, true);
216         return true;
217     }
218 
219     /**
220      * Creates the popup and assigns cached properties.
221      *
222      * @return an initialized popup
223      */
224     @NonNull
createPopup()225     private MenuPopup createPopup() {
226         final WindowManager windowManager = mContext.getSystemService(WindowManager.class);
227         final Rect maxWindowBounds = windowManager.getMaximumWindowMetrics().getBounds();
228 
229         final int smallestWidth = Math.min(maxWindowBounds.width(), maxWindowBounds.height());
230         final int minSmallestWidthCascading = mContext.getResources().getDimensionPixelSize(
231             com.android.internal.R.dimen.cascading_menus_min_smallest_width);
232         final boolean enableCascadingSubmenus = smallestWidth >= minSmallestWidthCascading;
233 
234         final MenuPopup popup;
235         if (enableCascadingSubmenus) {
236             popup = new CascadingMenuPopup(mContext, mAnchorView, mPopupStyleAttr,
237                     mPopupStyleRes, mOverflowOnly);
238         } else {
239             popup = new StandardMenuPopup(mContext, mMenu, mAnchorView, mPopupStyleAttr,
240                     mPopupStyleRes, mOverflowOnly);
241         }
242 
243         // Assign immutable properties.
244         popup.addMenu(mMenu);
245         popup.setOnDismissListener(mInternalOnDismissListener);
246 
247         // Assign mutable properties. These may be reassigned later.
248         popup.setAnchorView(mAnchorView);
249         popup.setCallback(mPresenterCallback);
250         popup.setForceShowIcon(mForceShowIcon);
251         popup.setGravity(mDropDownGravity);
252 
253         return popup;
254     }
255 
showPopup(int xOffset, int yOffset, boolean useOffsets, boolean showTitle)256     private void showPopup(int xOffset, int yOffset, boolean useOffsets, boolean showTitle) {
257         final MenuPopup popup = getPopup();
258         popup.setShowTitle(showTitle);
259 
260         if (useOffsets) {
261             // If the resolved drop-down gravity is RIGHT, the popup's right
262             // edge will be aligned with the anchor view. Adjust by the anchor
263             // width such that the top-right corner is at the X offset.
264             final int hgrav = Gravity.getAbsoluteGravity(mDropDownGravity,
265                     mAnchorView.getLayoutDirection()) & Gravity.HORIZONTAL_GRAVITY_MASK;
266             if (hgrav == Gravity.RIGHT) {
267                 xOffset -= mAnchorView.getWidth();
268             }
269 
270             popup.setHorizontalOffset(xOffset);
271             popup.setVerticalOffset(yOffset);
272 
273             // Set the transition epicenter to be roughly finger (or mouse
274             // cursor) sized and centered around the offset position. This
275             // will give the appearance that the window is emerging from
276             // the touch point.
277             final float density = mContext.getResources().getDisplayMetrics().density;
278             final int halfSize = (int) (TOUCH_EPICENTER_SIZE_DP * density / 2);
279             final Rect epicenter = new Rect(xOffset - halfSize, yOffset - halfSize,
280                     xOffset + halfSize, yOffset + halfSize);
281             popup.setEpicenterBounds(epicenter);
282         }
283 
284         popup.show();
285     }
286 
287     /**
288      * Dismisses the popup, if showing.
289      */
290     @Override
291     @UnsupportedAppUsage
dismiss()292     public void dismiss() {
293         if (isShowing()) {
294             mPopup.dismiss();
295         }
296     }
297 
298     /**
299      * Called after the popup has been dismissed.
300      * <p>
301      * <strong>Note:</strong> Subclasses should call the super implementation
302      * last to ensure that any necessary tear down has occurred before the
303      * listener specified by {@link #setOnDismissListener(OnDismissListener)}
304      * is called.
305      */
onDismiss()306     protected void onDismiss() {
307         mPopup = null;
308 
309         if (mOnDismissListener != null) {
310             mOnDismissListener.onDismiss();
311         }
312     }
313 
isShowing()314     public boolean isShowing() {
315         return mPopup != null && mPopup.isShowing();
316     }
317 
318     @Override
setPresenterCallback(@ullable MenuPresenter.Callback cb)319     public void setPresenterCallback(@Nullable MenuPresenter.Callback cb) {
320         mPresenterCallback = cb;
321         if (mPopup != null) {
322             mPopup.setCallback(cb);
323         }
324     }
325 
326     /**
327      * Listener used to proxy dismiss callbacks to the helper's owner.
328      */
329     private final OnDismissListener mInternalOnDismissListener = new OnDismissListener() {
330         @Override
331         public void onDismiss() {
332             MenuPopupHelper.this.onDismiss();
333         }
334     };
335 }
336