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