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 android.support.v7.view.menu;
18
19import static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP;
20
21import android.content.Context;
22import android.graphics.Point;
23import android.graphics.Rect;
24import android.os.Build;
25import android.support.annotation.AttrRes;
26import android.support.annotation.NonNull;
27import android.support.annotation.Nullable;
28import android.support.annotation.RestrictTo;
29import android.support.annotation.StyleRes;
30import android.support.v4.view.GravityCompat;
31import android.support.v4.view.ViewCompat;
32import android.support.v7.appcompat.R;
33import android.view.Display;
34import android.view.Gravity;
35import android.view.View;
36import android.view.WindowManager;
37import android.widget.PopupWindow.OnDismissListener;
38
39/**
40 * Presents a menu as a small, simple popup anchored to another view.
41 *
42 * @hide
43 */
44@RestrictTo(LIBRARY_GROUP)
45public class MenuPopupHelper implements MenuHelper {
46    private static final int TOUCH_EPICENTER_SIZE_DP = 48;
47
48    private final Context mContext;
49
50    // Immutable cached popup menu properties.
51    private final MenuBuilder mMenu;
52    private final boolean mOverflowOnly;
53    private final int mPopupStyleAttr;
54    private final int mPopupStyleRes;
55
56    // Mutable cached popup menu properties.
57    private View mAnchorView;
58    private int mDropDownGravity = Gravity.START;
59    private boolean mForceShowIcon;
60    private MenuPresenter.Callback mPresenterCallback;
61
62    private MenuPopup mPopup;
63    private OnDismissListener mOnDismissListener;
64
65    public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu) {
66        this(context, menu, null, false, R.attr.popupMenuStyle, 0);
67    }
68
69    public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu,
70            @NonNull View anchorView) {
71        this(context, menu, anchorView, false, R.attr.popupMenuStyle, 0);
72    }
73
74    public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu,
75            @NonNull View anchorView,
76            boolean overflowOnly, @AttrRes int popupStyleAttr) {
77        this(context, menu, anchorView, overflowOnly, popupStyleAttr, 0);
78    }
79
80    public MenuPopupHelper(@NonNull Context context, @NonNull MenuBuilder menu,
81            @NonNull View anchorView, boolean overflowOnly, @AttrRes int popupStyleAttr,
82            @StyleRes int popupStyleRes) {
83        mContext = context;
84        mMenu = menu;
85        mAnchorView = anchorView;
86        mOverflowOnly = overflowOnly;
87        mPopupStyleAttr = popupStyleAttr;
88        mPopupStyleRes = popupStyleRes;
89    }
90
91    public void setOnDismissListener(@Nullable OnDismissListener listener) {
92        mOnDismissListener = listener;
93    }
94
95    /**
96      * Sets the view to which the popup window is anchored.
97      * <p>
98      * Changes take effect on the next call to show().
99      *
100      * @param anchor the view to which the popup window should be anchored
101      */
102    public void setAnchorView(@NonNull View anchor) {
103        mAnchorView = anchor;
104    }
105
106    /**
107     * Sets whether the popup menu's adapter is forced to show icons in the
108     * menu item views.
109     * <p>
110     * Changes take effect on the next call to show().
111     *
112     * @param forceShowIcon {@code true} to force icons to be shown, or
113     *                  {@code false} for icons to be optionally shown
114     */
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    public void setGravity(int gravity) {
130        mDropDownGravity = gravity;
131    }
132
133    /**
134     * @return alignment of the popup relative to the anchor
135     */
136    public int getGravity() {
137        return mDropDownGravity;
138    }
139
140    public void show() {
141        if (!tryShow()) {
142            throw new IllegalStateException("MenuPopupHelper cannot be used without an anchor");
143        }
144    }
145
146    public void show(int x, int y) {
147        if (!tryShow(x, y)) {
148            throw new IllegalStateException("MenuPopupHelper cannot be used without an anchor");
149        }
150    }
151
152    @NonNull
153    public MenuPopup getPopup() {
154        if (mPopup == null) {
155            mPopup = createPopup();
156        }
157        return mPopup;
158    }
159
160    /**
161     * Attempts to show the popup anchored to the view specified by {@link #setAnchorView(View)}.
162     *
163     * @return {@code true} if the popup was shown or was already showing prior to calling this
164     *         method, {@code false} otherwise
165     */
166    public boolean tryShow() {
167        if (isShowing()) {
168            return true;
169        }
170
171        if (mAnchorView == null) {
172            return false;
173        }
174
175        showPopup(0, 0, false, false);
176        return true;
177    }
178
179    /**
180     * Shows the popup menu and makes a best-effort to anchor it to the
181     * specified (x,y) coordinate relative to the anchor view.
182     * <p>
183     * Additionally, the popup's transition epicenter (see
184     * {@link android.widget.PopupWindow#setEpicenterBounds(Rect)} will be
185     * centered on the specified coordinate, rather than using the bounds of
186     * the anchor view.
187     * <p>
188     * If the popup's resolved gravity is {@link Gravity#LEFT}, this will
189     * display the popup with its top-left corner at (x,y) relative to the
190     * anchor view. If the resolved gravity is {@link Gravity#RIGHT}, the
191     * popup's top-right corner will be at (x,y).
192     * <p>
193     * If the popup cannot be displayed fully on-screen, this method will
194     * attempt to scroll the anchor view's ancestors and/or offset the popup
195     * such that it may be displayed fully on-screen.
196     *
197     * @param x x coordinate relative to the anchor view
198     * @param y y coordinate relative to the anchor view
199     * @return {@code true} if the popup was shown or was already showing prior
200     *         to calling this method, {@code false} otherwise
201     */
202    public boolean tryShow(int x, int y) {
203        if (isShowing()) {
204            return true;
205        }
206
207        if (mAnchorView == null) {
208            return false;
209        }
210
211        showPopup(x, y, true, true);
212        return true;
213    }
214
215    /**
216     * Creates the popup and assigns cached properties.
217     *
218     * @return an initialized popup
219     */
220    @NonNull
221    private MenuPopup createPopup() {
222        final WindowManager windowManager = (WindowManager) mContext.getSystemService(
223                Context.WINDOW_SERVICE);
224        final Display display = windowManager.getDefaultDisplay();
225        final Point displaySize = new Point();
226
227        if (Build.VERSION.SDK_INT >= 17) {
228            display.getRealSize(displaySize);
229        } else {
230            display.getSize(displaySize);
231        }
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
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                    ViewCompat.getLayoutDirection(mAnchorView)) & 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
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     */
309    protected void onDismiss() {
310        mPopup = null;
311
312        if (mOnDismissListener != null) {
313            mOnDismissListener.onDismiss();
314        }
315    }
316
317    public boolean isShowing() {
318        return mPopup != null && mPopup.isShowing();
319    }
320
321    @Override
322    public void setPresenterCallback(@Nullable MenuPresenter.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