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
17package androidx.appcompat.view.menu;
18
19import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
20
21import android.content.Context;
22import android.graphics.Point;
23import android.graphics.Rect;
24import android.os.Build;
25import android.view.Display;
26import android.view.Gravity;
27import android.view.View;
28import android.view.WindowManager;
29import android.widget.ListView;
30import android.widget.PopupWindow.OnDismissListener;
31
32import androidx.annotation.AttrRes;
33import androidx.annotation.NonNull;
34import androidx.annotation.Nullable;
35import androidx.annotation.RestrictTo;
36import androidx.annotation.StyleRes;
37import androidx.appcompat.R;
38import androidx.core.view.GravityCompat;
39import androidx.core.view.ViewCompat;
40
41/**
42 * Presents a menu as a small, simple popup anchored to another view.
43 *
44 * @hide
45 */
46@RestrictTo(LIBRARY_GROUP)
47public 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
67    public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu) {
68        this(context, menu, null, false, R.attr.popupMenuStyle, 0);
69    }
70
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
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
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
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      */
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     */
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      */
131    public void setGravity(int gravity) {
132        mDropDownGravity = gravity;
133    }
134
135    /**
136     * @return alignment of the popup relative to the anchor
137     */
138    public int getGravity() {
139        return mDropDownGravity;
140    }
141
142    public void show() {
143        if (!tryShow()) {
144            throw new IllegalStateException("MenuPopupHelper cannot be used without an anchor");
145        }
146    }
147
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    public MenuPopup getPopup() {
156        if (mPopup == null) {
157            mPopup = createPopup();
158        }
159        return mPopup;
160    }
161
162    /**
163     * Attempts to show the popup anchored to the view specified by {@link #setAnchorView(View)}.
164     *
165     * @return {@code true} if the popup was shown or was already showing prior to calling this
166     *         method, {@code false} otherwise
167     */
168    public boolean tryShow() {
169        if (isShowing()) {
170            return true;
171        }
172
173        if (mAnchorView == null) {
174            return false;
175        }
176
177        showPopup(0, 0, false, false);
178        return true;
179    }
180
181    /**
182     * Shows the popup menu and makes a best-effort to anchor it to the
183     * specified (x,y) coordinate relative to the anchor view.
184     * <p>
185     * Additionally, the popup's transition epicenter (see
186     * {@link android.widget.PopupWindow#setEpicenterBounds(Rect)} will be
187     * centered on the specified coordinate, rather than using the bounds of
188     * the anchor view.
189     * <p>
190     * If the popup's resolved gravity is {@link Gravity#LEFT}, this will
191     * display the popup with its top-left corner at (x,y) relative to the
192     * anchor view. If the resolved gravity is {@link Gravity#RIGHT}, the
193     * popup's top-right corner will be at (x,y).
194     * <p>
195     * If the popup cannot be displayed fully on-screen, this method will
196     * attempt to scroll the anchor view's ancestors and/or offset the popup
197     * such that it may be displayed fully on-screen.
198     *
199     * @param x x coordinate relative to the anchor view
200     * @param y y coordinate relative to the anchor view
201     * @return {@code true} if the popup was shown or was already showing prior
202     *         to calling this method, {@code false} otherwise
203     */
204    public boolean tryShow(int x, int y) {
205        if (isShowing()) {
206            return true;
207        }
208
209        if (mAnchorView == null) {
210            return false;
211        }
212
213        showPopup(x, y, true, true);
214        return true;
215    }
216
217    /**
218     * Creates the popup and assigns cached properties.
219     *
220     * @return an initialized popup
221     */
222    @NonNull
223    private MenuPopup createPopup() {
224        final WindowManager windowManager = (WindowManager) mContext.getSystemService(
225                Context.WINDOW_SERVICE);
226        final Display display = windowManager.getDefaultDisplay();
227        final Point displaySize = new Point();
228
229        if (Build.VERSION.SDK_INT >= 17) {
230            display.getRealSize(displaySize);
231        } else {
232            display.getSize(displaySize);
233        }
234
235        final int smallestWidth = Math.min(displaySize.x, displaySize.y);
236        final int minSmallestWidthCascading = mContext.getResources().getDimensionPixelSize(
237                R.dimen.abc_cascading_menus_min_smallest_width);
238        final boolean enableCascadingSubmenus = smallestWidth >= minSmallestWidthCascading;
239
240        final MenuPopup popup;
241        if (enableCascadingSubmenus) {
242            popup = new CascadingMenuPopup(mContext, mAnchorView, mPopupStyleAttr,
243                    mPopupStyleRes, mOverflowOnly);
244        } else {
245            popup = new StandardMenuPopup(mContext, mMenu, mAnchorView, mPopupStyleAttr,
246                    mPopupStyleRes, mOverflowOnly);
247        }
248
249        // Assign immutable properties.
250        popup.addMenu(mMenu);
251        popup.setOnDismissListener(mInternalOnDismissListener);
252
253        // Assign mutable properties. These may be reassigned later.
254        popup.setAnchorView(mAnchorView);
255        popup.setCallback(mPresenterCallback);
256        popup.setForceShowIcon(mForceShowIcon);
257        popup.setGravity(mDropDownGravity);
258
259        return popup;
260    }
261
262    private void showPopup(int xOffset, int yOffset, boolean useOffsets, boolean showTitle) {
263        final MenuPopup popup = getPopup();
264        popup.setShowTitle(showTitle);
265
266        if (useOffsets) {
267            // If the resolved drop-down gravity is RIGHT, the popup's right
268            // edge will be aligned with the anchor view. Adjust by the anchor
269            // width such that the top-right corner is at the X offset.
270            final int hgrav = GravityCompat.getAbsoluteGravity(mDropDownGravity,
271                    ViewCompat.getLayoutDirection(mAnchorView)) & Gravity.HORIZONTAL_GRAVITY_MASK;
272            if (hgrav == Gravity.RIGHT) {
273                xOffset -= mAnchorView.getWidth();
274            }
275
276            popup.setHorizontalOffset(xOffset);
277            popup.setVerticalOffset(yOffset);
278
279            // Set the transition epicenter to be roughly finger (or mouse
280            // cursor) sized and centered around the offset position. This
281            // will give the appearance that the window is emerging from
282            // the touch point.
283            final float density = mContext.getResources().getDisplayMetrics().density;
284            final int halfSize = (int) (TOUCH_EPICENTER_SIZE_DP * density / 2);
285            final Rect epicenter = new Rect(xOffset - halfSize, yOffset - halfSize,
286                    xOffset + halfSize, yOffset + halfSize);
287            popup.setEpicenterBounds(epicenter);
288        }
289
290        popup.show();
291    }
292
293    /**
294     * Dismisses the popup, if showing.
295     */
296    @Override
297    public void dismiss() {
298        if (isShowing()) {
299            mPopup.dismiss();
300        }
301    }
302
303    /**
304     * Called after the popup has been dismissed.
305     * <p>
306     * <strong>Note:</strong> Subclasses should call the super implementation
307     * last to ensure that any necessary tear down has occurred before the
308     * listener specified by {@link #setOnDismissListener(OnDismissListener)}
309     * is called.
310     */
311    protected void onDismiss() {
312        mPopup = null;
313
314        if (mOnDismissListener != null) {
315            mOnDismissListener.onDismiss();
316        }
317    }
318
319    public boolean isShowing() {
320        return mPopup != null && mPopup.isShowing();
321    }
322
323    @Override
324    public void setPresenterCallback(@Nullable MenuPresenter.Callback cb) {
325        mPresenterCallback = cb;
326        if (mPopup != null) {
327            mPopup.setCallback(cb);
328        }
329    }
330
331    /**
332     * Listener used to proxy dismiss callbacks to the helper's owner.
333     */
334    private final OnDismissListener mInternalOnDismissListener = new OnDismissListener() {
335        @Override
336        public void onDismiss() {
337            MenuPopupHelper.this.onDismiss();
338        }
339    };
340
341    /**
342     * API to get the underlying ListView of the Popup
343     */
344    public ListView getListView() {
345        return getPopup().getListView();
346    }
347}
348