SlidingTab.java revision 24ccf3c59077d81b0dd3b314822ff7dab215c165
1/*
2 * Copyright (C) 2009 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.widget;
18
19import android.content.Context;
20import android.content.res.Resources;
21import android.content.res.TypedArray;
22import android.graphics.Rect;
23import android.os.Handler;
24import android.os.Message;
25import android.os.Vibrator;
26import android.util.AttributeSet;
27import android.util.Log;
28import android.view.Gravity;
29import android.view.MotionEvent;
30import android.view.View;
31import android.view.ViewGroup;
32import android.widget.ImageView;
33import android.widget.TextView;
34import android.widget.ImageView.ScaleType;
35import com.android.internal.R;
36
37/**
38 * A special widget containing two Sliders and a threshold for each.  Moving either slider beyond
39 * the threshold will cause the registered OnTriggerListener.onTrigger() to be called with
40 * {@link OnTriggerListener#LEFT_HANDLE} or {@link OnTriggerListener#RIGHT_HANDLE} to be called.
41 *
42 */
43public class SlidingTab extends ViewGroup {
44    private static final String LOG_TAG = "SlidingTab";
45    private static final boolean DBG = false;
46    private static final int HORIZONTAL = 0; // as defined in attrs.xml
47    private static final int VERTICAL = 1;
48    private static final int MSG_ANIMATE = 100;
49
50    // TODO: Make these configurable
51    private static final float TARGET_ZONE = 2.0f / 3.0f;
52    private static final long VIBRATE_SHORT = 30;
53    private static final long VIBRATE_LONG = 40;
54
55    private OnTriggerListener mOnTriggerListener;
56    private int mGrabbedState = OnTriggerListener.NO_HANDLE;
57    private boolean mTriggered = false;
58    private Vibrator mVibrator;
59    private float mDensity; // used to scale dimensions for bitmaps.
60
61    private final SlidingTabHandler mHandler = new SlidingTabHandler();
62
63    /**
64     * Either {@link #HORIZONTAL} or {@link #VERTICAL}.
65     */
66    private int mOrientation;
67
68    private Slider mLeftSlider;
69    private Slider mRightSlider;
70    private Slider mCurrentSlider;
71    private boolean mTracking;
72    private float mTargetZone;
73    private Slider mOtherSlider;
74    private boolean mAnimating;
75
76    /**
77     * Interface definition for a callback to be invoked when a tab is triggered
78     * by moving it beyond a target zone.
79     */
80    public interface OnTriggerListener {
81        /**
82         * The interface was triggered because the user let go of the handle without reaching the
83         * target zone.
84         */
85        public static final int NO_HANDLE = 0;
86
87        /**
88         * The interface was triggered because the user grabbed the left handle and moved it past
89         * the target zone.
90         */
91        public static final int LEFT_HANDLE = 1;
92
93        /**
94         * The interface was triggered because the user grabbed the right handle and moved it past
95         * the target zone.
96         */
97        public static final int RIGHT_HANDLE = 2;
98
99        /**
100         * Called when the user moves a handle beyond the target zone.
101         *
102         * @param v The view that was triggered.
103         * @param whichHandle  Which "dial handle" the user grabbed,
104         *        either {@link #LEFT_HANDLE}, {@link #RIGHT_HANDLE}.
105         */
106        void onTrigger(View v, int whichHandle);
107
108        /**
109         * Called when the "grabbed state" changes (i.e. when the user either grabs or releases
110         * one of the handles.)
111         *
112         * @param v the view that was triggered
113         * @param grabbedState the new state: {@link #NO_HANDLE}, {@link #LEFT_HANDLE},
114         * or {@link #RIGHT_HANDLE}.
115         */
116        void onGrabbedStateChange(View v, int grabbedState);
117    }
118
119    /**
120     * Simple container class for all things pertinent to a slider.
121     * A slider consists of 3 Views:
122     *
123     * {@link #tab} is the tab shown on the screen in the default state.
124     * {@link #text} is the view revealed as the user slides the tab out.
125     * {@link #target} is the target the user must drag the slider past to trigger the slider.
126     *
127     */
128    private static class Slider {
129        /**
130         * Tab alignment - determines which side the tab should be drawn on
131         */
132        public static final int ALIGN_LEFT = 0;
133        public static final int ALIGN_RIGHT = 1;
134        public static final int ALIGN_TOP = 2;
135        public static final int ALIGN_BOTTOM = 3;
136
137        /**
138         * States for the view.
139         */
140        private static final int STATE_NORMAL = 0;
141        private static final int STATE_PRESSED = 1;
142        private static final int STATE_ACTIVE = 2;
143
144        private final ImageView tab;
145        private final TextView text;
146        private final ImageView target;
147
148        /**
149         * Constructor
150         *
151         * @param parent the container view of this one
152         * @param tabId drawable for the tab
153         * @param barId drawable for the bar
154         * @param targetId drawable for the target
155         */
156        Slider(ViewGroup parent, int tabId, int barId, int targetId) {
157            // Create tab
158            tab = new ImageView(parent.getContext());
159            tab.setBackgroundResource(tabId);
160            tab.setScaleType(ScaleType.CENTER);
161            tab.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT,
162                    LayoutParams.WRAP_CONTENT));
163
164            // Create hint TextView
165            text = new TextView(parent.getContext());
166            text.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT,
167                    LayoutParams.FILL_PARENT));
168            text.setBackgroundResource(barId);
169            text.setTextAppearance(parent.getContext(), R.style.TextAppearance_SlidingTabNormal);
170            // hint.setSingleLine();  // Hmm.. this causes the text to disappear off-screen
171
172            // Create target
173            target = new ImageView(parent.getContext());
174            target.setImageResource(targetId);
175            target.setScaleType(ScaleType.CENTER);
176            target.setLayoutParams(
177                    new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));
178            target.setVisibility(View.INVISIBLE);
179
180            parent.addView(target); // this needs to be first - relies on painter's algorithm
181            parent.addView(tab);
182            parent.addView(text);
183        }
184
185        void setIcon(int iconId) {
186            tab.setImageResource(iconId);
187        }
188
189        void setTabBackgroundResource(int tabId) {
190            tab.setBackgroundResource(tabId);
191        }
192
193        void setBarBackgroundResource(int barId) {
194            text.setBackgroundResource(barId);
195        }
196
197        void setHintText(int resId) {
198            // TODO: Text should be blank if widget is vertical
199            text.setText(resId);
200        }
201
202        void hide() {
203            // TODO: Animate off the screen
204            text.setVisibility(View.INVISIBLE);
205            tab.setVisibility(View.INVISIBLE);
206            target.setVisibility(View.INVISIBLE);
207        }
208
209        void setState(int state) {
210            text.setPressed(state == STATE_PRESSED);
211            tab.setPressed(state == STATE_PRESSED);
212            if (state == STATE_ACTIVE) {
213                final int[] activeState = new int[] {com.android.internal.R.attr.state_active};
214                if (text.getBackground().isStateful()) {
215                    text.getBackground().setState(activeState);
216                }
217                if (tab.getBackground().isStateful()) {
218                    tab.getBackground().setState(activeState);
219                }
220                text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabActive);
221            } else {
222                text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabNormal);
223            }
224        }
225
226        void showTarget() {
227            target.setVisibility(View.VISIBLE);
228        }
229
230        void reset() {
231            setState(STATE_NORMAL);
232            text.setVisibility(View.VISIBLE);
233            text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabNormal);
234            tab.setVisibility(View.VISIBLE);
235            target.setVisibility(View.INVISIBLE);
236        }
237
238        void setTarget(int targetId) {
239            target.setImageResource(targetId);
240        }
241
242        /**
243         * Layout the given widgets within the parent.
244         *
245         * @param l the parent's left border
246         * @param t the parent's top border
247         * @param r the parent's right border
248         * @param b the parent's bottom border
249         * @param alignment which side to align the widget to
250         */
251        void layout(int l, int t, int r, int b, int alignment) {
252            final int handleWidth = tab.getBackground().getIntrinsicWidth();
253            final int handleHeight = tab.getBackground().getIntrinsicHeight();
254            final int targetWidth = target.getDrawable().getIntrinsicWidth();
255            final int targetHeight = target.getDrawable().getIntrinsicHeight();
256            final int parentWidth = r - l;
257            final int parentHeight = b - t;
258
259            final int leftTarget = (int) (TARGET_ZONE * parentWidth) - targetWidth + handleWidth / 2;
260            final int rightTarget = (int) ((1.0f - TARGET_ZONE) * parentWidth) - handleWidth / 2;
261            final int left = (parentWidth - handleWidth) / 2;
262            final int right = left + handleWidth;
263
264            if (alignment == ALIGN_LEFT || alignment == ALIGN_RIGHT) {
265                // horizontal
266                final int targetTop = (parentHeight - targetHeight) / 2;
267                final int targetBottom = targetTop + targetHeight;
268                final int top = (parentHeight - handleHeight) / 2;
269                final int bottom = (parentHeight + handleHeight) / 2;
270                if (alignment == ALIGN_LEFT) {
271                    tab.layout(0, top, handleWidth, bottom);
272                    text.layout(0 - parentWidth, top, 0, bottom);
273                    text.setGravity(Gravity.RIGHT);
274                    target.layout(leftTarget, targetTop, leftTarget + targetWidth, targetBottom);
275                } else {
276                    tab.layout(parentWidth - handleWidth, top, parentWidth, bottom);
277                    text.layout(parentWidth, top, parentWidth + parentWidth, bottom);
278                    target.layout(rightTarget, targetTop, rightTarget + targetWidth, targetBottom);
279                    text.setGravity(Gravity.TOP);
280                }
281            } else {
282                // vertical
283                final int targetLeft = (parentWidth - targetWidth) / 2;
284                final int targetRight = (parentWidth + targetWidth) / 2;
285                final int top = (int) (TARGET_ZONE * parentHeight) + handleHeight / 2 - targetHeight;
286                final int bottom = (int) ((1.0f - TARGET_ZONE) * parentHeight) - handleHeight / 2;
287                if (alignment == ALIGN_TOP) {
288                    tab.layout(left, 0, right, handleHeight);
289                    text.layout(left, 0 - parentHeight, right, 0);
290                    target.layout(targetLeft, top, targetRight, top + targetHeight);
291                } else {
292                    tab.layout(left, parentHeight - handleHeight, right, parentHeight);
293                    text.layout(left, parentHeight, right, parentHeight + parentHeight);
294                    target.layout(targetLeft, bottom, targetRight, bottom + targetHeight);
295                }
296            }
297        }
298
299        public int getTabWidth() {
300            return tab.getDrawable().getIntrinsicWidth();
301        }
302
303        public int getTabHeight() {
304            return tab.getDrawable().getIntrinsicHeight();
305        }
306    }
307
308    public SlidingTab(Context context) {
309        this(context, null);
310    }
311
312    /**
313     * Constructor used when this widget is created from a layout file.
314     */
315    public SlidingTab(Context context, AttributeSet attrs) {
316        super(context, attrs);
317
318        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SlidingTab);
319        mOrientation = a.getInt(R.styleable.SlidingTab_orientation, HORIZONTAL);
320        a.recycle();
321
322        Resources r = getResources();
323        mDensity = r.getDisplayMetrics().density;
324        if (DBG) log("- Density: " + mDensity);
325
326        mLeftSlider = new Slider(this,
327                R.drawable.jog_tab_left_generic,
328                R.drawable.jog_tab_bar_left_generic,
329                R.drawable.jog_tab_target_gray);
330        mRightSlider = new Slider(this,
331                R.drawable.jog_tab_right_generic,
332                R.drawable.jog_tab_bar_right_generic,
333                R.drawable.jog_tab_target_gray);
334
335        // setBackgroundColor(0x80808080);
336    }
337
338    @Override
339    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
340        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
341        int widthSpecSize =  MeasureSpec.getSize(widthMeasureSpec);
342
343        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
344        int heightSpecSize =  MeasureSpec.getSize(heightMeasureSpec);
345
346        if (widthSpecMode == MeasureSpec.UNSPECIFIED || heightSpecMode == MeasureSpec.UNSPECIFIED) {
347            throw new RuntimeException(LOG_TAG + " cannot have UNSPECIFIED dimensions");
348        }
349
350        final int leftTabWidth = (int) (mDensity * mLeftSlider.getTabWidth() + 0.5f);
351        final int rightTabWidth = (int) (mDensity * mRightSlider.getTabWidth() + 0.5f);
352        final int leftTabHeight = (int) (mDensity * mLeftSlider.getTabHeight() + 0.5f);
353        final int rightTabHeight = (int) (mDensity * mRightSlider.getTabHeight() + 0.5f);
354        final int width;
355        final int height;
356        if (isHorizontal()) {
357            width = Math.max(widthSpecSize, leftTabWidth + rightTabWidth);
358            height = Math.max(leftTabHeight, rightTabHeight);
359        } else {
360            width = Math.max(leftTabWidth, rightTabHeight);
361            height = Math.max(heightSpecSize, leftTabHeight + rightTabHeight);
362        }
363        setMeasuredDimension(width, height);
364    }
365
366    @Override
367    public boolean onInterceptTouchEvent(MotionEvent event) {
368        final int action = event.getAction();
369        final float x = event.getX();
370        final float y = event.getY();
371
372        final Rect frame = new Rect();
373
374        if (mAnimating) {
375            return false;
376        }
377
378        View leftHandle = mLeftSlider.tab;
379        leftHandle.getHitRect(frame);
380        boolean leftHit = frame.contains((int) x, (int) y);
381
382        View rightHandle = mRightSlider.tab;
383        rightHandle.getHitRect(frame);
384        boolean rightHit = frame.contains((int)x, (int) y);
385
386        if (!mTracking && !(leftHit || rightHit)) {
387            return false;
388        }
389
390        switch (action) {
391            case MotionEvent.ACTION_DOWN: {
392                mTracking = true;
393                mTriggered = false;
394                vibrate(VIBRATE_SHORT);
395                if (leftHit) {
396                    mCurrentSlider = mLeftSlider;
397                    mOtherSlider = mRightSlider;
398                    mTargetZone = isHorizontal() ? TARGET_ZONE : 1.0f - TARGET_ZONE;
399                    setGrabbedState(OnTriggerListener.LEFT_HANDLE);
400                } else {
401                    mCurrentSlider = mRightSlider;
402                    mOtherSlider = mLeftSlider;
403                    mTargetZone = isHorizontal() ? 1.0f - TARGET_ZONE : TARGET_ZONE;
404                    setGrabbedState(OnTriggerListener.RIGHT_HANDLE);
405                }
406                mCurrentSlider.setState(Slider.STATE_PRESSED);
407                mCurrentSlider.showTarget();
408                mOtherSlider.hide();
409                break;
410            }
411        }
412
413        return true;
414    }
415
416    @Override
417    public boolean onTouchEvent(MotionEvent event) {
418        if (mTracking) {
419            final int action = event.getAction();
420            final float x = event.getX();
421            final float y = event.getY();
422            final View handle = mCurrentSlider.tab;
423            switch (action) {
424                case MotionEvent.ACTION_MOVE:
425                    moveHandle(x, y);
426                    float position = isHorizontal() ? x : y;
427                    float target = mTargetZone * (isHorizontal() ? getWidth() : getHeight());
428                    boolean targetZoneReached;
429                    if (isHorizontal()) {
430                        targetZoneReached = mCurrentSlider == mLeftSlider ?
431                                position > target : position < target;
432                    } else {
433                        targetZoneReached = mCurrentSlider == mLeftSlider ?
434                                position < target : position > target;
435                    }
436                    if (!mTriggered && targetZoneReached) {
437                        mTriggered = true;
438                        mTracking = false;
439                        mCurrentSlider.setState(Slider.STATE_ACTIVE);
440                        dispatchTriggerEvent(mCurrentSlider == mLeftSlider ?
441                            OnTriggerListener.LEFT_HANDLE : OnTriggerListener.RIGHT_HANDLE);
442
443                        // TODO: This is a place holder for the real animation. It just holds
444                        // the screen for 500ms.
445                        mAnimating = true;
446                        mHandler.postDelayed(new Runnable() {
447                            public void run() {
448                                resetView();
449                                mAnimating = false;
450                            }
451                        }, 500);
452                    }
453
454                    if (isHorizontal() && (y <= handle.getBottom() && y >= handle.getTop()) ||
455                            !isHorizontal() && (x >= handle.getLeft() && x <= handle.getRight()) ) {
456                        break;
457                    }
458                    // Intentionally fall through - we're outside tracking rectangle
459
460                case MotionEvent.ACTION_UP:
461                case MotionEvent.ACTION_CANCEL:
462                    mTracking = false;
463                    mTriggered = false;
464                    resetView();
465                    setGrabbedState(OnTriggerListener.NO_HANDLE);
466                    break;
467            }
468        }
469
470        return mTracking || super.onTouchEvent(event);
471    }
472
473    private boolean isHorizontal() {
474        return mOrientation == HORIZONTAL;
475    }
476
477    private void resetView() {
478        mLeftSlider.reset();
479        mRightSlider.reset();
480        onLayout(true, getLeft(), getTop(), getLeft() + getWidth(), getTop() + getHeight());
481    }
482
483    @Override
484    protected void onLayout(boolean changed, int l, int t, int r, int b) {
485        if (!changed) return;
486
487        // Center the widgets in the view
488        mLeftSlider.layout(l, t, r, b, isHorizontal() ? Slider.ALIGN_LEFT : Slider.ALIGN_BOTTOM);
489        mRightSlider.layout(l, t, r, b, isHorizontal() ? Slider.ALIGN_RIGHT : Slider.ALIGN_TOP);
490
491        invalidate(); // TODO: be more conservative about what we're invalidating
492    }
493
494    private void moveHandle(float x, float y) {
495        final View handle = mCurrentSlider.tab;
496        final View content = mCurrentSlider.text;
497        if (isHorizontal()) {
498            int deltaX = (int) x - handle.getLeft() - (handle.getWidth() / 2);
499            handle.offsetLeftAndRight(deltaX);
500            content.offsetLeftAndRight(deltaX);
501        } else {
502            int deltaY = (int) y - handle.getTop() - (handle.getHeight() / 2);
503            handle.offsetTopAndBottom(deltaY);
504            content.offsetTopAndBottom(deltaY);
505        }
506        invalidate(); // TODO: be more conservative about what we're invalidating
507    }
508
509    /**
510     * Sets the left handle icon to a given resource.
511     *
512     * The resource should refer to a Drawable object, or use 0 to remove
513     * the icon.
514     *
515     * @param iconId the resource ID of the icon drawable
516     * @param targetId the resource of the target drawable
517     * @param barId the resource of the bar drawable (stateful)
518     * @param tabId the resource of the
519     */
520    public void setLeftTabResources(int iconId, int targetId, int barId, int tabId) {
521        mLeftSlider.setIcon(iconId);
522        mLeftSlider.setTarget(targetId);
523        mLeftSlider.setBarBackgroundResource(barId);
524        mLeftSlider.setTabBackgroundResource(tabId);
525    }
526
527    /**
528     * Sets the left handle hint text to a given resource string.
529     *
530     * @param resId
531     */
532    public void setLeftHintText(int resId) {
533        mLeftSlider.setHintText(resId);
534    }
535
536    /**
537     * Sets the right handle icon to a given resource.
538     *
539     * The resource should refer to a Drawable object, or use 0 to remove
540     * the icon.
541     *
542     * @param iconId the resource ID of the icon drawable
543     * @param targetId the resource of the target drawable
544     * @param barId the resource of the bar drawable (stateful)
545     * @param tabId the resource of the
546     */
547    public void setRightTabResources(int iconId, int targetId, int barId, int tabId) {
548        mRightSlider.setIcon(iconId);
549        mRightSlider.setTarget(targetId);
550        mRightSlider.setBarBackgroundResource(barId);
551        mRightSlider.setTabBackgroundResource(tabId);
552    }
553
554    /**
555     * Sets the left handle hint text to a given resource string.
556     *
557     * @param resId
558     */
559    public void setRightHintText(int resId) {
560        mRightSlider.setHintText(resId);
561    }
562
563    /**
564     * Triggers haptic feedback.
565     */
566    private synchronized void vibrate(long duration) {
567        if (mVibrator == null) {
568            mVibrator = (android.os.Vibrator)
569                    getContext().getSystemService(Context.VIBRATOR_SERVICE);
570        }
571        mVibrator.vibrate(duration);
572    }
573
574    /**
575     * Registers a callback to be invoked when the user triggers an event.
576     *
577     * @param listener the OnDialTriggerListener to attach to this view
578     */
579    public void setOnTriggerListener(OnTriggerListener listener) {
580        mOnTriggerListener = listener;
581    }
582
583    /**
584     * Dispatches a trigger event to listener. Ignored if a listener is not set.
585     * @param whichHandle the handle that triggered the event.
586     */
587    private void dispatchTriggerEvent(int whichHandle) {
588        vibrate(VIBRATE_LONG);
589        if (mOnTriggerListener != null) {
590            mOnTriggerListener.onTrigger(this, whichHandle);
591        }
592    }
593
594    /**
595     * Sets the current grabbed state, and dispatches a grabbed state change
596     * event to our listener.
597     */
598    private void setGrabbedState(int newState) {
599        if (newState != mGrabbedState) {
600            mGrabbedState = newState;
601            if (mOnTriggerListener != null) {
602                mOnTriggerListener.onGrabbedStateChange(this, mGrabbedState);
603            }
604        }
605    }
606
607    private class SlidingTabHandler extends Handler {
608        public void handleMessage(Message m) {
609            switch (m.what) {
610                case MSG_ANIMATE:
611                    doAnimation();
612                    break;
613            }
614        }
615    }
616
617    private void doAnimation() {
618        if (mAnimating) {
619
620        }
621    }
622
623    private void log(String msg) {
624        Log.d(LOG_TAG, msg);
625    }
626}
627