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