PieRenderer.java revision 63e79531bd35bd5bc1fc11cbc1f93acb6b9c7b0b
1/*
2 * Copyright (C) 2012 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.camera.ui;
18
19import android.content.Context;
20import android.content.res.Resources;
21import android.graphics.Canvas;
22import android.graphics.Color;
23import android.graphics.Paint;
24import android.graphics.Path;
25import android.graphics.Point;
26import android.graphics.PointF;
27import android.graphics.RectF;
28import android.os.Handler;
29import android.os.Message;
30import android.view.MotionEvent;
31import android.view.View;
32import android.view.animation.Animation;
33import android.view.animation.Transformation;
34
35import com.android.camera.R;
36
37import java.util.ArrayList;
38import java.util.List;
39
40public class PieRenderer extends OverlayRenderer
41        implements FocusIndicator {
42
43    private static final String TAG = "CAM Pie";
44
45    // Sometimes continuous autofocus starts and stops several times quickly.
46    // These states are used to make sure the animation is run for at least some
47    // time.
48    private int mState;
49    private ScaleAnimation mAnimation = new ScaleAnimation();
50    private static final int STATE_IDLE = 0;
51    private static final int STATE_FOCUSING = 1;
52    private static final int STATE_FINISHING = 2;
53    private static final int STATE_PIE = 3;
54
55    private Runnable mDisappear = new Disappear();
56    private Animation.AnimationListener mEndAction = new EndAction();
57    private static final int SCALING_UP_TIME = 600;
58    private static final int SCALING_DOWN_TIME = 100;
59    private static final int DISAPPEAR_TIMEOUT = 200;
60    private static final int DIAL_HORIZONTAL = 157;
61
62    private static final long PIE_OPEN_DELAY = 200;
63    private static final long FOCUS_TAP_TIMEOUT = 500;
64
65    private static final int MSG_OPEN = 2;
66    private static final int MSG_CLOSE = 3;
67    private static final int MSG_SUBMENU = 4;
68    private static final int MSG_FOCUS_TAP = 5;
69    private static final float PIE_SWEEP = (float)(Math.PI * 2 / 3);
70    // geometry
71    private Point mCenter;
72    private int mRadius;
73    private int mRadiusInc;
74    private int mSlop;
75
76    // the detection if touch is inside a slice is offset
77    // inbounds by this amount to allow the selection to show before the
78    // finger covers it
79    private int mTouchOffset;
80
81    private List<PieItem> mItems;
82
83    private PieItem mOpenItem;
84
85    private Paint mNormalPaint;
86    private Paint mSelectedPaint;
87    private Paint mSubPaint;
88
89    // touch handling
90    private PieItem mCurrentItem;
91
92    private Paint mFocusPaint;
93    private int mSuccessColor;
94    private int mFailColor;
95    private Paint mDotPaint;
96    private int mCircleSize;
97    private int mDotRadius;
98    private int mFocusX;
99    private int mFocusY;
100    private int mCenterX;
101    private int mCenterY;
102
103    private int mDialAngle;
104    private RectF mCircle;
105    private RectF mDial;
106    private Point mPoint1;
107    private Point mPoint2;
108    private int mStartAnimationAngle;
109    private boolean mFocused;
110    private int mInnerOffset;
111    private int mOuterStroke;
112    private int mInnerStroke;
113    private boolean mFocusFromTap;
114    private boolean mTapMode;
115
116
117    private Handler mHandler = new Handler() {
118        public void handleMessage(Message msg) {
119            switch(msg.what) {
120            case MSG_OPEN:
121                if (mListener != null) {
122                    mListener.onPieOpened(mCenter.x, mCenter.y);
123                }
124                break;
125            case MSG_CLOSE:
126                if (mListener != null) {
127                    mListener.onPieClosed();
128                }
129                break;
130            case MSG_SUBMENU:
131                openCurrentItem();
132                break;
133            case MSG_FOCUS_TAP:
134                // reset flag
135                mTapMode = false;
136                if (mState == STATE_PIE) {
137                    show(false);
138                }
139                break;
140            }
141        }
142    };
143
144    private PieListener mListener;
145
146    static public interface PieListener {
147        public void onPieOpened(int centerX, int centerY);
148        public void onPieClosed();
149    }
150
151    public void setPieListener(PieListener pl) {
152        mListener = pl;
153    }
154
155    public PieRenderer(Context context) {
156        init(context);
157    }
158    private void init(Context ctx) {
159        setVisible(false);
160        mItems = new ArrayList<PieItem>();
161        Resources res = ctx.getResources();
162        mRadius = (int) res.getDimensionPixelSize(R.dimen.pie_radius_start);
163        mCircleSize = mRadius - res.getDimensionPixelSize(R.dimen.focus_radius_offset);
164        mRadiusInc =  (int) res.getDimensionPixelSize(R.dimen.pie_radius_increment);
165        mSlop = (int) res.getDimensionPixelSize(R.dimen.pie_touch_slop);
166        mTouchOffset = (int) res.getDimensionPixelSize(R.dimen.pie_touch_offset);
167        mCenter = new Point(0,0);
168        mNormalPaint = new Paint();
169        mNormalPaint.setColor(Color.argb(0, 0, 0, 0));
170        mNormalPaint.setAntiAlias(true);
171        mSelectedPaint = new Paint();
172        mSelectedPaint.setColor(Color.argb(255, 51, 181, 229));
173        mSelectedPaint.setAntiAlias(true);
174        mSubPaint = new Paint();
175        mSubPaint.setAntiAlias(true);
176        mSubPaint.setColor(Color.argb(200, 250, 230, 128));
177        mFocusPaint = new Paint();
178        mFocusPaint.setAntiAlias(true);
179        mFocusPaint.setColor(Color.WHITE);
180        mFocusPaint.setStyle(Paint.Style.STROKE);
181        mSuccessColor = Color.GREEN;
182        mFailColor = Color.RED;
183        mDotPaint = new Paint();
184        mDotPaint.setAntiAlias(true);
185        mDotPaint.setColor(Color.argb(160, 255, 255, 255));
186        mDotPaint.setStyle(Paint.Style.FILL);
187        mCircle = new RectF();
188        mDial = new RectF();
189        mPoint1 = new Point();
190        mPoint2 = new Point();
191        mInnerOffset = res.getDimensionPixelSize(R.dimen.focus_inner_offset);
192        mOuterStroke = res.getDimensionPixelSize(R.dimen.focus_outer_stroke);
193        mInnerStroke = res.getDimensionPixelSize(R.dimen.focus_inner_stroke);
194        mDotRadius = res.getDimensionPixelSize(R.dimen.focus_dot_radius);
195        mState = STATE_IDLE;
196    }
197
198    public boolean showsItems() {
199        return mTapMode;
200    }
201
202    public void addItem(PieItem item) {
203        // add the item to the pie itself
204        mItems.add(item);
205    }
206
207    public void removeItem(PieItem item) {
208        mItems.remove(item);
209    }
210
211    public void clearItems() {
212        mItems.clear();
213    }
214
215    public void showInCenter() {
216        if (isVisible()) {
217            mTapMode = false;
218            show(false);
219        } else {
220            setCenter(mCenterX, mCenterY);
221            mTapMode = true;
222            show(true);
223        }
224    }
225
226    /**
227     * guaranteed has center set
228     * @param show
229     */
230    private void show(boolean show) {
231        if (show) {
232            mState = STATE_PIE;
233            // ensure clean state
234            mCurrentItem = null;
235            mOpenItem = null;
236            for (PieItem item : mItems) {
237                item.setSelected(false);
238            }
239            layoutPie();
240        } else {
241            mState = STATE_IDLE;
242        }
243        setVisible(show);
244        mHandler.sendEmptyMessage(show ? MSG_OPEN : MSG_CLOSE);
245    }
246
247    public void setCenter(int x, int y) {
248        mCenter.x = x;
249        mCenter.y = y;
250        // when using the pie menu, align the focus ring
251        alignFocus(x, y);
252    }
253
254    private void setupPie(int x, int y) {
255        // when using the focus ring, align pie items
256        mCenter.x = x;
257        mCenter.y = y;
258        mCurrentItem = null;
259        mOpenItem = null;
260        for (PieItem item : mItems) {
261            item.setSelected(false);
262        }
263        layoutPie();
264    }
265
266    private void layoutPie() {
267        int rgap = 2;
268        int inner = mRadius + rgap;
269        int outer = mRadius + mRadiusInc - rgap;
270        int gap = 1;
271        layoutItems(mItems, (float) (Math.PI / 2), inner, outer, gap);
272    }
273
274    private void layoutItems(List<PieItem> items, float centerAngle, int inner,
275            int outer, int gap) {
276        float emptyangle = PIE_SWEEP / 16;
277        float sweep = (float) (PIE_SWEEP - 2 * emptyangle) / items.size();
278        float angle = centerAngle - PIE_SWEEP / 2 + emptyangle + sweep / 2;
279        // check if we have custom geometry
280        // first item we find triggers custom sweep for all
281        // this allows us to re-use the path
282        for (PieItem item : items) {
283            if (item.getCenter() >= 0) {
284                sweep = item.getSweep();
285                break;
286            }
287        }
288        Path path = makeSlice(getDegrees(0) - gap, getDegrees(sweep) + gap,
289                outer, inner, mCenter);
290        for (PieItem item : items) {
291            // shared between items
292            item.setPath(path);
293            View view = item.getView();
294            if (item.getCenter() >= 0) {
295                angle = item.getCenter();
296            }
297            if (view != null) {
298                view.measure(view.getLayoutParams().width,
299                        view.getLayoutParams().height);
300                int w = view.getMeasuredWidth();
301                int h = view.getMeasuredHeight();
302                // move views to outer border
303                int r = inner + (outer - inner) * 2 / 3;
304                int x = (int) (r * Math.cos(angle));
305                int y = mCenter.y - (int) (r * Math.sin(angle)) - h / 2;
306                x = mCenter.x + x - w / 2;
307                view.layout(x, y, x + w, y + h);
308            }
309            float itemstart = angle - sweep / 2;
310            item.setGeometry(itemstart, sweep, inner, outer);
311            if (item.hasItems()) {
312                layoutItems(item.getItems(), angle, inner,
313                        outer + mRadiusInc / 2, gap);
314            }
315            angle += sweep;
316        }
317    }
318
319    private Path makeSlice(float start, float end, int outer, int inner, Point center) {
320        RectF bb =
321                new RectF(center.x - outer, center.y - outer, center.x + outer,
322                        center.y + outer);
323        RectF bbi =
324                new RectF(center.x - inner, center.y - inner, center.x + inner,
325                        center.y + inner);
326        Path path = new Path();
327        path.arcTo(bb, start, end - start, true);
328        path.arcTo(bbi, end, start - end);
329        path.close();
330        return path;
331    }
332
333    /**
334     * converts a
335     * @param angle from 0..PI to Android degrees (clockwise starting at 3 o'clock)
336     * @return skia angle
337     */
338    private float getDegrees(double angle) {
339        return (float) (360 - 180 * angle / Math.PI);
340    }
341
342    @Override
343    public void onDraw(Canvas canvas) {
344        drawFocus(canvas);
345        if (mState == STATE_FINISHING) return;
346        if (mOpenItem == null) {
347            // draw base menu
348            for (PieItem item : mItems) {
349                drawItem(canvas, item);
350            }
351        } else {
352            for (PieItem inner : mOpenItem.getItems()) {
353                drawItem(canvas, inner);
354            }
355        }
356    }
357
358    private void drawItem(Canvas canvas, PieItem item) {
359        if (item.getView() != null) {
360            if (mState == STATE_PIE) {
361                if (item.getPath() != null) {
362                    Paint p = item.isSelected() ? mSelectedPaint : mNormalPaint;
363                    int state = canvas.save();
364                    float r = getDegrees(item.getStartAngle());
365                    canvas.rotate(r, mCenter.x, mCenter.y);
366                    canvas.drawPath(item.getPath(), p);
367                    canvas.restoreToCount(state);
368                    // draw the item view
369                    View view = item.getView();
370                    state = canvas.save();
371                    canvas.translate(view.getX(), view.getY());
372                    view.draw(canvas);
373                    canvas.restoreToCount(state);
374                }
375            } else if (mState == STATE_FOCUSING && !mFocusFromTap) {
376                View view = item.getView();
377                canvas.drawCircle(view.getLeft() + view.getWidth() / 2,
378                        view.getTop() + view.getHeight() / 2, mDotRadius,
379                        mDotPaint);
380            }
381        }
382    }
383
384    // touch handling for pie
385
386    @Override
387    public boolean onTouchEvent(MotionEvent evt) {
388        float x = evt.getX();
389        float y = evt.getY();
390        int action = evt.getActionMasked();
391        PointF polar = getPolar(x, y, !(mTapMode));
392        if (MotionEvent.ACTION_DOWN == action) {
393            if (mTapMode) {
394                PieItem item = findItem(polar);
395                if ((item != null) && (mCurrentItem != item)) {
396                    mHandler.removeMessages(MSG_FOCUS_TAP);
397                    mState = STATE_PIE;
398                    onEnter(item);
399                }
400            } else {
401                setCenter((int) x, (int) y);
402                show(true);
403            }
404            return true;
405        } else if (MotionEvent.ACTION_UP == action) {
406            if (mTapMode) {
407                PieItem item = findItem(polar);
408                if (item == null) {
409                    mState = STATE_IDLE;
410                    show(false);
411                    mTapMode = false;
412                } else {
413                    if (!item.hasItems()) {
414                        show(false);
415                        mTapMode = false;
416                        mState = STATE_IDLE;
417                        item.getView().performClick();
418                        item.setSelected(false);
419                    }
420                }
421                return true;
422            } else if (isVisible()) {
423                PieItem item = mCurrentItem;
424                deselect();
425                show(false);
426                if ((item != null) && (item.getView() != null)) {
427                    item.getView().performClick();
428                }
429                return true;
430            }
431        } else if (MotionEvent.ACTION_CANCEL == action) {
432            if (isVisible() || mTapMode) {
433                show(false);
434            }
435            deselect();
436            return false;
437        } else if (MotionEvent.ACTION_MOVE == action) {
438            if (polar.y < mRadius) {
439                if (mOpenItem != null) {
440                    mOpenItem = null;
441                } else {
442                    deselect();
443                }
444                return false;
445            }
446            PieItem item = findItem(polar);
447            if ((item != null) && (mCurrentItem != item)) {
448                onEnter(item);
449            }
450        }
451        return false;
452    }
453
454    /**
455     * enter a slice for a view
456     * updates model only
457     * @param item
458     */
459    private void onEnter(PieItem item) {
460        if (mCurrentItem != null) {
461            mCurrentItem.setSelected(false);
462        }
463        if (item != null && item.isEnabled()) {
464            item.setSelected(true);
465            mCurrentItem = item;
466            if ((mCurrentItem != mOpenItem) && mCurrentItem.hasItems()) {
467                mHandler.sendEmptyMessageDelayed(MSG_SUBMENU, PIE_OPEN_DELAY);
468            }
469        } else {
470            mCurrentItem = null;
471        }
472    }
473
474    private void deselect() {
475        if (mCurrentItem != null) {
476            mCurrentItem.setSelected(false);
477            mHandler.removeMessages(MSG_SUBMENU);
478        }
479        if (mOpenItem != null) {
480            mOpenItem = null;
481        }
482        mCurrentItem = null;
483    }
484
485    private void openCurrentItem() {
486        if ((mCurrentItem != null) && mCurrentItem.hasItems()) {
487            mCurrentItem.setSelected(false);
488            mOpenItem = mCurrentItem;
489        }
490    }
491
492    private PointF getPolar(float x, float y, boolean useOffset) {
493        PointF res = new PointF();
494        // get angle and radius from x/y
495        res.x = (float) Math.PI / 2;
496        x = x - mCenter.x;
497        y = mCenter.y - y;
498        res.y = (float) Math.sqrt(x * x + y * y);
499        if (x != 0) {
500            res.x = (float) Math.atan2(y,  x);
501            if (res.x < 0) {
502                res.x = (float) (2 * Math.PI + res.x);
503            }
504        }
505        res.y = res.y + (useOffset ? mTouchOffset : 0);
506        return res;
507    }
508
509    /**
510     * @param polar x: angle, y: dist
511     * @return the item at angle/dist or null
512     */
513    private PieItem findItem(PointF polar) {
514        // find the matching item:
515        List<PieItem> items = (mOpenItem != null) ? mOpenItem.getItems() : mItems;
516        for (PieItem item : items) {
517            if (inside(polar, item)) {
518                return item;
519            }
520        }
521        return null;
522    }
523
524    private boolean inside(PointF polar, PieItem item) {
525        return (item.getInnerRadius() < polar.y)
526                && (item.getStartAngle() < polar.x)
527                && (item.getStartAngle() + item.getSweep() > polar.x)
528                && (!mTapMode || (item.getOuterRadius() > polar.y));
529    }
530
531    @Override
532    public boolean handlesTouch() {
533        return true;
534    }
535
536    // focus specific code
537
538    public void setFocus(int x, int y, boolean startImmediately) {
539        mFocusFromTap = true;
540        mTapMode = true;
541        switch(mOverlay.getOrientation()) {
542        case 0:
543            mFocusX = x;
544            mFocusY = y;
545            break;
546        case 180:
547            mFocusX = getWidth() - x;
548            mFocusY = getHeight() - y;
549            break;
550        case 90:
551            mFocusX = getWidth() - y;
552            mFocusY = x;
553            break;
554        case 270:
555            mFocusX = y ;
556            mFocusY = getHeight() - x;
557            break;
558        }
559        setCircle(mFocusX, mFocusY);
560        setupPie(mFocusX, mFocusY);
561        if (startImmediately) {
562            // cameras that don't support focus still need to show menu
563            setVisible(true);
564            mState = STATE_PIE;
565            mHandler.removeMessages(MSG_FOCUS_TAP);
566            mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_FOCUS_TAP),
567                    FOCUS_TAP_TIMEOUT);
568            update();
569        }
570    }
571
572    public void alignFocus(int x, int y) {
573        mOverlay.removeCallbacks(mDisappear);
574        mAnimation.cancel();
575        mAnimation.reset();
576        mFocusX = x;
577        mFocusY = y;
578        mDialAngle = DIAL_HORIZONTAL;
579        setCircle(x, y);
580        mFocused = false;
581    }
582
583    public int getSize() {
584        return 2 * mCircleSize;
585    }
586
587    private int getRandomRange() {
588        return (int)(-60 + 120 * Math.random());
589    }
590
591    @Override
592    public void layout(int l, int t, int r, int b) {
593        super.layout(l, t, r, b);
594        mCenterX = (r - l) / 2;
595        mCenterY = (b - t) / 2;
596        mFocusX = mCenterX;
597        mFocusY = mCenterY;
598        setCircle(mFocusX, mFocusY);
599    }
600
601    private void setCircle(int cx, int cy) {
602        mCircle.set(cx - mCircleSize, cy - mCircleSize,
603                cx + mCircleSize, cy + mCircleSize);
604        mDial.set(cx - mCircleSize + mInnerOffset, cy - mCircleSize + mInnerOffset,
605                cx + mCircleSize - mInnerOffset, cy + mCircleSize - mInnerOffset);
606    }
607
608    public void drawFocus(Canvas canvas) {
609        mFocusPaint.setStrokeWidth(mOuterStroke);
610        canvas.drawCircle((float) mFocusX, (float) mFocusY, (float) mCircleSize, mFocusPaint);
611        int color = mFocusPaint.getColor();
612        if (mState == STATE_FINISHING) {
613            mFocusPaint.setColor(mFocused ? mSuccessColor : mFailColor);
614        }
615        mFocusPaint.setStrokeWidth(mInnerStroke);
616        canvas.drawArc(mDial, mDialAngle, 45, false, mFocusPaint);
617        canvas.drawArc(mDial, mDialAngle + 180, 45, false, mFocusPaint);
618        drawLine(canvas, mDialAngle, mFocusPaint);
619        drawLine(canvas, mDialAngle + 45, mFocusPaint);
620        drawLine(canvas, mDialAngle + 180, mFocusPaint);
621        drawLine(canvas, mDialAngle + 225, mFocusPaint);
622        mFocusPaint.setColor(color);
623    }
624
625    private void drawLine(Canvas canvas, int angle, Paint p) {
626        convertCart(angle, mCircleSize - mInnerOffset, mPoint1);
627        convertCart(angle, mCircleSize - mInnerOffset + mInnerOffset / 3, mPoint2);
628        canvas.drawLine(mPoint1.x + mFocusX, mPoint1.y + mFocusY,
629                mPoint2.x + mFocusX, mPoint2.y + mFocusY, p);
630    }
631
632    private static void convertCart(int angle, int radius, Point out) {
633        double a = 2 * Math.PI * (angle % 360) / 360;
634        out.x = (int) (radius * Math.cos(a) + 0.5);
635        out.y = (int) (radius * Math.sin(a) + 0.5);
636    }
637
638    @Override
639    public void showStart() {
640        if (mState == STATE_IDLE) {
641            if (mFocusFromTap) {
642                mHandler.removeMessages(MSG_FOCUS_TAP);
643                mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_FOCUS_TAP),
644                        FOCUS_TAP_TIMEOUT);
645            }
646            mStartAnimationAngle = 67;
647            int range = getRandomRange();
648            startAnimation(SCALING_UP_TIME,
649                    false, mStartAnimationAngle, mStartAnimationAngle + range);
650            mState = STATE_FOCUSING;
651        }
652    }
653
654    @Override
655    public void showSuccess(boolean timeout) {
656        if (mState == STATE_FOCUSING) {
657            startAnimation(SCALING_DOWN_TIME,
658                    timeout, mStartAnimationAngle);
659            mState = STATE_FINISHING;
660            mFocused = true;
661        }
662    }
663
664    @Override
665    public void showFail(boolean timeout) {
666        if (mState == STATE_FOCUSING) {
667            startAnimation(SCALING_DOWN_TIME,
668                    timeout, mStartAnimationAngle);
669            mState = STATE_FINISHING;
670            mFocused = false;
671        }
672    }
673
674    @Override
675    public void clear() {
676        if (mState == STATE_PIE) return;
677        mAnimation.cancel();
678        mFocused = false;
679        mFocusFromTap = false;
680        mOverlay.removeCallbacks(mDisappear);
681        mDisappear.run();
682    }
683
684    private void startAnimation(long duration, boolean timeout,
685            float toScale) {
686        startAnimation(duration, timeout, mDialAngle,
687                toScale);
688    }
689
690    private void startAnimation(long duration, boolean timeout,
691            float fromScale, float toScale) {
692        setVisible(true);
693        mAnimation.cancel();
694        mAnimation.reset();
695        mAnimation.setDuration(duration);
696        mAnimation.setScale(fromScale, toScale);
697        mAnimation.setAnimationListener(timeout ? mEndAction : null);
698        mOverlay.startAnimation(mAnimation);
699        update();
700    }
701
702    private class EndAction implements Animation.AnimationListener {
703        @Override
704        public void onAnimationEnd(Animation animation) {
705            // Keep the focus indicator for some time.
706            mOverlay.postDelayed(mDisappear, DISAPPEAR_TIMEOUT);
707        }
708
709        @Override
710        public void onAnimationRepeat(Animation animation) {
711        }
712
713        @Override
714        public void onAnimationStart(Animation animation) {
715        }
716    }
717
718    private class Disappear implements Runnable {
719        @Override
720        public void run() {
721            setVisible(false);
722            mFocusX = mCenterX;
723            mFocusY = mCenterY;
724            mState = STATE_IDLE;
725            setCircle(mFocusX, mFocusY);
726            setupPie(mFocusX, mFocusY);
727            mFocused = false;
728        }
729    }
730
731    private class ScaleAnimation extends Animation {
732        private float mFrom = 1f;
733        private float mTo = 1f;
734
735        public ScaleAnimation() {
736            setFillAfter(true);
737        }
738
739        public void setScale(float from, float to) {
740            mFrom = from;
741            mTo = to;
742        }
743
744        @Override
745        protected void applyTransformation(float interpolatedTime, Transformation t) {
746            mDialAngle = (int)(mFrom + (mTo - mFrom) * interpolatedTime);
747        }
748    }
749
750}
751