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 androidx.appcompat.view.menu;
18 
19 import static androidx.annotation.RestrictTo.Scope.LIBRARY;
20 import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
21 
22 import android.content.Context;
23 import android.graphics.Point;
24 import android.graphics.Rect;
25 import android.view.Display;
26 import android.view.Gravity;
27 import android.view.View;
28 import android.view.WindowManager;
29 import android.widget.ListView;
30 import android.widget.PopupWindow;
31 import android.widget.PopupWindow.OnDismissListener;
32 
33 import androidx.annotation.AttrRes;
34 import androidx.annotation.RestrictTo;
35 import androidx.annotation.StyleRes;
36 import androidx.appcompat.R;
37 import androidx.core.view.GravityCompat;
38 
39 import org.jspecify.annotations.NonNull;
40 import org.jspecify.annotations.Nullable;
41 
42 /**
43  * Presents a menu as a small, simple popup anchored to another view.
44  *
45  */
46 @RestrictTo(LIBRARY_GROUP_PREFIX)
47 public class MenuPopupHelper implements MenuHelper {
48     private static final int TOUCH_EPICENTER_SIZE_DP = 48;
49 
50     private final Context mContext;
51 
52     // Immutable cached popup menu properties.
53     private final MenuBuilder mMenu;
54     private final boolean mOverflowOnly;
55     private final int mPopupStyleAttr;
56     private final int mPopupStyleRes;
57 
58     // Mutable cached popup menu properties.
59     private View mAnchorView;
60     private int mDropDownGravity = Gravity.START;
61     private boolean mForceShowIcon;
62     private MenuPresenter.Callback mPresenterCallback;
63 
64     private MenuPopup mPopup;
65     private OnDismissListener mOnDismissListener;
66 
MenuPopupHelper(@onNull Context context, @NonNull MenuBuilder menu)67     public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu) {
68         this(context, menu, null, false, R.attr.popupMenuStyle, 0);
69     }
70 
MenuPopupHelper(@onNull Context context, @NonNull MenuBuilder menu, @NonNull View anchorView)71     public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu,
72             @NonNull View anchorView) {
73         this(context, menu, anchorView, false, R.attr.popupMenuStyle, 0);
74     }
75 
MenuPopupHelper(@onNull Context context, @NonNull MenuBuilder menu, @NonNull View anchorView, boolean overflowOnly, @AttrRes int popupStyleAttr)76     public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu,
77             @NonNull View anchorView,
78             boolean overflowOnly, @AttrRes int popupStyleAttr) {
79         this(context, menu, anchorView, overflowOnly, popupStyleAttr, 0);
80     }
81 
MenuPopupHelper(@onNull Context context, @NonNull MenuBuilder menu, @NonNull View anchorView, boolean overflowOnly, @AttrRes int popupStyleAttr, @StyleRes int popupStyleRes)82     public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu,
83             @NonNull View anchorView, boolean overflowOnly, @AttrRes int popupStyleAttr,
84             @StyleRes int popupStyleRes) {
85         mContext = context;
86         mMenu = menu;
87         mAnchorView = anchorView;
88         mOverflowOnly = overflowOnly;
89         mPopupStyleAttr = popupStyleAttr;
90         mPopupStyleRes = popupStyleRes;
91     }
92 
setOnDismissListener(@ullable OnDismissListener listener)93     public void setOnDismissListener(@Nullable OnDismissListener listener) {
94         mOnDismissListener = listener;
95     }
96 
97     /**
98       * Sets the view to which the popup window is anchored.
99       * <p>
100       * Changes take effect on the next call to show().
101       *
102       * @param anchor the view to which the popup window should be anchored
103       */
setAnchorView(@onNull View anchor)104     public void setAnchorView(@NonNull View anchor) {
105         mAnchorView = anchor;
106     }
107 
108     /**
109      * Sets whether the popup menu's adapter is forced to show icons in the
110      * menu item views.
111      * <p>
112      * Changes take effect on the next call to show().
113      *
114      * @param forceShowIcon {@code true} to force icons to be shown, or
115      *                  {@code false} for icons to be optionally shown
116      */
setForceShowIcon(boolean forceShowIcon)117     public void setForceShowIcon(boolean forceShowIcon) {
118         mForceShowIcon = forceShowIcon;
119         if (mPopup != null) {
120             mPopup.setForceShowIcon(forceShowIcon);
121         }
122     }
123 
124     /**
125       * Sets the alignment of the popup window relative to the anchor view.
126       * <p>
127       * Changes take effect on the next call to show().
128       *
129       * @param gravity alignment of the popup relative to the anchor
130       */
setGravity(int gravity)131     public void setGravity(int gravity) {
132         mDropDownGravity = gravity;
133     }
134 
135     /**
136      * @return alignment of the popup relative to the anchor
137      */
getGravity()138     public int getGravity() {
139         return mDropDownGravity;
140     }
141 
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     /**
155      */
156     @RestrictTo(LIBRARY)
getPopup()157     public @NonNull MenuPopup getPopup() {
158         if (mPopup == null) {
159             mPopup = createPopup();
160         }
161         return mPopup;
162     }
163 
164     /**
165      * Attempts to show the popup anchored to the view specified by {@link #setAnchorView(View)}.
166      *
167      * @return {@code true} if the popup was shown or was already showing prior to calling this
168      *         method, {@code false} otherwise
169      */
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 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     @SuppressWarnings("deprecation") /* getDefaultDisplay */
createPopup()225     private @NonNull MenuPopup createPopup() {
226         final WindowManager windowManager = (WindowManager) mContext.getSystemService(
227                 Context.WINDOW_SERVICE);
228         final Display display = windowManager.getDefaultDisplay();
229         final Point displaySize = new Point();
230 
231         display.getRealSize(displaySize);
232 
233         final int smallestWidth = Math.min(displaySize.x, displaySize.y);
234         final int minSmallestWidthCascading = mContext.getResources().getDimensionPixelSize(
235                 R.dimen.abc_cascading_menus_min_smallest_width);
236         final boolean enableCascadingSubmenus = smallestWidth >= minSmallestWidthCascading;
237 
238         final MenuPopup popup;
239         if (enableCascadingSubmenus) {
240             popup = new CascadingMenuPopup(mContext, mAnchorView, mPopupStyleAttr,
241                     mPopupStyleRes, mOverflowOnly);
242         } else {
243             popup = new StandardMenuPopup(mContext, mMenu, mAnchorView, mPopupStyleAttr,
244                     mPopupStyleRes, mOverflowOnly);
245         }
246 
247         // Assign immutable properties.
248         popup.addMenu(mMenu);
249         popup.setOnDismissListener(mInternalOnDismissListener);
250 
251         // Assign mutable properties. These may be reassigned later.
252         popup.setAnchorView(mAnchorView);
253         popup.setCallback(mPresenterCallback);
254         popup.setForceShowIcon(mForceShowIcon);
255         popup.setGravity(mDropDownGravity);
256 
257         return popup;
258     }
259 
showPopup(int xOffset, int yOffset, boolean useOffsets, boolean showTitle)260     private void showPopup(int xOffset, int yOffset, boolean useOffsets, boolean showTitle) {
261         final MenuPopup popup = getPopup();
262         popup.setShowTitle(showTitle);
263 
264         if (useOffsets) {
265             // If the resolved drop-down gravity is RIGHT, the popup's right
266             // edge will be aligned with the anchor view. Adjust by the anchor
267             // width such that the top-right corner is at the X offset.
268             final int hgrav = GravityCompat.getAbsoluteGravity(mDropDownGravity,
269                     mAnchorView.getLayoutDirection()) & Gravity.HORIZONTAL_GRAVITY_MASK;
270             if (hgrav == Gravity.RIGHT) {
271                 xOffset -= mAnchorView.getWidth();
272             }
273 
274             popup.setHorizontalOffset(xOffset);
275             popup.setVerticalOffset(yOffset);
276 
277             // Set the transition epicenter to be roughly finger (or mouse
278             // cursor) sized and centered around the offset position. This
279             // will give the appearance that the window is emerging from
280             // the touch point.
281             final float density = mContext.getResources().getDisplayMetrics().density;
282             final int halfSize = (int) (TOUCH_EPICENTER_SIZE_DP * density / 2);
283             final Rect epicenter = new Rect(xOffset - halfSize, yOffset - halfSize,
284                     xOffset + halfSize, yOffset + halfSize);
285             popup.setEpicenterBounds(epicenter);
286         }
287 
288         popup.show();
289     }
290 
291     /**
292      * Dismisses the popup, if showing.
293      */
294     @Override
dismiss()295     public void dismiss() {
296         if (isShowing()) {
297             mPopup.dismiss();
298         }
299     }
300 
301     /**
302      * Called after the popup has been dismissed.
303      * <p>
304      * <strong>Note:</strong> Subclasses should call the super implementation
305      * last to ensure that any necessary tear down has occurred before the
306      * listener specified by {@link #setOnDismissListener(OnDismissListener)}
307      * is called.
308      */
onDismiss()309     protected void onDismiss() {
310         mPopup = null;
311 
312         if (mOnDismissListener != null) {
313             mOnDismissListener.onDismiss();
314         }
315     }
316 
isShowing()317     public boolean isShowing() {
318         return mPopup != null && mPopup.isShowing();
319     }
320 
321     @Override
setPresenterCallback(MenuPresenter.@ullable Callback cb)322     public void setPresenterCallback(MenuPresenter.@Nullable Callback cb) {
323         mPresenterCallback = cb;
324         if (mPopup != null) {
325             mPopup.setCallback(cb);
326         }
327     }
328 
329     /**
330      * Listener used to proxy dismiss callbacks to the helper's owner.
331      */
332     private final OnDismissListener mInternalOnDismissListener = new OnDismissListener() {
333         @Override
334         public void onDismiss() {
335             MenuPopupHelper.this.onDismiss();
336         }
337     };
338 
339     /**
340      * API to get the underlying ListView of the Popup
341      */
getListView()342     public ListView getListView() {
343         return getPopup().getListView();
344     }
345 }
346