SlidingTab.java revision 425ca595dcc37ddb7a9f96310e5b800f424811a6
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            text.setText(resId);
205        }
206
207        void hide() {
208            // TODO: Animate off the screen
209            text.setVisibility(View.INVISIBLE);
210            tab.setVisibility(View.INVISIBLE);
211            target.setVisibility(View.INVISIBLE);
212        }
213
214        void setState(int state) {
215            text.setPressed(state == STATE_PRESSED);
216            tab.setPressed(state == STATE_PRESSED);
217            if (state == STATE_ACTIVE) {
218                final int[] activeState = new int[] {com.android.internal.R.attr.state_active};
219                if (text.getBackground().isStateful()) {
220                    text.getBackground().setState(activeState);
221                }
222                if (tab.getBackground().isStateful()) {
223                    tab.getBackground().setState(activeState);
224                }
225                text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabActive);
226            } else {
227                text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabNormal);
228            }
229            currentState = state;
230        }
231
232        void showTarget() {
233            target.setVisibility(View.VISIBLE);
234        }
235
236        void reset() {
237            setState(STATE_NORMAL);
238            text.setVisibility(View.VISIBLE);
239            text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabNormal);
240            tab.setVisibility(View.VISIBLE);
241            target.setVisibility(View.INVISIBLE);
242        }
243
244        void setTarget(int targetId) {
245            target.setImageResource(targetId);
246        }
247
248        /**
249         * Layout the given widgets within the parent.
250         *
251         * @param l the parent's left border
252         * @param t the parent's top border
253         * @param r the parent's right border
254         * @param b the parent's bottom border
255         * @param alignment which side to align the widget to
256         */
257        void layout(int l, int t, int r, int b, int alignment) {
258            final Drawable tabBackground = tab.getBackground();
259            final int handleWidth = tabBackground.getIntrinsicWidth();
260            final int handleHeight = tabBackground.getIntrinsicHeight();
261            final Drawable targetDrawable = target.getDrawable();
262            final int targetWidth = targetDrawable.getIntrinsicWidth();
263            final int targetHeight = targetDrawable.getIntrinsicHeight();
264            final int parentWidth = r - l;
265            final int parentHeight = b - t;
266
267            final int leftTarget = (int) (THRESHOLD * parentWidth) - targetWidth + handleWidth / 2;
268            final int rightTarget = (int) ((1.0f - THRESHOLD) * parentWidth) - handleWidth / 2;
269            final int left = (parentWidth - handleWidth) / 2;
270            final int right = left + handleWidth;
271
272            if (alignment == ALIGN_LEFT || alignment == ALIGN_RIGHT) {
273                // horizontal
274                final int targetTop = (parentHeight - targetHeight) / 2;
275                final int targetBottom = targetTop + targetHeight;
276                final int top = (parentHeight - handleHeight) / 2;
277                final int bottom = (parentHeight + handleHeight) / 2;
278                if (alignment == ALIGN_LEFT) {
279                    tab.layout(0, top, handleWidth, bottom);
280                    text.layout(0 - parentWidth, top, 0, bottom);
281                    text.setGravity(Gravity.RIGHT);
282                    target.layout(leftTarget, targetTop, leftTarget + targetWidth, targetBottom);
283                } else {
284                    tab.layout(parentWidth - handleWidth, top, parentWidth, bottom);
285                    text.layout(parentWidth, top, parentWidth + parentWidth, bottom);
286                    target.layout(rightTarget, targetTop, rightTarget + targetWidth, targetBottom);
287                    text.setGravity(Gravity.TOP);
288                }
289            } else {
290                // vertical
291                final int targetLeft = (parentWidth - targetWidth) / 2;
292                final int targetRight = (parentWidth + targetWidth) / 2;
293                final int top = (int) (THRESHOLD * parentHeight) + handleHeight / 2 - targetHeight;
294                final int bottom = (int) ((1.0f - THRESHOLD) * parentHeight) - handleHeight / 2;
295                if (alignment == ALIGN_TOP) {
296                    tab.layout(left, 0, right, handleHeight);
297                    text.layout(left, 0 - parentHeight, right, 0);
298                    target.layout(targetLeft, top, targetRight, top + targetHeight);
299                } else {
300                    tab.layout(left, parentHeight - handleHeight, right, parentHeight);
301                    text.layout(left, parentHeight, right, parentHeight + parentHeight);
302                    target.layout(targetLeft, bottom, targetRight, bottom + targetHeight);
303                }
304            }
305        }
306
307        public void updateDrawableStates() {
308            setState(currentState);
309        }
310
311        /**
312         * Ensure all the dependent widgets are measured.
313         */
314        public void measure() {
315            tab.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
316                    View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
317            text.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
318                    View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
319        }
320
321        /**
322         * Get the measured tab width. Must be called after {@link Slider#measure()}.
323         * @return
324         */
325        public int getTabWidth() {
326            return tab.getMeasuredWidth();
327        }
328
329        /**
330         * Get the measured tab width. Must be called after {@link Slider#measure()}.
331         * @return
332         */
333        public int getTabHeight() {
334            return tab.getMeasuredHeight();
335        }
336    }
337
338    public SlidingTab(Context context) {
339        this(context, null);
340    }
341
342    /**
343     * Constructor used when this widget is created from a layout file.
344     */
345    public SlidingTab(Context context, AttributeSet attrs) {
346        super(context, attrs);
347
348        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SlidingTab);
349        mOrientation = a.getInt(R.styleable.SlidingTab_orientation, HORIZONTAL);
350        a.recycle();
351
352        Resources r = getResources();
353        mDensity = r.getDisplayMetrics().density;
354        if (DBG) log("- Density: " + mDensity);
355
356        mLeftSlider = new Slider(this,
357                R.drawable.jog_tab_left_generic,
358                R.drawable.jog_tab_bar_left_generic,
359                R.drawable.jog_tab_target_gray);
360        mRightSlider = new Slider(this,
361                R.drawable.jog_tab_right_generic,
362                R.drawable.jog_tab_bar_right_generic,
363                R.drawable.jog_tab_target_gray);
364
365        // setBackgroundColor(0x80808080);
366    }
367
368    @Override
369    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
370        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
371        int widthSpecSize =  MeasureSpec.getSize(widthMeasureSpec);
372
373        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
374        int heightSpecSize =  MeasureSpec.getSize(heightMeasureSpec);
375
376        if (widthSpecMode == MeasureSpec.UNSPECIFIED || heightSpecMode == MeasureSpec.UNSPECIFIED) {
377            throw new RuntimeException(LOG_TAG + " cannot have UNSPECIFIED dimensions");
378        }
379
380        mLeftSlider.measure();
381        mRightSlider.measure();
382        final int leftTabWidth = mLeftSlider.getTabWidth();
383        final int rightTabWidth = mRightSlider.getTabWidth();
384        final int leftTabHeight = mLeftSlider.getTabHeight();
385        final int rightTabHeight = mRightSlider.getTabHeight();
386        final int width;
387        final int height;
388        if (isHorizontal()) {
389            width = Math.max(widthSpecSize, leftTabWidth + rightTabWidth);
390            height = Math.max(leftTabHeight, rightTabHeight);
391        } else {
392            width = Math.max(leftTabWidth, rightTabHeight);
393            height = Math.max(heightSpecSize, leftTabHeight + rightTabHeight);
394        }
395        setMeasuredDimension(width, height);
396    }
397
398    @Override
399    public boolean onInterceptTouchEvent(MotionEvent event) {
400        final int action = event.getAction();
401        final float x = event.getX();
402        final float y = event.getY();
403
404        final Rect frame = new Rect();
405
406        if (mAnimating) {
407            return false;
408        }
409
410        View leftHandle = mLeftSlider.tab;
411        leftHandle.getHitRect(frame);
412        boolean leftHit = frame.contains((int) x, (int) y);
413
414        View rightHandle = mRightSlider.tab;
415        rightHandle.getHitRect(frame);
416        boolean rightHit = frame.contains((int)x, (int) y);
417
418        if (!mTracking && !(leftHit || rightHit)) {
419            return false;
420        }
421
422        switch (action) {
423            case MotionEvent.ACTION_DOWN: {
424                mTracking = true;
425                mTriggered = false;
426                vibrate(VIBRATE_SHORT);
427                if (leftHit) {
428                    mCurrentSlider = mLeftSlider;
429                    mOtherSlider = mRightSlider;
430                    mThreshold = isHorizontal() ? THRESHOLD : 1.0f - THRESHOLD;
431                    setGrabbedState(OnTriggerListener.LEFT_HANDLE);
432                } else {
433                    mCurrentSlider = mRightSlider;
434                    mOtherSlider = mLeftSlider;
435                    mThreshold = isHorizontal() ? 1.0f - THRESHOLD : THRESHOLD;
436                    setGrabbedState(OnTriggerListener.RIGHT_HANDLE);
437                }
438                mCurrentSlider.setState(Slider.STATE_PRESSED);
439                mCurrentSlider.showTarget();
440                mOtherSlider.hide();
441                break;
442            }
443        }
444
445        return true;
446    }
447
448    @Override
449    public boolean onTouchEvent(MotionEvent event) {
450        if (mTracking) {
451            final int action = event.getAction();
452            final float x = event.getX();
453            final float y = event.getY();
454            final View handle = mCurrentSlider.tab;
455            switch (action) {
456                case MotionEvent.ACTION_MOVE:
457                    moveHandle(x, y);
458                    float position = isHorizontal() ? x : y;
459                    float target = mThreshold * (isHorizontal() ? getWidth() : getHeight());
460                    boolean thresholdReached;
461                    if (isHorizontal()) {
462                        thresholdReached = mCurrentSlider == mLeftSlider ?
463                                position > target : position < target;
464                    } else {
465                        thresholdReached = mCurrentSlider == mLeftSlider ?
466                                position < target : position > target;
467                    }
468                    if (!mTriggered && thresholdReached) {
469                        mTriggered = true;
470                        mTracking = false;
471                        mCurrentSlider.setState(Slider.STATE_ACTIVE);
472                        dispatchTriggerEvent(mCurrentSlider == mLeftSlider ?
473                            OnTriggerListener.LEFT_HANDLE : OnTriggerListener.RIGHT_HANDLE);
474
475                        // TODO: This is a place holder for the real animation. It just holds
476                        // the screen for the duration of the animation for now.
477                        mAnimating = true;
478                        mHandler.postDelayed(new Runnable() {
479                            public void run() {
480                                resetView();
481                                mAnimating = false;
482                            }
483                        }, ANIMATION_DURATION);
484                    }
485
486                    if (isHorizontal() && (y <= handle.getBottom() && y >= handle.getTop()) ||
487                            !isHorizontal() && (x >= handle.getLeft() && x <= handle.getRight()) ) {
488                        break;
489                    }
490                    // Intentionally fall through - we're outside tracking rectangle
491
492                case MotionEvent.ACTION_UP:
493                case MotionEvent.ACTION_CANCEL:
494                    mTracking = false;
495                    mTriggered = false;
496                    resetView();
497                    setGrabbedState(OnTriggerListener.NO_HANDLE);
498                    break;
499            }
500        }
501
502        return mTracking || super.onTouchEvent(event);
503    }
504
505    private boolean isHorizontal() {
506        return mOrientation == HORIZONTAL;
507    }
508
509    private void resetView() {
510        mLeftSlider.reset();
511        mRightSlider.reset();
512        onLayout(true, getLeft(), getTop(), getLeft() + getWidth(), getTop() + getHeight());
513    }
514
515    @Override
516    protected void onLayout(boolean changed, int l, int t, int r, int b) {
517        if (!changed) return;
518
519        // Center the widgets in the view
520        mLeftSlider.layout(l, t, r, b, isHorizontal() ? Slider.ALIGN_LEFT : Slider.ALIGN_BOTTOM);
521        mRightSlider.layout(l, t, r, b, isHorizontal() ? Slider.ALIGN_RIGHT : Slider.ALIGN_TOP);
522
523        invalidate(); // TODO: be more conservative about what we're invalidating
524    }
525
526    private void moveHandle(float x, float y) {
527        final View handle = mCurrentSlider.tab;
528        final View content = mCurrentSlider.text;
529        if (isHorizontal()) {
530            int deltaX = (int) x - handle.getLeft() - (handle.getWidth() / 2);
531            handle.offsetLeftAndRight(deltaX);
532            content.offsetLeftAndRight(deltaX);
533        } else {
534            int deltaY = (int) y - handle.getTop() - (handle.getHeight() / 2);
535            handle.offsetTopAndBottom(deltaY);
536            content.offsetTopAndBottom(deltaY);
537        }
538        invalidate(); // TODO: be more conservative about what we're invalidating
539    }
540
541    /**
542     * Sets the left handle icon to a given resource.
543     *
544     * The resource should refer to a Drawable object, or use 0 to remove
545     * the icon.
546     *
547     * @param iconId the resource ID of the icon drawable
548     * @param targetId the resource of the target drawable
549     * @param barId the resource of the bar drawable (stateful)
550     * @param tabId the resource of the
551     */
552    public void setLeftTabResources(int iconId, int targetId, int barId, int tabId) {
553        mLeftSlider.setIcon(iconId);
554        mLeftSlider.setTarget(targetId);
555        mLeftSlider.setBarBackgroundResource(barId);
556        mLeftSlider.setTabBackgroundResource(tabId);
557        mLeftSlider.updateDrawableStates();
558    }
559
560    /**
561     * Sets the left handle hint text to a given resource string.
562     *
563     * @param resId
564     */
565    public void setLeftHintText(int resId) {
566        if (isHorizontal()) {
567            mLeftSlider.setHintText(resId);
568        }
569    }
570
571    /**
572     * Sets the right handle icon to a given resource.
573     *
574     * The resource should refer to a Drawable object, or use 0 to remove
575     * the icon.
576     *
577     * @param iconId the resource ID of the icon drawable
578     * @param targetId the resource of the target drawable
579     * @param barId the resource of the bar drawable (stateful)
580     * @param tabId the resource of the
581     */
582    public void setRightTabResources(int iconId, int targetId, int barId, int tabId) {
583        mRightSlider.setIcon(iconId);
584        mRightSlider.setTarget(targetId);
585        mRightSlider.setBarBackgroundResource(barId);
586        mRightSlider.setTabBackgroundResource(tabId);
587        mRightSlider.updateDrawableStates();
588    }
589
590    /**
591     * Sets the left handle hint text to a given resource string.
592     *
593     * @param resId
594     */
595    public void setRightHintText(int resId) {
596        if (isHorizontal()) {
597            mRightSlider.setHintText(resId);
598        }
599    }
600
601    /**
602     * Triggers haptic feedback.
603     */
604    private synchronized void vibrate(long duration) {
605        if (mVibrator == null) {
606            mVibrator = (android.os.Vibrator)
607                    getContext().getSystemService(Context.VIBRATOR_SERVICE);
608        }
609        mVibrator.vibrate(duration);
610    }
611
612    /**
613     * Registers a callback to be invoked when the user triggers an event.
614     *
615     * @param listener the OnDialTriggerListener to attach to this view
616     */
617    public void setOnTriggerListener(OnTriggerListener listener) {
618        mOnTriggerListener = listener;
619    }
620
621    /**
622     * Dispatches a trigger event to listener. Ignored if a listener is not set.
623     * @param whichHandle the handle that triggered the event.
624     */
625    private void dispatchTriggerEvent(int whichHandle) {
626        vibrate(VIBRATE_LONG);
627        if (mOnTriggerListener != null) {
628            mOnTriggerListener.onTrigger(this, whichHandle);
629        }
630    }
631
632    /**
633     * Sets the current grabbed state, and dispatches a grabbed state change
634     * event to our listener.
635     */
636    private void setGrabbedState(int newState) {
637        if (newState != mGrabbedState) {
638            mGrabbedState = newState;
639            if (mOnTriggerListener != null) {
640                mOnTriggerListener.onGrabbedStateChange(this, mGrabbedState);
641            }
642        }
643    }
644
645    private class SlidingTabHandler extends Handler {
646        public void handleMessage(Message m) {
647            switch (m.what) {
648                case MSG_ANIMATE:
649                    doAnimation();
650                    break;
651            }
652        }
653    }
654
655    private void doAnimation() {
656        if (mAnimating) {
657
658        }
659    }
660
661    private void log(String msg) {
662        Log.d(LOG_TAG, msg);
663    }
664}
665