1/*
2 * Copyright (C) 2015 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;
18
19import android.annotation.NonNull;
20import android.annotation.Nullable;
21import android.content.Context;
22import android.graphics.Point;
23import android.graphics.Rect;
24import android.view.ActionMode;
25import android.view.Menu;
26import android.view.MenuInflater;
27import android.view.MenuItem;
28import android.view.View;
29import android.view.ViewConfiguration;
30import android.view.ViewGroup;
31import android.view.ViewParent;
32import android.view.WindowManager;
33
34import android.widget.PopupWindow;
35import com.android.internal.R;
36import com.android.internal.util.Preconditions;
37import com.android.internal.view.menu.MenuBuilder;
38import com.android.internal.widget.FloatingToolbar;
39
40import java.util.Arrays;
41
42public final class FloatingActionMode extends ActionMode {
43
44    private static final int MAX_HIDE_DURATION = 3000;
45    private static final int MOVING_HIDE_DELAY = 50;
46
47    @NonNull private final Context mContext;
48    @NonNull private final ActionMode.Callback2 mCallback;
49    @NonNull private final MenuBuilder mMenu;
50    @NonNull private final Rect mContentRect;
51    @NonNull private final Rect mContentRectOnScreen;
52    @NonNull private final Rect mPreviousContentRectOnScreen;
53    @NonNull private final int[] mViewPositionOnScreen;
54    @NonNull private final int[] mPreviousViewPositionOnScreen;
55    @NonNull private final int[] mRootViewPositionOnScreen;
56    @NonNull private final Rect mViewRectOnScreen;
57    @NonNull private final Rect mPreviousViewRectOnScreen;
58    @NonNull private final Rect mScreenRect;
59    @NonNull private final View mOriginatingView;
60    @NonNull private final Point mDisplaySize;
61    private final int mBottomAllowance;
62
63    private final Runnable mMovingOff = new Runnable() {
64        public void run() {
65            if (isViewStillActive()) {
66                mFloatingToolbarVisibilityHelper.setMoving(false);
67                mFloatingToolbarVisibilityHelper.updateToolbarVisibility();
68            }
69        }
70    };
71
72    private final Runnable mHideOff = new Runnable() {
73        public void run() {
74            if (isViewStillActive()) {
75                mFloatingToolbarVisibilityHelper.setHideRequested(false);
76                mFloatingToolbarVisibilityHelper.updateToolbarVisibility();
77            }
78        }
79    };
80
81    @NonNull private FloatingToolbar mFloatingToolbar;
82    @NonNull private FloatingToolbarVisibilityHelper mFloatingToolbarVisibilityHelper;
83
84    public FloatingActionMode(
85            Context context, ActionMode.Callback2 callback,
86            View originatingView, FloatingToolbar floatingToolbar) {
87        mContext = Preconditions.checkNotNull(context);
88        mCallback = Preconditions.checkNotNull(callback);
89        mMenu = new MenuBuilder(context).setDefaultShowAsAction(
90                MenuItem.SHOW_AS_ACTION_IF_ROOM);
91        setType(ActionMode.TYPE_FLOATING);
92        mMenu.setCallback(new MenuBuilder.Callback() {
93            @Override
94            public void onMenuModeChange(MenuBuilder menu) {}
95
96            @Override
97            public boolean onMenuItemSelected(MenuBuilder menu, MenuItem item) {
98                return mCallback.onActionItemClicked(FloatingActionMode.this, item);
99            }
100        });
101        mContentRect = new Rect();
102        mContentRectOnScreen = new Rect();
103        mPreviousContentRectOnScreen = new Rect();
104        mViewPositionOnScreen = new int[2];
105        mPreviousViewPositionOnScreen = new int[2];
106        mRootViewPositionOnScreen = new int[2];
107        mViewRectOnScreen = new Rect();
108        mPreviousViewRectOnScreen = new Rect();
109        mScreenRect = new Rect();
110        mOriginatingView = Preconditions.checkNotNull(originatingView);
111        mOriginatingView.getLocationOnScreen(mViewPositionOnScreen);
112        // Allow the content rect to overshoot a little bit beyond the
113        // bottom view bound if necessary.
114        mBottomAllowance = context.getResources()
115                .getDimensionPixelSize(R.dimen.content_rect_bottom_clip_allowance);
116        mDisplaySize = new Point();
117        setFloatingToolbar(Preconditions.checkNotNull(floatingToolbar));
118    }
119
120    private void setFloatingToolbar(FloatingToolbar floatingToolbar) {
121        mFloatingToolbar = floatingToolbar
122                .setMenu(mMenu)
123                .setOnMenuItemClickListener(item -> mMenu.performItemAction(item, 0));
124        mFloatingToolbarVisibilityHelper = new FloatingToolbarVisibilityHelper(mFloatingToolbar);
125        mFloatingToolbarVisibilityHelper.activate();
126    }
127
128    @Override
129    public void setTitle(CharSequence title) {}
130
131    @Override
132    public void setTitle(int resId) {}
133
134    @Override
135    public void setSubtitle(CharSequence subtitle) {}
136
137    @Override
138    public void setSubtitle(int resId) {}
139
140    @Override
141    public void setCustomView(View view) {}
142
143    @Override
144    public void invalidate() {
145        mCallback.onPrepareActionMode(this, mMenu);
146        invalidateContentRect();  // Will re-layout and show the toolbar if necessary.
147    }
148
149    @Override
150    public void invalidateContentRect() {
151        mCallback.onGetContentRect(this, mOriginatingView, mContentRect);
152        repositionToolbar();
153    }
154
155    public void updateViewLocationInWindow() {
156        mOriginatingView.getLocationOnScreen(mViewPositionOnScreen);
157        mOriginatingView.getRootView().getLocationOnScreen(mRootViewPositionOnScreen);
158        mOriginatingView.getGlobalVisibleRect(mViewRectOnScreen);
159        mViewRectOnScreen.offset(mRootViewPositionOnScreen[0], mRootViewPositionOnScreen[1]);
160
161        if (!Arrays.equals(mViewPositionOnScreen, mPreviousViewPositionOnScreen)
162                || !mViewRectOnScreen.equals(mPreviousViewRectOnScreen)) {
163            repositionToolbar();
164            mPreviousViewPositionOnScreen[0] = mViewPositionOnScreen[0];
165            mPreviousViewPositionOnScreen[1] = mViewPositionOnScreen[1];
166            mPreviousViewRectOnScreen.set(mViewRectOnScreen);
167        }
168    }
169
170    private void repositionToolbar() {
171        mContentRectOnScreen.set(mContentRect);
172
173        // Offset the content rect into screen coordinates, taking into account any transformations
174        // that may be applied to the originating view or its ancestors.
175        final ViewParent parent = mOriginatingView.getParent();
176        if (parent instanceof ViewGroup) {
177            ((ViewGroup) parent).getChildVisibleRect(
178                    mOriginatingView, mContentRectOnScreen,
179                    null /* offset */, true /* forceParentCheck */);
180            mContentRectOnScreen.offset(mRootViewPositionOnScreen[0], mRootViewPositionOnScreen[1]);
181        } else {
182            mContentRectOnScreen.offset(mViewPositionOnScreen[0], mViewPositionOnScreen[1]);
183        }
184
185        if (isContentRectWithinBounds()) {
186            mFloatingToolbarVisibilityHelper.setOutOfBounds(false);
187            // Make sure that content rect is not out of the view's visible bounds.
188            mContentRectOnScreen.set(
189                    Math.max(mContentRectOnScreen.left, mViewRectOnScreen.left),
190                    Math.max(mContentRectOnScreen.top, mViewRectOnScreen.top),
191                    Math.min(mContentRectOnScreen.right, mViewRectOnScreen.right),
192                    Math.min(mContentRectOnScreen.bottom,
193                            mViewRectOnScreen.bottom + mBottomAllowance));
194
195            if (!mContentRectOnScreen.equals(mPreviousContentRectOnScreen)) {
196                // Content rect is moving.
197                mOriginatingView.removeCallbacks(mMovingOff);
198                mFloatingToolbarVisibilityHelper.setMoving(true);
199                mOriginatingView.postDelayed(mMovingOff, MOVING_HIDE_DELAY);
200
201                mFloatingToolbar.setContentRect(mContentRectOnScreen);
202                mFloatingToolbar.updateLayout();
203            }
204        } else {
205            mFloatingToolbarVisibilityHelper.setOutOfBounds(true);
206            mContentRectOnScreen.setEmpty();
207        }
208        mFloatingToolbarVisibilityHelper.updateToolbarVisibility();
209
210        mPreviousContentRectOnScreen.set(mContentRectOnScreen);
211    }
212
213    private boolean isContentRectWithinBounds() {
214        mContext.getSystemService(WindowManager.class)
215            .getDefaultDisplay().getRealSize(mDisplaySize);
216        mScreenRect.set(0, 0, mDisplaySize.x, mDisplaySize.y);
217
218        return intersectsClosed(mContentRectOnScreen, mScreenRect)
219            && intersectsClosed(mContentRectOnScreen, mViewRectOnScreen);
220    }
221
222    /*
223     * Same as Rect.intersects, but includes cases where the rectangles touch.
224    */
225    private static boolean intersectsClosed(Rect a, Rect b) {
226         return a.left <= b.right && b.left <= a.right
227                 && a.top <= b.bottom && b.top <= a.bottom;
228    }
229
230    @Override
231    public void hide(long duration) {
232        if (duration == ActionMode.DEFAULT_HIDE_DURATION) {
233            duration = ViewConfiguration.getDefaultActionModeHideDuration();
234        }
235        duration = Math.min(MAX_HIDE_DURATION, duration);
236        mOriginatingView.removeCallbacks(mHideOff);
237        if (duration <= 0) {
238            mHideOff.run();
239        } else {
240            mFloatingToolbarVisibilityHelper.setHideRequested(true);
241            mFloatingToolbarVisibilityHelper.updateToolbarVisibility();
242            mOriginatingView.postDelayed(mHideOff, duration);
243        }
244    }
245
246    /**
247     * If this is set to true, the action mode view will dismiss itself on touch events outside of
248     * its window. This only makes sense if the action mode view is a PopupWindow that is touchable
249     * but not focusable, which means touches outside of the window will be delivered to the window
250     * behind. The default is false.
251     *
252     * This is for internal use only and the approach to this may change.
253     * @hide
254     *
255     * @param outsideTouchable whether or not this action mode is "outside touchable"
256     * @param onDismiss optional. Sets a callback for when this action mode popup dismisses itself
257     */
258    public void setOutsideTouchable(
259            boolean outsideTouchable, @Nullable PopupWindow.OnDismissListener onDismiss) {
260        mFloatingToolbar.setOutsideTouchable(outsideTouchable, onDismiss);
261    }
262
263    @Override
264    public void onWindowFocusChanged(boolean hasWindowFocus) {
265        mFloatingToolbarVisibilityHelper.setWindowFocused(hasWindowFocus);
266        mFloatingToolbarVisibilityHelper.updateToolbarVisibility();
267    }
268
269    @Override
270    public void finish() {
271        reset();
272        mCallback.onDestroyActionMode(this);
273    }
274
275    @Override
276    public Menu getMenu() {
277        return mMenu;
278    }
279
280    @Override
281    public CharSequence getTitle() {
282        return null;
283    }
284
285    @Override
286    public CharSequence getSubtitle() {
287        return null;
288    }
289
290    @Override
291    public View getCustomView() {
292        return null;
293    }
294
295    @Override
296    public MenuInflater getMenuInflater() {
297        return new MenuInflater(mContext);
298    }
299
300    private void reset() {
301        mFloatingToolbar.dismiss();
302        mFloatingToolbarVisibilityHelper.deactivate();
303        mOriginatingView.removeCallbacks(mMovingOff);
304        mOriginatingView.removeCallbacks(mHideOff);
305    }
306
307    private boolean isViewStillActive() {
308        return mOriginatingView.getWindowVisibility() == View.VISIBLE
309                && mOriginatingView.isShown();
310    }
311
312    /**
313     * A helper for showing/hiding the floating toolbar depending on certain states.
314     */
315    private static final class FloatingToolbarVisibilityHelper {
316
317        private static final long MIN_SHOW_DURATION_FOR_MOVE_HIDE = 500;
318
319        private final FloatingToolbar mToolbar;
320
321        private boolean mHideRequested;
322        private boolean mMoving;
323        private boolean mOutOfBounds;
324        private boolean mWindowFocused = true;
325
326        private boolean mActive;
327
328        private long mLastShowTime;
329
330        public FloatingToolbarVisibilityHelper(FloatingToolbar toolbar) {
331            mToolbar = Preconditions.checkNotNull(toolbar);
332        }
333
334        public void activate() {
335            mHideRequested = false;
336            mMoving = false;
337            mOutOfBounds = false;
338            mWindowFocused = true;
339
340            mActive = true;
341        }
342
343        public void deactivate() {
344            mActive = false;
345            mToolbar.dismiss();
346        }
347
348        public void setHideRequested(boolean hide) {
349            mHideRequested = hide;
350        }
351
352        public void setMoving(boolean moving) {
353            // Avoid unintended flickering by allowing the toolbar to show long enough before
354            // triggering the 'moving' flag - which signals a hide.
355            final boolean showingLongEnough =
356                System.currentTimeMillis() - mLastShowTime > MIN_SHOW_DURATION_FOR_MOVE_HIDE;
357            if (!moving || showingLongEnough) {
358                mMoving = moving;
359            }
360        }
361
362        public void setOutOfBounds(boolean outOfBounds) {
363            mOutOfBounds = outOfBounds;
364        }
365
366        public void setWindowFocused(boolean windowFocused) {
367            mWindowFocused = windowFocused;
368        }
369
370        public void updateToolbarVisibility() {
371            if (!mActive) {
372                return;
373            }
374
375            if (mHideRequested || mMoving || mOutOfBounds || !mWindowFocused) {
376                mToolbar.hide();
377            } else {
378                mToolbar.show();
379                mLastShowTime = System.currentTimeMillis();
380            }
381        }
382    }
383}
384