ZoomButtonsController.java revision c39a6e0c51e182338deb8b63d07933b585134929
1/*
2 * Copyright (C) 2008 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.widget;
18
19import android.app.AlertDialog;
20import android.app.Dialog;
21import android.content.BroadcastReceiver;
22import android.content.ContentResolver;
23import android.content.Context;
24import android.content.Intent;
25import android.content.IntentFilter;
26import android.content.SharedPreferences;
27import android.graphics.PixelFormat;
28import android.graphics.Rect;
29import android.os.Handler;
30import android.os.Message;
31import android.os.SystemClock;
32import android.provider.Settings;
33import android.view.Gravity;
34import android.view.KeyEvent;
35import android.view.LayoutInflater;
36import android.view.MotionEvent;
37import android.view.View;
38import android.view.ViewConfiguration;
39import android.view.ViewGroup;
40import android.view.Window;
41import android.view.WindowManager;
42import android.view.View.OnClickListener;
43import android.view.WindowManager.LayoutParams;
44
45// TODO: make sure no px values exist, only dip (scale if necessary from Viewconfiguration)
46
47/**
48 * TODO: Docs
49 *
50 * If you are using this with a custom View, please call
51 * {@link #setVisible(boolean) setVisible(false)} from the
52 * {@link View#onDetachedFromWindow}.
53 *
54 * @hide
55 */
56public class ZoomButtonsController implements View.OnTouchListener, View.OnKeyListener {
57
58    private static final String TAG = "ZoomButtonsController";
59
60    private static final int ZOOM_CONTROLS_TIMEOUT =
61            (int) ViewConfiguration.getZoomControlsTimeout();
62
63    // TODO: scaled to density
64    private static final int ZOOM_CONTROLS_TOUCH_PADDING = 20;
65
66    private Context mContext;
67    private WindowManager mWindowManager;
68
69    /**
70     * The view that is being zoomed by this zoom controller.
71     */
72    private View mOwnerView;
73
74    /**
75     * The bounds of the owner view in global coordinates. This is recalculated
76     * each time the zoom controller is shown.
77     */
78    private Rect mOwnerViewBounds = new Rect();
79
80    /**
81     * The container that is added as a window.
82     */
83    private FrameLayout mContainer;
84    private LayoutParams mContainerLayoutParams;
85    private int[] mContainerLocation = new int[2];
86
87    private ZoomControls mControls;
88
89    /**
90     * The view (or null) that should receive touch events. This will get set if
91     * the touch down hits the container. It will be reset on the touch up.
92     */
93    private View mTouchTargetView;
94    /**
95     * The {@link #mTouchTargetView}'s location in window, set on touch down.
96     */
97    private int[] mTouchTargetLocationInWindow = new int[2];
98    /**
99     * If the zoom controller is dismissed but the user is still in a touch
100     * interaction, we set this to true. This will ignore all touch events until
101     * up/cancel, and then set the owner's touch listener to null.
102     */
103    private boolean mReleaseTouchListenerOnUp;
104
105    private boolean mIsSecondTapDown;
106
107    private boolean mIsVisible;
108
109    private Rect mTempRect = new Rect();
110
111    private OnZoomListener mCallback;
112
113    /**
114     * When showing the zoom, we add the view as a new window. However, there is
115     * logic that needs to know the size of the zoom which is determined after
116     * it's laid out. Therefore, we must post this logic onto the UI thread so
117     * it will be exceuted AFTER the layout. This is the logic.
118     */
119    private Runnable mPostedVisibleInitializer;
120
121    private IntentFilter mConfigurationChangedFilter =
122            new IntentFilter(Intent.ACTION_CONFIGURATION_CHANGED);
123
124    private BroadcastReceiver mConfigurationChangedReceiver = new BroadcastReceiver() {
125        @Override
126        public void onReceive(Context context, Intent intent) {
127            if (!mIsVisible) return;
128
129            mHandler.removeMessages(MSG_POST_CONFIGURATION_CHANGED);
130            mHandler.sendEmptyMessage(MSG_POST_CONFIGURATION_CHANGED);
131        }
132    };
133
134    /**
135     * The setting name that tracks whether we've shown the zoom tutorial.
136     */
137    private static final String SETTING_NAME_SHOWN_TUTORIAL = "shown_zoom_tutorial";
138    private static Dialog sTutorialDialog;
139
140    /** When configuration changes, this is called after the UI thread is idle. */
141    private static final int MSG_POST_CONFIGURATION_CHANGED = 2;
142    /** Used to delay the zoom controller dismissal. */
143    private static final int MSG_DISMISS_ZOOM_CONTROLS = 3;
144    /**
145     * If setVisible(true) is called and the owner view's window token is null,
146     * we delay the setVisible(true) call until it is not null.
147     */
148    private static final int MSG_POST_SET_VISIBLE = 4;
149
150    private Handler mHandler = new Handler() {
151        @Override
152        public void handleMessage(Message msg) {
153            switch (msg.what) {
154                case MSG_POST_CONFIGURATION_CHANGED:
155                    onPostConfigurationChanged();
156                    break;
157
158                case MSG_DISMISS_ZOOM_CONTROLS:
159                    setVisible(false);
160                    break;
161
162                case MSG_POST_SET_VISIBLE:
163                    if (mOwnerView.getWindowToken() == null) {
164                        // Doh, it is still null, throw an exception
165                        throw new IllegalArgumentException(
166                                "Cannot make the zoom controller visible if the owner view is " +
167                                "not attached to a window.");
168                    }
169                    setVisible(true);
170                    break;
171            }
172
173        }
174    };
175
176    public ZoomButtonsController(Context context, View ownerView) {
177        mContext = context;
178        mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
179        mOwnerView = ownerView;
180
181        mContainer = createContainer();
182    }
183
184    public void setZoomInEnabled(boolean enabled) {
185        mControls.setIsZoomInEnabled(enabled);
186    }
187
188    public void setZoomOutEnabled(boolean enabled) {
189        mControls.setIsZoomOutEnabled(enabled);
190    }
191
192    public void setZoomSpeed(long speed) {
193        mControls.setZoomSpeed(speed);
194    }
195
196    private FrameLayout createContainer() {
197        LayoutParams lp = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
198        lp.gravity = Gravity.BOTTOM | Gravity.CENTER;
199        lp.flags = LayoutParams.FLAG_NOT_TOUCHABLE |
200                LayoutParams.FLAG_NOT_FOCUSABLE |
201                LayoutParams.FLAG_LAYOUT_NO_LIMITS;
202        lp.height = LayoutParams.WRAP_CONTENT;
203        lp.width = LayoutParams.FILL_PARENT;
204        lp.type = LayoutParams.TYPE_APPLICATION_PANEL;
205        lp.format = PixelFormat.TRANSPARENT;
206        lp.windowAnimations = com.android.internal.R.style.Animation_ZoomButtons;
207        mContainerLayoutParams = lp;
208
209        FrameLayout container = new FrameLayout(mContext);
210        container.setLayoutParams(lp);
211        container.setMeasureAllChildren(true);
212        container.setOnKeyListener(this);
213
214        LayoutInflater inflater = (LayoutInflater) mContext
215                .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
216        inflater.inflate(com.android.internal.R.layout.zoom_container, container);
217
218        mControls = (ZoomControls) container.findViewById(com.android.internal.R.id.zoomControls);
219        mControls.setOnZoomInClickListener(new OnClickListener() {
220            public void onClick(View v) {
221                dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT);
222                if (mCallback != null) mCallback.onZoom(true);
223            }
224        });
225        mControls.setOnZoomOutClickListener(new OnClickListener() {
226            public void onClick(View v) {
227                dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT);
228                if (mCallback != null) mCallback.onZoom(false);
229            }
230        });
231
232        return container;
233    }
234
235    public void setCallback(OnZoomListener callback) {
236        mCallback = callback;
237    }
238
239    public void setFocusable(boolean focusable) {
240        if (focusable) {
241            mContainerLayoutParams.flags &= ~LayoutParams.FLAG_NOT_FOCUSABLE;
242        } else {
243            mContainerLayoutParams.flags |= LayoutParams.FLAG_NOT_FOCUSABLE;
244        }
245
246        if (mIsVisible) {
247            mWindowManager.updateViewLayout(mContainer, mContainerLayoutParams);
248        }
249    }
250
251    public boolean isVisible() {
252        return mIsVisible;
253    }
254
255    public void setVisible(boolean visible) {
256
257        if (!useThisZoom(mContext)) return;
258
259        if (visible) {
260            if (mOwnerView.getWindowToken() == null) {
261                /*
262                 * We need a window token to show ourselves, maybe the owner's
263                 * window hasn't been created yet but it will have been by the
264                 * time the looper is idle, so post the setVisible(true) call.
265                 */
266                if (!mHandler.hasMessages(MSG_POST_SET_VISIBLE)) {
267                    mHandler.sendEmptyMessage(MSG_POST_SET_VISIBLE);
268                }
269                return;
270            }
271
272            dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT);
273        }
274
275        if (mIsVisible == visible) {
276            return;
277        }
278        mIsVisible = visible;
279
280        if (visible) {
281            if (mContainerLayoutParams.token == null) {
282                mContainerLayoutParams.token = mOwnerView.getWindowToken();
283            }
284
285            mWindowManager.addView(mContainer, mContainerLayoutParams);
286
287            if (mPostedVisibleInitializer == null) {
288                mPostedVisibleInitializer = new Runnable() {
289                    public void run() {
290                        refreshPositioningVariables();
291
292                        if (mCallback != null) {
293                            mCallback.onVisibilityChanged(true);
294                        }
295                    }
296                };
297            }
298
299            mHandler.post(mPostedVisibleInitializer);
300
301            // Handle configuration changes when visible
302            mContext.registerReceiver(mConfigurationChangedReceiver, mConfigurationChangedFilter);
303
304            // Steal touches events from the owner
305            mOwnerView.setOnTouchListener(this);
306            mReleaseTouchListenerOnUp = false;
307
308        } else {
309            // Don't want to steal any more touches
310            if (mTouchTargetView != null) {
311                // We are still stealing the touch events for this touch
312                // sequence, so release the touch listener later
313                mReleaseTouchListenerOnUp = true;
314            } else {
315                mOwnerView.setOnTouchListener(null);
316            }
317
318            // No longer care about configuration changes
319            mContext.unregisterReceiver(mConfigurationChangedReceiver);
320
321            mWindowManager.removeView(mContainer);
322            mHandler.removeCallbacks(mPostedVisibleInitializer);
323
324            if (mCallback != null) {
325                mCallback.onVisibilityChanged(false);
326            }
327        }
328
329    }
330
331    /**
332     * TODO: docs
333     *
334     * Notes:
335     * - Please ensure you set your View to INVISIBLE not GONE when hiding it.
336     *
337     * @return TODO
338     */
339    public ViewGroup getContainer() {
340        return mContainer;
341    }
342
343    private void dismissControlsDelayed(int delay) {
344        mHandler.removeMessages(MSG_DISMISS_ZOOM_CONTROLS);
345        mHandler.sendEmptyMessageDelayed(MSG_DISMISS_ZOOM_CONTROLS, delay);
346    }
347
348    /**
349     * Should be called by the client for each event belonging to the second tap
350     * (the down, move, up, and cancel events).
351     *
352     * @param event The event belonging to the second tap.
353     * @return Whether the event was consumed.
354     */
355    public boolean handleDoubleTapEvent(MotionEvent event) {
356        if (!useThisZoom(mContext)) return false;
357
358        int action = event.getAction();
359
360        if (action == MotionEvent.ACTION_DOWN) {
361            int x = (int) event.getX();
362            int y = (int) event.getY();
363
364            /*
365             * This class will consume all events in the second tap (down,
366             * move(s), up). But, the owner already got the second tap's down,
367             * so cancel that. Do this before setVisible, since that call
368             * will set us as a touch listener.
369             */
370            MotionEvent cancelEvent = MotionEvent.obtain(event.getDownTime(),
371                    SystemClock.elapsedRealtime(),
372                    MotionEvent.ACTION_CANCEL, 0, 0, 0);
373            mOwnerView.dispatchTouchEvent(cancelEvent);
374            cancelEvent.recycle();
375
376            setVisible(true);
377            centerPoint(x, y);
378            mIsSecondTapDown = true;
379        }
380
381        return true;
382    }
383
384    private void refreshPositioningVariables() {
385        // Calculate the owner view's bounds
386        mOwnerView.getGlobalVisibleRect(mOwnerViewBounds);
387        mContainer.getLocationOnScreen(mContainerLocation);
388    }
389
390    /**
391     * Centers the point (in owner view's coordinates).
392     */
393    private void centerPoint(int x, int y) {
394        if (mCallback != null) {
395            mCallback.onCenter(x, y);
396        }
397    }
398
399    public boolean onKey(View v, int keyCode, KeyEvent event) {
400        dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT);
401        return false;
402    }
403
404    public boolean onTouch(View v, MotionEvent event) {
405        int action = event.getAction();
406
407        // Consume all events during the second-tap interaction (down, move, up/cancel)
408        boolean consumeEvent = mIsSecondTapDown;
409        if ((action == MotionEvent.ACTION_UP) || (action == MotionEvent.ACTION_CANCEL)) {
410            // The second tap can no longer be down
411            mIsSecondTapDown = false;
412        }
413
414        if (mReleaseTouchListenerOnUp) {
415            // The controls were dismissed but we need to throw away all events until the up
416            if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
417                mOwnerView.setOnTouchListener(null);
418                setTouchTargetView(null);
419                mReleaseTouchListenerOnUp = false;
420            }
421
422            // Eat this event
423            return true;
424        }
425
426        // TODO: optimize this (it ends up removing message and queuing another)
427        dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT);
428
429        View targetView = mTouchTargetView;
430
431        switch (action) {
432            case MotionEvent.ACTION_DOWN:
433                targetView = getViewForTouch((int) event.getRawX(), (int) event.getRawY());
434                setTouchTargetView(targetView);
435                break;
436
437            case MotionEvent.ACTION_UP:
438            case MotionEvent.ACTION_CANCEL:
439                setTouchTargetView(null);
440                break;
441        }
442
443        if (targetView != null) {
444            // The upperleft corner of the target view in raw coordinates
445            int targetViewRawX = mContainerLocation[0] + mTouchTargetLocationInWindow[0];
446            int targetViewRawY = mContainerLocation[1] + mTouchTargetLocationInWindow[1];
447
448            MotionEvent containerEvent = MotionEvent.obtain(event);
449            // Convert the motion event into the target view's coordinates (from
450            // owner view's coordinates)
451            containerEvent.offsetLocation(mOwnerViewBounds.left - targetViewRawX,
452                    mOwnerViewBounds.top - targetViewRawY);
453            boolean retValue = targetView.dispatchTouchEvent(containerEvent);
454            containerEvent.recycle();
455            return retValue || consumeEvent;
456
457        } else {
458            return consumeEvent;
459        }
460    }
461
462    private void setTouchTargetView(View view) {
463        mTouchTargetView = view;
464        if (view != null) {
465            view.getLocationInWindow(mTouchTargetLocationInWindow);
466        }
467    }
468
469    /**
470     * Returns the View that should receive a touch at the given coordinates.
471     *
472     * @param rawX The raw X.
473     * @param rawY The raw Y.
474     * @return The view that should receive the touches, or null if there is not one.
475     */
476    private View getViewForTouch(int rawX, int rawY) {
477        // Reverse order so the child drawn on top gets first dibs.
478        int containerCoordsX = rawX - mContainerLocation[0];
479        int containerCoordsY = rawY - mContainerLocation[1];
480        Rect frame = mTempRect;
481        for (int i = mContainer.getChildCount() - 1; i >= 0; i--) {
482            View child = mContainer.getChildAt(i);
483            if (child.getVisibility() != View.VISIBLE) {
484                continue;
485            }
486
487            child.getHitRect(frame);
488            // Expand the touch region
489            frame.top -= ZOOM_CONTROLS_TOUCH_PADDING;
490            if (frame.contains(containerCoordsX, containerCoordsY)) {
491                return child;
492            }
493        }
494
495        return null;
496    }
497
498    private void onPostConfigurationChanged() {
499        dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT);
500        refreshPositioningVariables();
501    }
502
503    /*
504     * This is static so Activities can call this instead of the Views
505     * (Activities usually do not have a reference to the ZoomButtonsController
506     * instance.)
507     */
508    /**
509     * Shows a "tutorial" (some text) to the user teaching her the new zoom
510     * invocation method. Must call from the main thread.
511     * <p>
512     * It checks the global system setting to ensure this has not been seen
513     * before. Furthermore, if the application does not have privilege to write
514     * to the system settings, it will store this bit locally in a shared
515     * preference.
516     *
517     * @hide This should only be used by our main apps--browser, maps, and
518     *       gallery
519     */
520    public static void showZoomTutorialOnce(Context context) {
521        ContentResolver cr = context.getContentResolver();
522        if (Settings.System.getInt(cr, SETTING_NAME_SHOWN_TUTORIAL, 0) == 1) {
523            return;
524        }
525
526        SharedPreferences sp = context.getSharedPreferences("_zoom", Context.MODE_PRIVATE);
527        if (sp.getInt(SETTING_NAME_SHOWN_TUTORIAL, 0) == 1) {
528            return;
529        }
530
531        if (sTutorialDialog != null && sTutorialDialog.isShowing()) {
532            sTutorialDialog.dismiss();
533        }
534
535        LayoutInflater layoutInflater =
536                (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
537        TextView textView = (TextView) layoutInflater.inflate(
538                com.android.internal.R.layout.alert_dialog_simple_text, null)
539                .findViewById(android.R.id.text1);
540        textView.setText(com.android.internal.R.string.tutorial_double_tap_to_zoom_message_short);
541
542        sTutorialDialog = new AlertDialog.Builder(context)
543                .setView(textView)
544                .setIcon(0)
545                .create();
546
547        Window window = sTutorialDialog.getWindow();
548        window.setGravity(Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM);
549        window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND |
550                WindowManager.LayoutParams.FLAG_BLUR_BEHIND);
551        window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE |
552                WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
553
554        sTutorialDialog.show();
555    }
556
557    /** @hide Should only be used by Android platform apps */
558    public static void finishZoomTutorial(Context context, boolean userNotified) {
559        if (sTutorialDialog == null) return;
560
561        sTutorialDialog.dismiss();
562        sTutorialDialog = null;
563
564        // Record that they have seen the tutorial
565        if (userNotified) {
566            try {
567                Settings.System.putInt(context.getContentResolver(), SETTING_NAME_SHOWN_TUTORIAL,
568                        1);
569            } catch (SecurityException e) {
570                /*
571                 * The app does not have permission to clear this global flag, make
572                 * sure the user does not see the message when he comes back to this
573                 * same app at least.
574                 */
575                SharedPreferences sp = context.getSharedPreferences("_zoom", Context.MODE_PRIVATE);
576                sp.edit().putInt(SETTING_NAME_SHOWN_TUTORIAL, 1).commit();
577            }
578        }
579    }
580
581    /** @hide Should only be used by Android platform apps */
582    public void finishZoomTutorial() {
583        finishZoomTutorial(mContext, true);
584    }
585
586    // Temporary methods for different zoom types
587    static int getZoomType(Context context) {
588        return Settings.System.getInt(context.getContentResolver(), "zoom", 2);
589    }
590
591    public static boolean useOldZoom(Context context) {
592        return getZoomType(context) == 0;
593    }
594
595    public static boolean useThisZoom(Context context) {
596        return getZoomType(context) == 2;
597    }
598
599    public interface OnZoomListener {
600        void onCenter(int x, int y);
601        void onVisibilityChanged(boolean visible);
602        void onZoom(boolean zoomIn);
603    }
604}
605