PieMenu.java revision ee8ede1146cefb85d0b9e7f1fc796fcc8808629a
1/*
2 * Copyright (C) 2010 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.browser.view;
18
19import android.animation.Animator;
20import android.animation.Animator.AnimatorListener;
21import android.animation.AnimatorListenerAdapter;
22import android.animation.ValueAnimator;
23import android.animation.ValueAnimator.AnimatorUpdateListener;
24import android.content.Context;
25import android.content.res.Resources;
26import android.graphics.Canvas;
27import android.graphics.Paint;
28import android.graphics.Path;
29import android.graphics.Point;
30import android.graphics.PointF;
31import android.graphics.RectF;
32import android.graphics.drawable.Drawable;
33import android.util.AttributeSet;
34import android.view.MotionEvent;
35import android.view.SoundEffectConstants;
36import android.view.View;
37import android.view.ViewGroup;
38import android.widget.FrameLayout;
39
40import com.android.browser.R;
41
42import java.util.ArrayList;
43import java.util.List;
44
45public class PieMenu extends FrameLayout {
46
47    private static final int MAX_LEVELS = 5;
48    private static final long ANIMATION = 80;
49
50    public interface PieController {
51        /**
52         * called before menu opens to customize menu
53         * returns if pie state has been changed
54         */
55        public boolean onOpen();
56        public void stopEditingUrl();
57
58    }
59
60    /**
61     * A view like object that lives off of the pie menu
62     */
63    public interface PieView {
64
65        public interface OnLayoutListener {
66            public void onLayout(int ax, int ay, boolean left);
67        }
68
69        public void setLayoutListener(OnLayoutListener l);
70
71        public void layout(int anchorX, int anchorY, boolean onleft, float angle,
72                int parentHeight);
73
74        public void draw(Canvas c);
75
76        public boolean onTouchEvent(MotionEvent evt);
77
78    }
79
80    private Point mCenter;
81    private int mRadius;
82    private int mRadiusInc;
83    private int mSlop;
84    private int mTouchOffset;
85    private Path mPath;
86
87    private boolean mOpen;
88    private PieController mController;
89
90    private List<PieItem> mItems;
91    private int mLevels;
92    private int[] mCounts;
93    private PieView mPieView = null;
94
95    // sub menus
96    private List<PieItem> mCurrentItems;
97    private PieItem mOpenItem;
98
99    private Drawable mBackground;
100    private Paint mNormalPaint;
101    private Paint mSelectedPaint;
102    private Paint mSubPaint;
103
104    // touch handling
105    private PieItem mCurrentItem;
106
107    private boolean mUseBackground;
108    private boolean mAnimating;
109
110    /**
111     * @param context
112     * @param attrs
113     * @param defStyle
114     */
115    public PieMenu(Context context, AttributeSet attrs, int defStyle) {
116        super(context, attrs, defStyle);
117        init(context);
118    }
119
120    /**
121     * @param context
122     * @param attrs
123     */
124    public PieMenu(Context context, AttributeSet attrs) {
125        super(context, attrs);
126        init(context);
127    }
128
129    /**
130     * @param context
131     */
132    public PieMenu(Context context) {
133        super(context);
134        init(context);
135    }
136
137    private void init(Context ctx) {
138        mItems = new ArrayList<PieItem>();
139        mLevels = 0;
140        mCounts = new int[MAX_LEVELS];
141        Resources res = ctx.getResources();
142        mRadius = (int) res.getDimension(R.dimen.qc_radius_start);
143        mRadiusInc = (int) res.getDimension(R.dimen.qc_radius_increment);
144        mSlop = (int) res.getDimension(R.dimen.qc_slop);
145        mTouchOffset = (int) res.getDimension(R.dimen.qc_touch_offset);
146        mOpen = false;
147        setWillNotDraw(false);
148        setDrawingCacheEnabled(false);
149        mCenter = new Point(0,0);
150        mBackground = res.getDrawable(R.drawable.qc_background_normal);
151        mNormalPaint = new Paint();
152        mNormalPaint.setColor(res.getColor(R.color.qc_normal));
153        mNormalPaint.setAntiAlias(true);
154        mSelectedPaint = new Paint();
155        mSelectedPaint.setColor(res.getColor(R.color.qc_selected));
156        mSelectedPaint.setAntiAlias(true);
157        mSubPaint = new Paint();
158        mSubPaint.setAntiAlias(true);
159        mSubPaint.setColor(res.getColor(R.color.qc_sub));
160    }
161
162    public void setController(PieController ctl) {
163        mController = ctl;
164    }
165
166    public void setUseBackground(boolean useBackground) {
167        mUseBackground = useBackground;
168    }
169
170    public void addItem(PieItem item) {
171        // add the item to the pie itself
172        mItems.add(item);
173        int l = item.getLevel();
174        mLevels = Math.max(mLevels, l);
175        mCounts[l]++;
176    }
177
178    public void removeItem(PieItem item) {
179        mItems.remove(item);
180    }
181
182    public void clearItems() {
183        mItems.clear();
184    }
185
186    private boolean onTheLeft() {
187        return mCenter.x < mSlop;
188    }
189
190    /**
191     * guaranteed has center set
192     * @param show
193     */
194    private void show(boolean show) {
195        mOpen = show;
196        if (mOpen) {
197            // ensure clean state
198            mAnimating = false;
199            mCurrentItem = null;
200            mOpenItem = null;
201            mPieView = null;
202            mController.stopEditingUrl();
203            mCurrentItems = mItems;
204            for (PieItem item : mCurrentItems) {
205                item.setSelected(false);
206            }
207            if (mController != null) {
208                boolean changed = mController.onOpen();
209            }
210            layoutPie();
211            animateOpen();
212        }
213        invalidate();
214    }
215
216    private void animateOpen() {
217        ValueAnimator anim = ValueAnimator.ofFloat(0, 1);
218        anim.addUpdateListener(new AnimatorUpdateListener() {
219            @Override
220            public void onAnimationUpdate(ValueAnimator animation) {
221                for (PieItem item : mCurrentItems) {
222                    item.setAnimationAngle((1 - animation.getAnimatedFraction()) * (- item.getStart()));
223                }
224                invalidate();
225            }
226
227        });
228        anim.setDuration(2*ANIMATION);
229        anim.start();
230    }
231
232    private void setCenter(int x, int y) {
233        if (x < mSlop) {
234            mCenter.x = 0;
235        } else {
236            mCenter.x = getWidth();
237        }
238        mCenter.y = y;
239    }
240
241    private void layoutPie() {
242        float emptyangle = (float) Math.PI / 16;
243        int rgap = 2;
244        int inner = mRadius + rgap;
245        int outer = mRadius + mRadiusInc - rgap;
246        int gap = 1;
247        for (int i = 0; i < mLevels; i++) {
248            int level = i + 1;
249            float sweep = (float) (Math.PI - 2 * emptyangle) / mCounts[level];
250            float angle = emptyangle + sweep / 2;
251            mPath = makeSlice(getDegrees(0) - gap, getDegrees(sweep) + gap, outer, inner, mCenter);
252            for (PieItem item : mCurrentItems) {
253                if (item.getLevel() == level) {
254                    View view = item.getView();
255                    if (view != null) {
256                        view.measure(view.getLayoutParams().width,
257                                view.getLayoutParams().height);
258                        int w = view.getMeasuredWidth();
259                        int h = view.getMeasuredHeight();
260                        int r = inner + (outer - inner) * 2 / 3;
261                        int x = (int) (r * Math.sin(angle));
262                        int y = mCenter.y - (int) (r * Math.cos(angle)) - h / 2;
263                        if (onTheLeft()) {
264                            x = mCenter.x + x - w / 2;
265                        } else {
266                            x = mCenter.x - x - w / 2;
267                        }
268                        view.layout(x, y, x + w, y + h);
269                    }
270                    float itemstart = angle - sweep / 2;
271                    item.setGeometry(itemstart, sweep, inner, outer);
272                    angle += sweep;
273                }
274            }
275            inner += mRadiusInc;
276            outer += mRadiusInc;
277        }
278    }
279
280
281    /**
282     * converts a
283     *
284     * @param angle from 0..PI to Android degrees (clockwise starting at 3
285     *        o'clock)
286     * @return skia angle
287     */
288    private float getDegrees(double angle) {
289        return (float) (270 - 180 * angle / Math.PI);
290    }
291
292    @Override
293    protected void onDraw(Canvas canvas) {
294        if (mOpen) {
295            int state;
296            if (mUseBackground) {
297                int w = mBackground.getIntrinsicWidth();
298                int h = mBackground.getIntrinsicHeight();
299                int left = mCenter.x - w;
300                int top = mCenter.y - h / 2;
301                mBackground.setBounds(left, top, left + w, top + h);
302                state = canvas.save();
303                if (onTheLeft()) {
304                    canvas.scale(-1, 1);
305                }
306                mBackground.draw(canvas);
307                canvas.restoreToCount(state);
308            }
309            // draw base menu
310            PieItem last = mCurrentItem;
311            if (mOpenItem != null) {
312                last = mOpenItem;
313            }
314            for (PieItem item : mCurrentItems) {
315                if (item != last) {
316                    drawItem(canvas, item);
317                }
318            }
319            if (last != null) {
320                drawItem(canvas, last);
321            }
322            if (mPieView != null) {
323                mPieView.draw(canvas);
324            }
325        }
326    }
327
328    private void drawItem(Canvas canvas, PieItem item) {
329        if (item.getView() != null) {
330            Paint p = item.isSelected() ? mSelectedPaint : mNormalPaint;
331            if (!mItems.contains(item)) {
332                p = item.isSelected() ? mSelectedPaint : mSubPaint;
333            }
334            int state = canvas.save();
335            if (onTheLeft()) {
336                canvas.scale(-1, 1);
337            }
338            float r = getDegrees(item.getStartAngle()) - 270; // degrees(0)
339            canvas.rotate(r, mCenter.x, mCenter.y);
340            canvas.drawPath(mPath, p);
341            canvas.restoreToCount(state);
342            // draw the item view
343            View view = item.getView();
344            state = canvas.save();
345            canvas.translate(view.getX(), view.getY());
346            view.draw(canvas);
347            canvas.restoreToCount(state);
348        }
349    }
350
351    private Path makeSlice(float start, float end, int outer, int inner, Point center) {
352        RectF bb =
353                new RectF(center.x - outer, center.y - outer, center.x + outer,
354                        center.y + outer);
355        RectF bbi =
356                new RectF(center.x - inner, center.y - inner, center.x + inner,
357                        center.y + inner);
358        Path path = new Path();
359        path.arcTo(bb, start, end - start, true);
360        path.arcTo(bbi, end, start - end);
361        path.close();
362        return path;
363    }
364
365    // touch handling for pie
366
367    @Override
368    public boolean onTouchEvent(MotionEvent evt) {
369        float x = evt.getX();
370        float y = evt.getY();
371        int action = evt.getActionMasked();
372        if (MotionEvent.ACTION_DOWN == action) {
373            if ((x > getWidth() - mSlop) || (x < mSlop)) {
374                setCenter((int) x, (int) y);
375                show(true);
376                return true;
377            }
378        } else if (MotionEvent.ACTION_UP == action) {
379            if (mOpen) {
380                boolean handled = false;
381                if (mPieView != null) {
382                    handled = mPieView.onTouchEvent(evt);
383                }
384                PieItem item = mCurrentItem;
385                if (!mAnimating) {
386                    deselect();
387                }
388                show(false);
389                if (!mAnimating && !handled && (item != null) && (item.getView() != null)) {
390                    item.getView().performClick();
391                }
392                return true;
393            }
394        } else if (MotionEvent.ACTION_CANCEL == action) {
395            if (mOpen) {
396                show(false);
397            }
398            if (!mAnimating) {
399                deselect();
400                invalidate();
401            }
402            return false;
403        } else if (MotionEvent.ACTION_MOVE == action) {
404            if (mAnimating) return false;
405            boolean handled = false;
406            PointF polar = getPolar(x, y);
407            int maxr = mRadius + mLevels * mRadiusInc + 50;
408            if (mPieView != null) {
409                handled = mPieView.onTouchEvent(evt);
410            }
411            if (handled) {
412                invalidate();
413                return false;
414            }
415            if (polar.y < mRadius) {
416                if (mOpenItem != null) {
417                    closeSub();
418                } else if (!mAnimating) {
419                    deselect();
420                    invalidate();
421                }
422                return false;
423            }
424            if (polar.y > maxr) {
425                deselect();
426                show(false);
427                evt.setAction(MotionEvent.ACTION_DOWN);
428                if (getParent() != null) {
429                    ((ViewGroup) getParent()).dispatchTouchEvent(evt);
430                }
431                return false;
432            }
433            PieItem item = findItem(polar);
434            if (item == null) {
435            } else if (mCurrentItem != item) {
436                onEnter(item);
437                if ((item != null) && item.isPieView() && (item.getView() != null)) {
438                    int cx = item.getView().getLeft() + (onTheLeft()
439                            ? item.getView().getWidth() : 0);
440                    int cy = item.getView().getTop();
441                    mPieView = item.getPieView();
442                    layoutPieView(mPieView, cx, cy,
443                            (item.getStartAngle() + item.getSweep()) / 2);
444                }
445                invalidate();
446            }
447        }
448        // always re-dispatch event
449        return false;
450    }
451
452    private void layoutPieView(PieView pv, int x, int y, float angle) {
453        pv.layout(x, y, onTheLeft(), angle, getHeight());
454    }
455
456    /**
457     * enter a slice for a view
458     * updates model only
459     * @param item
460     */
461    private void onEnter(PieItem item) {
462        // deselect
463        if (mCurrentItem != null) {
464            mCurrentItem.setSelected(false);
465        }
466        if (item != null) {
467            // clear up stack
468            playSoundEffect(SoundEffectConstants.CLICK);
469            item.setSelected(true);
470            mPieView = null;
471            mCurrentItem = item;
472            if ((mCurrentItem != mOpenItem) && mCurrentItem.hasItems()) {
473                openSub(mCurrentItem);
474                mOpenItem = item;
475            }
476        } else {
477            mCurrentItem = null;
478        }
479
480    }
481
482    private void animateOut(final PieItem fixed, AnimatorListener listener) {
483        if ((mCurrentItems == null) || (fixed == null)) return;
484        final float target = fixed.getStartAngle();
485        ValueAnimator anim = ValueAnimator.ofFloat(0, 1);
486        anim.addUpdateListener(new AnimatorUpdateListener() {
487            @Override
488            public void onAnimationUpdate(ValueAnimator animation) {
489                for (PieItem item : mCurrentItems) {
490                    if (item != fixed) {
491                        item.setAnimationAngle(animation.getAnimatedFraction()
492                                * (target - item.getStart()));
493                    }
494                }
495                invalidate();
496            }
497        });
498        anim.setDuration(ANIMATION);
499        anim.addListener(listener);
500        anim.start();
501    }
502
503    private void animateIn(final PieItem fixed, AnimatorListener listener) {
504        if ((mCurrentItems == null) || (fixed == null)) return;
505        final float target = fixed.getStartAngle();
506        ValueAnimator anim = ValueAnimator.ofFloat(0, 1);
507        anim.addUpdateListener(new AnimatorUpdateListener() {
508            @Override
509            public void onAnimationUpdate(ValueAnimator animation) {
510                for (PieItem item : mCurrentItems) {
511                    if (item != fixed) {
512                        item.setAnimationAngle((1 - animation.getAnimatedFraction())
513                                * (target - item.getStart()));
514                    }
515                }
516                invalidate();
517
518            }
519
520        });
521        anim.setDuration(ANIMATION);
522        anim.addListener(listener);
523        anim.start();
524    }
525
526    private void openSub(final PieItem item) {
527        mAnimating = true;
528        animateOut(item, new AnimatorListenerAdapter() {
529            public void onAnimationEnd(Animator a) {
530                for (PieItem item : mCurrentItems) {
531                    item.setAnimationAngle(0);
532                }
533                mCurrentItems = new ArrayList<PieItem>(mItems.size());
534                int i = 0, j = 0;
535                while (i < mItems.size()) {
536                    if (mItems.get(i) == item) {
537                        mCurrentItems.add(item);
538                    } else {
539                        mCurrentItems.add(item.getItems().get(j++));
540                    }
541                    i++;
542                }
543                layoutPie();
544                animateIn(item, new AnimatorListenerAdapter() {
545                    public void onAnimationEnd(Animator a) {
546                        for (PieItem item : mCurrentItems) {
547                            item.setAnimationAngle(0);
548                        }
549                        mAnimating = false;
550                    }
551                });
552            }
553        });
554    }
555
556    private void closeSub() {
557        mAnimating = true;
558        if (mCurrentItem != null) {
559            mCurrentItem.setSelected(false);
560        }
561        animateOut(mOpenItem, new AnimatorListenerAdapter() {
562            public void onAnimationEnd(Animator a) {
563                for (PieItem item : mCurrentItems) {
564                    item.setAnimationAngle(0);
565                }
566                mCurrentItems = mItems;
567                mPieView = null;
568                animateIn(mOpenItem, new AnimatorListenerAdapter() {
569                    public void onAnimationEnd(Animator a) {
570                        for (PieItem item : mCurrentItems) {
571                            item.setAnimationAngle(0);
572                        }
573                        mAnimating = false;
574                        mOpenItem = null;
575                        mCurrentItem = null;
576                    }
577                });
578            }
579        });
580    }
581
582    private void deselect() {
583        if (mCurrentItem != null) {
584            mCurrentItem.setSelected(false);
585        }
586        if (mOpenItem != null) {
587            mOpenItem = null;
588            mCurrentItems = mItems;
589        }
590        mCurrentItem = null;
591        mPieView = null;
592    }
593
594    private PointF getPolar(float x, float y) {
595        PointF res = new PointF();
596        // get angle and radius from x/y
597        res.x = (float) Math.PI / 2;
598        x = mCenter.x - x;
599        if (mCenter.x < mSlop) {
600            x = -x;
601        }
602        y = mCenter.y - y;
603        res.y = (float) Math.sqrt(x * x + y * y);
604        if (y > 0) {
605            res.x = (float) Math.asin(x / res.y);
606        } else if (y < 0) {
607            res.x = (float) (Math.PI - Math.asin(x / res.y ));
608        }
609        return res;
610    }
611
612    /**
613     *
614     * @param polar x: angle, y: dist
615     * @return the item at angle/dist or null
616     */
617    private PieItem findItem(PointF polar) {
618        // find the matching item:
619        for (PieItem item : mCurrentItems) {
620            if (inside(polar, mTouchOffset, item)) {
621                return item;
622            }
623        }
624        return null;
625    }
626
627    private boolean inside(PointF polar, float offset, PieItem item) {
628        return (item.getInnerRadius() - offset < polar.y)
629        && (item.getOuterRadius() - offset > polar.y)
630        && (item.getStartAngle() < polar.x)
631        && (item.getStartAngle() + item.getSweep() > polar.x);
632    }
633
634}
635