• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2013 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 
17 package com.example.android.interactivechart;
18 
19 import android.content.Context;
20 import android.content.res.TypedArray;
21 import android.graphics.Canvas;
22 import android.graphics.Paint;
23 import android.graphics.Point;
24 import android.graphics.PointF;
25 import android.graphics.Rect;
26 import android.graphics.RectF;
27 import android.os.Parcel;
28 import android.os.Parcelable;
29 import android.support.v4.os.ParcelableCompat;
30 import android.support.v4.os.ParcelableCompatCreatorCallbacks;
31 import android.support.v4.view.GestureDetectorCompat;
32 import android.support.v4.view.ViewCompat;
33 import android.support.v4.widget.EdgeEffectCompat;
34 import android.util.AttributeSet;
35 import android.view.GestureDetector;
36 import android.view.MotionEvent;
37 import android.view.ScaleGestureDetector;
38 import android.view.View;
39 import android.widget.OverScroller;
40 
41 /**
42  * A view representing a simple yet interactive line chart for the function <code>x^3 - x/4</code>.
43  * <p>
44  * This view isn't all that useful on its own; rather it serves as an example of how to correctly
45  * implement these types of gestures to perform zooming and scrolling with interesting content
46  * types.
47  * <p>
48  * The view is interactive in that it can be zoomed and panned using
49  * typical <a href="http://developer.android.com/design/patterns/gestures.html">gestures</a> such
50  * as double-touch, drag, pinch-open, and pinch-close. This is done using the
51  * {@link ScaleGestureDetector}, {@link GestureDetector}, and {@link OverScroller} classes. Note
52  * that the platform-provided view scrolling behavior (e.g. {@link View#scrollBy(int, int)} is NOT
53  * used.
54  * <p>
55  * The view also demonstrates the correct use of
56  * <a href="http://developer.android.com/design/style/touch-feedback.html">touch feedback</a> to
57  * indicate to users that they've reached the content edges after a pan or fling gesture. This
58  * is done using the {@link EdgeEffectCompat} class.
59  * <p>
60  * Finally, this class demonstrates the basics of creating a custom view, including support for
61  * custom attributes (see the constructors), a simple implementation for
62  * {@link #onMeasure(int, int)}, an implementation for {@link #onSaveInstanceState()} and a fairly
63  * straightforward {@link Canvas}-based rendering implementation in
64  * {@link #onDraw(android.graphics.Canvas)}.
65  * <p>
66  * Note that this view doesn't automatically support directional navigation or other accessibility
67  * methods. Activities using this view should generally provide alternate navigation controls.
68  * Activities using this view should also present an alternate, text-based representation of this
69  * view's content for vision-impaired users.
70  */
71 public class InteractiveLineGraphView extends View {
72     private static final String TAG = "InteractiveLineGraphView";
73 
74     /**
75      * The number of individual points (samples) in the chart series to draw onscreen.
76      */
77     private static final int DRAW_STEPS = 30;
78 
79     /**
80      * Initial fling velocity for pan operations, in screen widths (or heights) per second.
81      *
82      * @see #panLeft()
83      * @see #panRight()
84      * @see #panUp()
85      * @see #panDown()
86      */
87     private static final float PAN_VELOCITY_FACTOR = 2f;
88 
89     /**
90      * The scaling factor for a single zoom 'step'.
91      *
92      * @see #zoomIn()
93      * @see #zoomOut()
94      */
95     private static final float ZOOM_AMOUNT = 0.25f;
96 
97     // Viewport extremes. See mCurrentViewport for a discussion of the viewport.
98     private static final float AXIS_X_MIN = -1f;
99     private static final float AXIS_X_MAX = 1f;
100     private static final float AXIS_Y_MIN = -1f;
101     private static final float AXIS_Y_MAX = 1f;
102 
103     /**
104      * The current viewport. This rectangle represents the currently visible chart domain
105      * and range. The currently visible chart X values are from this rectangle's left to its right.
106      * The currently visible chart Y values are from this rectangle's top to its bottom.
107      * <p>
108      * Note that this rectangle's top is actually the smaller Y value, and its bottom is the larger
109      * Y value. Since the chart is drawn onscreen in such a way that chart Y values increase
110      * towards the top of the screen (decreasing pixel Y positions), this rectangle's "top" is drawn
111      * above this rectangle's "bottom" value.
112      *
113      * @see #mContentRect
114      */
115     private RectF mCurrentViewport = new RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX);
116 
117     /**
118      * The current destination rectangle (in pixel coordinates) into which the chart data should
119      * be drawn. Chart labels are drawn outside this area.
120      *
121      * @see #mCurrentViewport
122      */
123     private Rect mContentRect = new Rect();
124 
125     // Current attribute values and Paints.
126     private float mLabelTextSize;
127     private int mLabelSeparation;
128     private int mLabelTextColor;
129     private Paint mLabelTextPaint;
130     private int mMaxLabelWidth;
131     private int mLabelHeight;
132     private float mGridThickness;
133     private int mGridColor;
134     private Paint mGridPaint;
135     private float mAxisThickness;
136     private int mAxisColor;
137     private Paint mAxisPaint;
138     private float mDataThickness;
139     private int mDataColor;
140     private Paint mDataPaint;
141 
142     // State objects and values related to gesture tracking.
143     private ScaleGestureDetector mScaleGestureDetector;
144     private GestureDetectorCompat mGestureDetector;
145     private OverScroller mScroller;
146     private Zoomer mZoomer;
147     private PointF mZoomFocalPoint = new PointF();
148     private RectF mScrollerStartViewport = new RectF(); // Used only for zooms and flings.
149 
150     // Edge effect / overscroll tracking objects.
151     private EdgeEffectCompat mEdgeEffectTop;
152     private EdgeEffectCompat mEdgeEffectBottom;
153     private EdgeEffectCompat mEdgeEffectLeft;
154     private EdgeEffectCompat mEdgeEffectRight;
155 
156     private boolean mEdgeEffectTopActive;
157     private boolean mEdgeEffectBottomActive;
158     private boolean mEdgeEffectLeftActive;
159     private boolean mEdgeEffectRightActive;
160 
161     // Buffers for storing current X and Y stops. See the computeAxisStops method for more details.
162     private final AxisStops mXStopsBuffer = new AxisStops();
163     private final AxisStops mYStopsBuffer = new AxisStops();
164 
165     // Buffers used during drawing. These are defined as fields to avoid allocation during
166     // draw calls.
167     private float[] mAxisXPositionsBuffer = new float[]{};
168     private float[] mAxisYPositionsBuffer = new float[]{};
169     private float[] mAxisXLinesBuffer = new float[]{};
170     private float[] mAxisYLinesBuffer = new float[]{};
171     private float[] mSeriesLinesBuffer = new float[(DRAW_STEPS + 1) * 4];
172     private final char[] mLabelBuffer = new char[100];
173     private Point mSurfaceSizeBuffer = new Point();
174 
175     /**
176      * The simple math function Y = fun(X) to draw on the chart.
177      * @param x The X value
178      * @return The Y value
179      */
fun(float x)180     protected static float fun(float x) {
181         return (float) Math.pow(x, 3) - x / 4;
182     }
183 
InteractiveLineGraphView(Context context)184     public InteractiveLineGraphView(Context context) {
185         this(context, null, 0);
186     }
187 
InteractiveLineGraphView(Context context, AttributeSet attrs)188     public InteractiveLineGraphView(Context context, AttributeSet attrs) {
189         this(context, attrs, 0);
190     }
191 
InteractiveLineGraphView(Context context, AttributeSet attrs, int defStyle)192     public InteractiveLineGraphView(Context context, AttributeSet attrs, int defStyle) {
193         super(context, attrs, defStyle);
194 
195         TypedArray a = context.getTheme().obtainStyledAttributes(
196                 attrs, R.styleable.InteractiveLineGraphView, defStyle, defStyle);
197 
198         try {
199             mLabelTextColor = a.getColor(
200                     R.styleable.InteractiveLineGraphView_labelTextColor, mLabelTextColor);
201             mLabelTextSize = a.getDimension(
202                     R.styleable.InteractiveLineGraphView_labelTextSize, mLabelTextSize);
203             mLabelSeparation = a.getDimensionPixelSize(
204                     R.styleable.InteractiveLineGraphView_labelSeparation, mLabelSeparation);
205 
206             mGridThickness = a.getDimension(
207                     R.styleable.InteractiveLineGraphView_gridThickness, mGridThickness);
208             mGridColor = a.getColor(
209                     R.styleable.InteractiveLineGraphView_gridColor, mGridColor);
210 
211             mAxisThickness = a.getDimension(
212                     R.styleable.InteractiveLineGraphView_axisThickness, mAxisThickness);
213             mAxisColor = a.getColor(
214                     R.styleable.InteractiveLineGraphView_axisColor, mAxisColor);
215 
216             mDataThickness = a.getDimension(
217                     R.styleable.InteractiveLineGraphView_dataThickness, mDataThickness);
218             mDataColor = a.getColor(
219                     R.styleable.InteractiveLineGraphView_dataColor, mDataColor);
220         } finally {
221             a.recycle();
222         }
223 
224         initPaints();
225 
226         // Sets up interactions
227         mScaleGestureDetector = new ScaleGestureDetector(context, mScaleGestureListener);
228         mGestureDetector = new GestureDetectorCompat(context, mGestureListener);
229 
230         mScroller = new OverScroller(context);
231         mZoomer = new Zoomer(context);
232 
233         // Sets up edge effects
234         mEdgeEffectLeft = new EdgeEffectCompat(context);
235         mEdgeEffectTop = new EdgeEffectCompat(context);
236         mEdgeEffectRight = new EdgeEffectCompat(context);
237         mEdgeEffectBottom = new EdgeEffectCompat(context);
238     }
239 
240     /**
241      * (Re)initializes {@link Paint} objects based on current attribute values.
242      */
initPaints()243     private void initPaints() {
244         mLabelTextPaint = new Paint();
245         mLabelTextPaint.setAntiAlias(true);
246         mLabelTextPaint.setTextSize(mLabelTextSize);
247         mLabelTextPaint.setColor(mLabelTextColor);
248         mLabelHeight = (int) Math.abs(mLabelTextPaint.getFontMetrics().top);
249         mMaxLabelWidth = (int) mLabelTextPaint.measureText("0000");
250 
251         mGridPaint = new Paint();
252         mGridPaint.setStrokeWidth(mGridThickness);
253         mGridPaint.setColor(mGridColor);
254         mGridPaint.setStyle(Paint.Style.STROKE);
255 
256         mAxisPaint = new Paint();
257         mAxisPaint.setStrokeWidth(mAxisThickness);
258         mAxisPaint.setColor(mAxisColor);
259         mAxisPaint.setStyle(Paint.Style.STROKE);
260 
261         mDataPaint = new Paint();
262         mDataPaint.setStrokeWidth(mDataThickness);
263         mDataPaint.setColor(mDataColor);
264         mDataPaint.setStyle(Paint.Style.STROKE);
265         mDataPaint.setAntiAlias(true);
266     }
267 
268     @Override
onSizeChanged(int w, int h, int oldw, int oldh)269     protected void onSizeChanged(int w, int h, int oldw, int oldh) {
270         super.onSizeChanged(w, h, oldw, oldh);
271         mContentRect.set(
272                 getPaddingLeft() + mMaxLabelWidth + mLabelSeparation,
273                 getPaddingTop(),
274                 getWidth() - getPaddingRight(),
275                 getHeight() - getPaddingBottom() - mLabelHeight - mLabelSeparation);
276     }
277 
278     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)279     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
280         int minChartSize = getResources().getDimensionPixelSize(R.dimen.min_chart_size);
281         setMeasuredDimension(
282                 Math.max(getSuggestedMinimumWidth(),
283                         resolveSize(minChartSize + getPaddingLeft() + mMaxLabelWidth
284                                 + mLabelSeparation + getPaddingRight(),
285                                 widthMeasureSpec)),
286                 Math.max(getSuggestedMinimumHeight(),
287                         resolveSize(minChartSize + getPaddingTop() + mLabelHeight
288                                 + mLabelSeparation + getPaddingBottom(),
289                                 heightMeasureSpec)));
290     }
291 
292     ////////////////////////////////////////////////////////////////////////////////////////////////
293     //
294     //     Methods and objects related to drawing
295     //
296     ////////////////////////////////////////////////////////////////////////////////////////////////
297 
298     @Override
onDraw(Canvas canvas)299     protected void onDraw(Canvas canvas) {
300         super.onDraw(canvas);
301 
302         // Draws axes and text labels
303         drawAxes(canvas);
304 
305         // Clips the next few drawing operations to the content area
306         int clipRestoreCount = canvas.save();
307         canvas.clipRect(mContentRect);
308 
309         drawDataSeriesUnclipped(canvas);
310         drawEdgeEffectsUnclipped(canvas);
311 
312         // Removes clipping rectangle
313         canvas.restoreToCount(clipRestoreCount);
314 
315         // Draws chart container
316         canvas.drawRect(mContentRect, mAxisPaint);
317     }
318 
319     /**
320      * Draws the chart axes and labels onto the canvas.
321      */
drawAxes(Canvas canvas)322     private void drawAxes(Canvas canvas) {
323         // Computes axis stops (in terms of numerical value and position on screen)
324         int i;
325 
326         computeAxisStops(
327                 mCurrentViewport.left,
328                 mCurrentViewport.right,
329                 mContentRect.width() / mMaxLabelWidth / 2,
330                 mXStopsBuffer);
331         computeAxisStops(
332                 mCurrentViewport.top,
333                 mCurrentViewport.bottom,
334                 mContentRect.height() / mLabelHeight / 2,
335                 mYStopsBuffer);
336 
337         // Avoid unnecessary allocations during drawing. Re-use allocated
338         // arrays and only reallocate if the number of stops grows.
339         if (mAxisXPositionsBuffer.length < mXStopsBuffer.numStops) {
340             mAxisXPositionsBuffer = new float[mXStopsBuffer.numStops];
341         }
342         if (mAxisYPositionsBuffer.length < mYStopsBuffer.numStops) {
343             mAxisYPositionsBuffer = new float[mYStopsBuffer.numStops];
344         }
345         if (mAxisXLinesBuffer.length < mXStopsBuffer.numStops * 4) {
346             mAxisXLinesBuffer = new float[mXStopsBuffer.numStops * 4];
347         }
348         if (mAxisYLinesBuffer.length < mYStopsBuffer.numStops * 4) {
349             mAxisYLinesBuffer = new float[mYStopsBuffer.numStops * 4];
350         }
351 
352         // Compute positions
353         for (i = 0; i < mXStopsBuffer.numStops; i++) {
354             mAxisXPositionsBuffer[i] = getDrawX(mXStopsBuffer.stops[i]);
355         }
356         for (i = 0; i < mYStopsBuffer.numStops; i++) {
357             mAxisYPositionsBuffer[i] = getDrawY(mYStopsBuffer.stops[i]);
358         }
359 
360         // Draws grid lines using drawLines (faster than individual drawLine calls)
361         for (i = 0; i < mXStopsBuffer.numStops; i++) {
362             mAxisXLinesBuffer[i * 4 + 0] = (float) Math.floor(mAxisXPositionsBuffer[i]);
363             mAxisXLinesBuffer[i * 4 + 1] = mContentRect.top;
364             mAxisXLinesBuffer[i * 4 + 2] = (float) Math.floor(mAxisXPositionsBuffer[i]);
365             mAxisXLinesBuffer[i * 4 + 3] = mContentRect.bottom;
366         }
367         canvas.drawLines(mAxisXLinesBuffer, 0, mXStopsBuffer.numStops * 4, mGridPaint);
368 
369         for (i = 0; i < mYStopsBuffer.numStops; i++) {
370             mAxisYLinesBuffer[i * 4 + 0] = mContentRect.left;
371             mAxisYLinesBuffer[i * 4 + 1] = (float) Math.floor(mAxisYPositionsBuffer[i]);
372             mAxisYLinesBuffer[i * 4 + 2] = mContentRect.right;
373             mAxisYLinesBuffer[i * 4 + 3] = (float) Math.floor(mAxisYPositionsBuffer[i]);
374         }
375         canvas.drawLines(mAxisYLinesBuffer, 0, mYStopsBuffer.numStops * 4, mGridPaint);
376 
377         // Draws X labels
378         int labelOffset;
379         int labelLength;
380         mLabelTextPaint.setTextAlign(Paint.Align.CENTER);
381         for (i = 0; i < mXStopsBuffer.numStops; i++) {
382             // Do not use String.format in high-performance code such as onDraw code.
383             labelLength = formatFloat(mLabelBuffer, mXStopsBuffer.stops[i], mXStopsBuffer.decimals);
384             labelOffset = mLabelBuffer.length - labelLength;
385             canvas.drawText(
386                     mLabelBuffer, labelOffset, labelLength,
387                     mAxisXPositionsBuffer[i],
388                     mContentRect.bottom + mLabelHeight + mLabelSeparation,
389                     mLabelTextPaint);
390         }
391 
392         // Draws Y labels
393         mLabelTextPaint.setTextAlign(Paint.Align.RIGHT);
394         for (i = 0; i < mYStopsBuffer.numStops; i++) {
395             // Do not use String.format in high-performance code such as onDraw code.
396             labelLength = formatFloat(mLabelBuffer, mYStopsBuffer.stops[i], mYStopsBuffer.decimals);
397             labelOffset = mLabelBuffer.length - labelLength;
398             canvas.drawText(
399                     mLabelBuffer, labelOffset, labelLength,
400                     mContentRect.left - mLabelSeparation,
401                     mAxisYPositionsBuffer[i] + mLabelHeight / 2,
402                     mLabelTextPaint);
403         }
404     }
405 
406     /**
407      * Rounds the given number to the given number of significant digits. Based on an answer on
408      * <a href="http://stackoverflow.com/questions/202302">Stack Overflow</a>.
409      */
roundToOneSignificantFigure(double num)410     private static float roundToOneSignificantFigure(double num) {
411         final float d = (float) Math.ceil((float) Math.log10(num < 0 ? -num : num));
412         final int power = 1 - (int) d;
413         final float magnitude = (float) Math.pow(10, power);
414         final long shifted = Math.round(num * magnitude);
415         return shifted / magnitude;
416     }
417 
418     private static final int POW10[] = {1, 10, 100, 1000, 10000, 100000, 1000000};
419 
420     /**
421      * Formats a float value to the given number of decimals. Returns the length of the string.
422      * The string begins at out.length - [return value].
423      */
formatFloat(final char[] out, float val, int digits)424     private static int formatFloat(final char[] out, float val, int digits) {
425         boolean negative = false;
426         if (val == 0) {
427             out[out.length - 1] = '0';
428             return 1;
429         }
430         if (val < 0) {
431             negative = true;
432             val = -val;
433         }
434         if (digits > POW10.length) {
435             digits = POW10.length - 1;
436         }
437         val *= POW10[digits];
438         long lval = Math.round(val);
439         int index = out.length - 1;
440         int charCount = 0;
441         while (lval != 0 || charCount < (digits + 1)) {
442             int digit = (int) (lval % 10);
443             lval = lval / 10;
444             out[index--] = (char) (digit + '0');
445             charCount++;
446             if (charCount == digits) {
447                 out[index--] = '.';
448                 charCount++;
449             }
450         }
451         if (negative) {
452             out[index--] = '-';
453             charCount++;
454         }
455         return charCount;
456     }
457 
458     /**
459      * Computes the set of axis labels to show given start and stop boundaries and an ideal number
460      * of stops between these boundaries.
461      *
462      * @param start The minimum extreme (e.g. the left edge) for the axis.
463      * @param stop The maximum extreme (e.g. the right edge) for the axis.
464      * @param steps The ideal number of stops to create. This should be based on available screen
465      *              space; the more space there is, the more stops should be shown.
466      * @param outStops The destination {@link AxisStops} object to populate.
467      */
computeAxisStops(float start, float stop, int steps, AxisStops outStops)468     private static void computeAxisStops(float start, float stop, int steps, AxisStops outStops) {
469         double range = stop - start;
470         if (steps == 0 || range <= 0) {
471             outStops.stops = new float[]{};
472             outStops.numStops = 0;
473             return;
474         }
475 
476         double rawInterval = range / steps;
477         double interval = roundToOneSignificantFigure(rawInterval);
478         double intervalMagnitude = Math.pow(10, (int) Math.log10(interval));
479         int intervalSigDigit = (int) (interval / intervalMagnitude);
480         if (intervalSigDigit > 5) {
481             // Use one order of magnitude higher, to avoid intervals like 0.9 or 90
482             interval = Math.floor(10 * intervalMagnitude);
483         }
484 
485         double first = Math.ceil(start / interval) * interval;
486         double last = Math.nextUp(Math.floor(stop / interval) * interval);
487 
488         double f;
489         int i;
490         int n = 0;
491         for (f = first; f <= last; f += interval) {
492             ++n;
493         }
494 
495         outStops.numStops = n;
496 
497         if (outStops.stops.length < n) {
498             // Ensure stops contains at least numStops elements.
499             outStops.stops = new float[n];
500         }
501 
502         for (f = first, i = 0; i < n; f += interval, ++i) {
503             outStops.stops[i] = (float) f;
504         }
505 
506         if (interval < 1) {
507             outStops.decimals = (int) Math.ceil(-Math.log10(interval));
508         } else {
509             outStops.decimals = 0;
510         }
511     }
512 
513     /**
514      * Computes the pixel offset for the given X chart value. This may be outside the view bounds.
515      */
getDrawX(float x)516     private float getDrawX(float x) {
517         return mContentRect.left
518                 + mContentRect.width()
519                 * (x - mCurrentViewport.left) / mCurrentViewport.width();
520     }
521 
522     /**
523      * Computes the pixel offset for the given Y chart value. This may be outside the view bounds.
524      */
getDrawY(float y)525     private float getDrawY(float y) {
526         return mContentRect.bottom
527                 - mContentRect.height()
528                 * (y - mCurrentViewport.top) / mCurrentViewport.height();
529     }
530 
531     /**
532      * Draws the currently visible portion of the data series defined by {@link #fun(float)} to the
533      * canvas. This method does not clip its drawing, so users should call {@link Canvas#clipRect
534      * before calling this method.
535      */
drawDataSeriesUnclipped(Canvas canvas)536     private void drawDataSeriesUnclipped(Canvas canvas) {
537         mSeriesLinesBuffer[0] = mContentRect.left;
538         mSeriesLinesBuffer[1] = getDrawY(fun(mCurrentViewport.left));
539         mSeriesLinesBuffer[2] = mSeriesLinesBuffer[0];
540         mSeriesLinesBuffer[3] = mSeriesLinesBuffer[1];
541         float x;
542         for (int i = 1; i <= DRAW_STEPS; i++) {
543             mSeriesLinesBuffer[i * 4 + 0] = mSeriesLinesBuffer[(i - 1) * 4 + 2];
544             mSeriesLinesBuffer[i * 4 + 1] = mSeriesLinesBuffer[(i - 1) * 4 + 3];
545 
546             x = (mCurrentViewport.left + (mCurrentViewport.width() / DRAW_STEPS * i));
547             mSeriesLinesBuffer[i * 4 + 2] = getDrawX(x);
548             mSeriesLinesBuffer[i * 4 + 3] = getDrawY(fun(x));
549         }
550         canvas.drawLines(mSeriesLinesBuffer, mDataPaint);
551     }
552 
553     /**
554      * Draws the overscroll "glow" at the four edges of the chart region, if necessary. The edges
555      * of the chart region are stored in {@link #mContentRect}.
556      *
557      * @see EdgeEffectCompat
558      */
drawEdgeEffectsUnclipped(Canvas canvas)559     private void drawEdgeEffectsUnclipped(Canvas canvas) {
560         // The methods below rotate and translate the canvas as needed before drawing the glow,
561         // since EdgeEffectCompat always draws a top-glow at 0,0.
562 
563         boolean needsInvalidate = false;
564 
565         if (!mEdgeEffectTop.isFinished()) {
566             final int restoreCount = canvas.save();
567             canvas.translate(mContentRect.left, mContentRect.top);
568             mEdgeEffectTop.setSize(mContentRect.width(), mContentRect.height());
569             if (mEdgeEffectTop.draw(canvas)) {
570                 needsInvalidate = true;
571             }
572             canvas.restoreToCount(restoreCount);
573         }
574 
575         if (!mEdgeEffectBottom.isFinished()) {
576             final int restoreCount = canvas.save();
577             canvas.translate(2 * mContentRect.left - mContentRect.right, mContentRect.bottom);
578             canvas.rotate(180, mContentRect.width(), 0);
579             mEdgeEffectBottom.setSize(mContentRect.width(), mContentRect.height());
580             if (mEdgeEffectBottom.draw(canvas)) {
581                 needsInvalidate = true;
582             }
583             canvas.restoreToCount(restoreCount);
584         }
585 
586         if (!mEdgeEffectLeft.isFinished()) {
587             final int restoreCount = canvas.save();
588             canvas.translate(mContentRect.left, mContentRect.bottom);
589             canvas.rotate(-90, 0, 0);
590             mEdgeEffectLeft.setSize(mContentRect.height(), mContentRect.width());
591             if (mEdgeEffectLeft.draw(canvas)) {
592                 needsInvalidate = true;
593             }
594             canvas.restoreToCount(restoreCount);
595         }
596 
597         if (!mEdgeEffectRight.isFinished()) {
598             final int restoreCount = canvas.save();
599             canvas.translate(mContentRect.right, mContentRect.top);
600             canvas.rotate(90, 0, 0);
601             mEdgeEffectRight.setSize(mContentRect.height(), mContentRect.width());
602             if (mEdgeEffectRight.draw(canvas)) {
603                 needsInvalidate = true;
604             }
605             canvas.restoreToCount(restoreCount);
606         }
607 
608         if (needsInvalidate) {
609             ViewCompat.postInvalidateOnAnimation(this);
610         }
611     }
612 
613     ////////////////////////////////////////////////////////////////////////////////////////////////
614     //
615     //     Methods and objects related to gesture handling
616     //
617     ////////////////////////////////////////////////////////////////////////////////////////////////
618 
619     /**
620      * Finds the chart point (i.e. within the chart's domain and range) represented by the
621      * given pixel coordinates, if that pixel is within the chart region described by
622      * {@link #mContentRect}. If the point is found, the "dest" argument is set to the point and
623      * this function returns true. Otherwise, this function returns false and "dest" is unchanged.
624      */
hitTest(float x, float y, PointF dest)625     private boolean hitTest(float x, float y, PointF dest) {
626         if (!mContentRect.contains((int) x, (int) y)) {
627             return false;
628         }
629 
630         dest.set(
631                 mCurrentViewport.left
632                         + mCurrentViewport.width()
633                         * (x - mContentRect.left) / mContentRect.width(),
634                 mCurrentViewport.top
635                         + mCurrentViewport.height()
636                         * (y - mContentRect.bottom) / -mContentRect.height());
637         return true;
638      }
639 
640     @Override
onTouchEvent(MotionEvent event)641     public boolean onTouchEvent(MotionEvent event) {
642         boolean retVal = mScaleGestureDetector.onTouchEvent(event);
643         retVal = mGestureDetector.onTouchEvent(event) || retVal;
644         return retVal || super.onTouchEvent(event);
645     }
646 
647     /**
648      * The scale listener, used for handling multi-finger scale gestures.
649      */
650     private final ScaleGestureDetector.OnScaleGestureListener mScaleGestureListener
651             = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
652         /**
653          * This is the active focal point in terms of the viewport. Could be a local
654          * variable but kept here to minimize per-frame allocations.
655          */
656         private PointF viewportFocus = new PointF();
657         private float lastSpanX;
658         private float lastSpanY;
659 
660         @Override
661         public boolean onScaleBegin(ScaleGestureDetector scaleGestureDetector) {
662             lastSpanX = ScaleGestureDetectorCompat.getCurrentSpanX(scaleGestureDetector);
663             lastSpanY = ScaleGestureDetectorCompat.getCurrentSpanY(scaleGestureDetector);
664             return true;
665         }
666 
667         @Override
668         public boolean onScale(ScaleGestureDetector scaleGestureDetector) {
669             float spanX = ScaleGestureDetectorCompat.getCurrentSpanX(scaleGestureDetector);
670             float spanY = ScaleGestureDetectorCompat.getCurrentSpanY(scaleGestureDetector);
671 
672             float newWidth = lastSpanX / spanX * mCurrentViewport.width();
673             float newHeight = lastSpanY / spanY * mCurrentViewport.height();
674 
675             float focusX = scaleGestureDetector.getFocusX();
676             float focusY = scaleGestureDetector.getFocusY();
677             hitTest(focusX, focusY, viewportFocus);
678 
679             mCurrentViewport.set(
680                     viewportFocus.x
681                             - newWidth * (focusX - mContentRect.left)
682                             / mContentRect.width(),
683                     viewportFocus.y
684                             - newHeight * (mContentRect.bottom - focusY)
685                             / mContentRect.height(),
686                     0,
687                     0);
688             mCurrentViewport.right = mCurrentViewport.left + newWidth;
689             mCurrentViewport.bottom = mCurrentViewport.top + newHeight;
690             constrainViewport();
691             ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this);
692 
693             lastSpanX = spanX;
694             lastSpanY = spanY;
695             return true;
696         }
697     };
698 
699     /**
700      * Ensures that current viewport is inside the viewport extremes defined by {@link #AXIS_X_MIN},
701      * {@link #AXIS_X_MAX}, {@link #AXIS_Y_MIN} and {@link #AXIS_Y_MAX}.
702      */
constrainViewport()703     private void constrainViewport() {
704         mCurrentViewport.left = Math.max(AXIS_X_MIN, mCurrentViewport.left);
705         mCurrentViewport.top = Math.max(AXIS_Y_MIN, mCurrentViewport.top);
706         mCurrentViewport.bottom = Math.max(Math.nextUp(mCurrentViewport.top),
707                 Math.min(AXIS_Y_MAX, mCurrentViewport.bottom));
708         mCurrentViewport.right = Math.max(Math.nextUp(mCurrentViewport.left),
709                 Math.min(AXIS_X_MAX, mCurrentViewport.right));
710     }
711 
712     /**
713      * The gesture listener, used for handling simple gestures such as double touches, scrolls,
714      * and flings.
715      */
716     private final GestureDetector.SimpleOnGestureListener mGestureListener
717             = new GestureDetector.SimpleOnGestureListener() {
718         @Override
719         public boolean onDown(MotionEvent e) {
720             releaseEdgeEffects();
721             mScrollerStartViewport.set(mCurrentViewport);
722             mScroller.forceFinished(true);
723             ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this);
724             return true;
725         }
726 
727         @Override
728         public boolean onDoubleTap(MotionEvent e) {
729             mZoomer.forceFinished(true);
730             if (hitTest(e.getX(), e.getY(), mZoomFocalPoint)) {
731                 mZoomer.startZoom(ZOOM_AMOUNT);
732             }
733             ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this);
734             return true;
735         }
736 
737         @Override
738         public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
739             // Scrolling uses math based on the viewport (as opposed to math using pixels).
740             /**
741              * Pixel offset is the offset in screen pixels, while viewport offset is the
742              * offset within the current viewport. For additional information on surface sizes
743              * and pixel offsets, see the docs for {@link computeScrollSurfaceSize()}. For
744              * additional information about the viewport, see the comments for
745              * {@link mCurrentViewport}.
746              */
747             float viewportOffsetX = distanceX * mCurrentViewport.width() / mContentRect.width();
748             float viewportOffsetY = -distanceY * mCurrentViewport.height() / mContentRect.height();
749             computeScrollSurfaceSize(mSurfaceSizeBuffer);
750             int scrolledX = (int) (mSurfaceSizeBuffer.x
751                     * (mCurrentViewport.left + viewportOffsetX - AXIS_X_MIN)
752                     / (AXIS_X_MAX - AXIS_X_MIN));
753             int scrolledY = (int) (mSurfaceSizeBuffer.y
754                     * (AXIS_Y_MAX - mCurrentViewport.bottom - viewportOffsetY)
755                     / (AXIS_Y_MAX - AXIS_Y_MIN));
756             boolean canScrollX = mCurrentViewport.left > AXIS_X_MIN
757                     || mCurrentViewport.right < AXIS_X_MAX;
758             boolean canScrollY = mCurrentViewport.top > AXIS_Y_MIN
759                     || mCurrentViewport.bottom < AXIS_Y_MAX;
760             setViewportBottomLeft(
761                     mCurrentViewport.left + viewportOffsetX,
762                     mCurrentViewport.bottom + viewportOffsetY);
763 
764             if (canScrollX && scrolledX < 0) {
765                 mEdgeEffectLeft.onPull(scrolledX / (float) mContentRect.width());
766                 mEdgeEffectLeftActive = true;
767             }
768             if (canScrollY && scrolledY < 0) {
769                 mEdgeEffectTop.onPull(scrolledY / (float) mContentRect.height());
770                 mEdgeEffectTopActive = true;
771             }
772             if (canScrollX && scrolledX > mSurfaceSizeBuffer.x - mContentRect.width()) {
773                 mEdgeEffectRight.onPull((scrolledX - mSurfaceSizeBuffer.x + mContentRect.width())
774                         / (float) mContentRect.width());
775                 mEdgeEffectRightActive = true;
776             }
777             if (canScrollY && scrolledY > mSurfaceSizeBuffer.y - mContentRect.height()) {
778                 mEdgeEffectBottom.onPull((scrolledY - mSurfaceSizeBuffer.y + mContentRect.height())
779                         / (float) mContentRect.height());
780                 mEdgeEffectBottomActive = true;
781             }
782             return true;
783         }
784 
785         @Override
786         public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
787             fling((int) -velocityX, (int) -velocityY);
788             return true;
789         }
790     };
791 
releaseEdgeEffects()792     private void releaseEdgeEffects() {
793         mEdgeEffectLeftActive
794                 = mEdgeEffectTopActive
795                 = mEdgeEffectRightActive
796                 = mEdgeEffectBottomActive
797                 = false;
798         mEdgeEffectLeft.onRelease();
799         mEdgeEffectTop.onRelease();
800         mEdgeEffectRight.onRelease();
801         mEdgeEffectBottom.onRelease();
802     }
803 
fling(int velocityX, int velocityY)804     private void fling(int velocityX, int velocityY) {
805         releaseEdgeEffects();
806         // Flings use math in pixels (as opposed to math based on the viewport).
807         computeScrollSurfaceSize(mSurfaceSizeBuffer);
808         mScrollerStartViewport.set(mCurrentViewport);
809         int startX = (int) (mSurfaceSizeBuffer.x * (mScrollerStartViewport.left - AXIS_X_MIN) / (
810                 AXIS_X_MAX - AXIS_X_MIN));
811         int startY = (int) (mSurfaceSizeBuffer.y * (AXIS_Y_MAX - mScrollerStartViewport.bottom) / (
812                 AXIS_Y_MAX - AXIS_Y_MIN));
813         mScroller.forceFinished(true);
814         mScroller.fling(
815                 startX,
816                 startY,
817                 velocityX,
818                 velocityY,
819                 0, mSurfaceSizeBuffer.x - mContentRect.width(),
820                 0, mSurfaceSizeBuffer.y - mContentRect.height(),
821                 mContentRect.width() / 2,
822                 mContentRect.height() / 2);
823         ViewCompat.postInvalidateOnAnimation(this);
824     }
825 
826     /**
827      * Computes the current scrollable surface size, in pixels. For example, if the entire chart
828      * area is visible, this is simply the current size of {@link #mContentRect}. If the chart
829      * is zoomed in 200% in both directions, the returned size will be twice as large horizontally
830      * and vertically.
831      */
computeScrollSurfaceSize(Point out)832     private void computeScrollSurfaceSize(Point out) {
833         out.set(
834                 (int) (mContentRect.width() * (AXIS_X_MAX - AXIS_X_MIN)
835                         / mCurrentViewport.width()),
836                 (int) (mContentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN)
837                         / mCurrentViewport.height()));
838     }
839 
840     @Override
computeScroll()841     public void computeScroll() {
842         super.computeScroll();
843 
844         boolean needsInvalidate = false;
845 
846         if (mScroller.computeScrollOffset()) {
847             // The scroller isn't finished, meaning a fling or programmatic pan operation is
848             // currently active.
849 
850             computeScrollSurfaceSize(mSurfaceSizeBuffer);
851             int currX = mScroller.getCurrX();
852             int currY = mScroller.getCurrY();
853 
854             boolean canScrollX = (mCurrentViewport.left > AXIS_X_MIN
855                     || mCurrentViewport.right < AXIS_X_MAX);
856             boolean canScrollY = (mCurrentViewport.top > AXIS_Y_MIN
857                     || mCurrentViewport.bottom < AXIS_Y_MAX);
858 
859             if (canScrollX
860                     && currX < 0
861                     && mEdgeEffectLeft.isFinished()
862                     && !mEdgeEffectLeftActive) {
863                 mEdgeEffectLeft.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller));
864                 mEdgeEffectLeftActive = true;
865                 needsInvalidate = true;
866             } else if (canScrollX
867                     && currX > (mSurfaceSizeBuffer.x - mContentRect.width())
868                     && mEdgeEffectRight.isFinished()
869                     && !mEdgeEffectRightActive) {
870                 mEdgeEffectRight.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller));
871                 mEdgeEffectRightActive = true;
872                 needsInvalidate = true;
873             }
874 
875             if (canScrollY
876                     && currY < 0
877                     && mEdgeEffectTop.isFinished()
878                     && !mEdgeEffectTopActive) {
879                 mEdgeEffectTop.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller));
880                 mEdgeEffectTopActive = true;
881                 needsInvalidate = true;
882             } else if (canScrollY
883                     && currY > (mSurfaceSizeBuffer.y - mContentRect.height())
884                     && mEdgeEffectBottom.isFinished()
885                     && !mEdgeEffectBottomActive) {
886                 mEdgeEffectBottom.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller));
887                 mEdgeEffectBottomActive = true;
888                 needsInvalidate = true;
889             }
890 
891             float currXRange = AXIS_X_MIN + (AXIS_X_MAX - AXIS_X_MIN)
892                     * currX / mSurfaceSizeBuffer.x;
893             float currYRange = AXIS_Y_MAX - (AXIS_Y_MAX - AXIS_Y_MIN)
894                     * currY / mSurfaceSizeBuffer.y;
895             setViewportBottomLeft(currXRange, currYRange);
896         }
897 
898         if (mZoomer.computeZoom()) {
899             // Performs the zoom since a zoom is in progress (either programmatically or via
900             // double-touch).
901             float newWidth = (1f - mZoomer.getCurrZoom()) * mScrollerStartViewport.width();
902             float newHeight = (1f - mZoomer.getCurrZoom()) * mScrollerStartViewport.height();
903             float pointWithinViewportX = (mZoomFocalPoint.x - mScrollerStartViewport.left)
904                     / mScrollerStartViewport.width();
905             float pointWithinViewportY = (mZoomFocalPoint.y - mScrollerStartViewport.top)
906                     / mScrollerStartViewport.height();
907             mCurrentViewport.set(
908                     mZoomFocalPoint.x - newWidth * pointWithinViewportX,
909                     mZoomFocalPoint.y - newHeight * pointWithinViewportY,
910                     mZoomFocalPoint.x + newWidth * (1 - pointWithinViewportX),
911                     mZoomFocalPoint.y + newHeight * (1 - pointWithinViewportY));
912             constrainViewport();
913             needsInvalidate = true;
914         }
915 
916         if (needsInvalidate) {
917             ViewCompat.postInvalidateOnAnimation(this);
918         }
919     }
920 
921     /**
922      * Sets the current viewport (defined by {@link #mCurrentViewport}) to the given
923      * X and Y positions. Note that the Y value represents the topmost pixel position, and thus
924      * the bottom of the {@link #mCurrentViewport} rectangle. For more details on why top and
925      * bottom are flipped, see {@link #mCurrentViewport}.
926      */
setViewportBottomLeft(float x, float y)927     private void setViewportBottomLeft(float x, float y) {
928         /**
929          * Constrains within the scroll range. The scroll range is simply the viewport extremes
930          * (AXIS_X_MAX, etc.) minus the viewport size. For example, if the extrema were 0 and 10,
931          * and the viewport size was 2, the scroll range would be 0 to 8.
932          */
933 
934         float curWidth = mCurrentViewport.width();
935         float curHeight = mCurrentViewport.height();
936         x = Math.max(AXIS_X_MIN, Math.min(x, AXIS_X_MAX - curWidth));
937         y = Math.max(AXIS_Y_MIN + curHeight, Math.min(y, AXIS_Y_MAX));
938 
939         mCurrentViewport.set(x, y - curHeight, x + curWidth, y);
940         ViewCompat.postInvalidateOnAnimation(this);
941     }
942 
943     ////////////////////////////////////////////////////////////////////////////////////////////////
944     //
945     //     Methods for programmatically changing the viewport
946     //
947     ////////////////////////////////////////////////////////////////////////////////////////////////
948 
949     /**
950      * Returns the current viewport (visible extremes for the chart domain and range.)
951      */
getCurrentViewport()952     public RectF getCurrentViewport() {
953         return new RectF(mCurrentViewport);
954     }
955 
956     /**
957      * Sets the chart's current viewport.
958      *
959      * @see #getCurrentViewport()
960      */
setCurrentViewport(RectF viewport)961     public void setCurrentViewport(RectF viewport) {
962         mCurrentViewport = viewport;
963         constrainViewport();
964         ViewCompat.postInvalidateOnAnimation(this);
965     }
966 
967     /**
968      * Smoothly zooms the chart in one step.
969      */
zoomIn()970     public void zoomIn() {
971         mScrollerStartViewport.set(mCurrentViewport);
972         mZoomer.forceFinished(true);
973         mZoomer.startZoom(ZOOM_AMOUNT);
974         mZoomFocalPoint.set(
975                 (mCurrentViewport.right + mCurrentViewport.left) / 2,
976                 (mCurrentViewport.bottom + mCurrentViewport.top) / 2);
977         ViewCompat.postInvalidateOnAnimation(this);
978     }
979 
980     /**
981      * Smoothly zooms the chart out one step.
982      */
zoomOut()983     public void zoomOut() {
984         mScrollerStartViewport.set(mCurrentViewport);
985         mZoomer.forceFinished(true);
986         mZoomer.startZoom(-ZOOM_AMOUNT);
987         mZoomFocalPoint.set(
988                 (mCurrentViewport.right + mCurrentViewport.left) / 2,
989                 (mCurrentViewport.bottom + mCurrentViewport.top) / 2);
990         ViewCompat.postInvalidateOnAnimation(this);
991     }
992 
993     /**
994      * Smoothly pans the chart left one step.
995      */
panLeft()996     public void panLeft() {
997         fling((int) (-PAN_VELOCITY_FACTOR * getWidth()), 0);
998     }
999 
1000     /**
1001      * Smoothly pans the chart right one step.
1002      */
panRight()1003     public void panRight() {
1004         fling((int) (PAN_VELOCITY_FACTOR * getWidth()), 0);
1005     }
1006 
1007     /**
1008      * Smoothly pans the chart up one step.
1009      */
panUp()1010     public void panUp() {
1011         fling(0, (int) (-PAN_VELOCITY_FACTOR * getHeight()));
1012     }
1013 
1014     /**
1015      * Smoothly pans the chart down one step.
1016      */
panDown()1017     public void panDown() {
1018         fling(0, (int) (PAN_VELOCITY_FACTOR * getHeight()));
1019     }
1020 
1021     ////////////////////////////////////////////////////////////////////////////////////////////////
1022     //
1023     //     Methods related to custom attributes
1024     //
1025     ////////////////////////////////////////////////////////////////////////////////////////////////
1026 
getLabelTextSize()1027     public float getLabelTextSize() {
1028         return mLabelTextSize;
1029     }
1030 
setLabelTextSize(float labelTextSize)1031     public void setLabelTextSize(float labelTextSize) {
1032         mLabelTextSize = labelTextSize;
1033         initPaints();
1034         ViewCompat.postInvalidateOnAnimation(this);
1035     }
1036 
getLabelTextColor()1037     public int getLabelTextColor() {
1038         return mLabelTextColor;
1039     }
1040 
setLabelTextColor(int labelTextColor)1041     public void setLabelTextColor(int labelTextColor) {
1042         mLabelTextColor = labelTextColor;
1043         initPaints();
1044         ViewCompat.postInvalidateOnAnimation(this);
1045     }
1046 
getGridThickness()1047     public float getGridThickness() {
1048         return mGridThickness;
1049     }
1050 
setGridThickness(float gridThickness)1051     public void setGridThickness(float gridThickness) {
1052         mGridThickness = gridThickness;
1053         initPaints();
1054         ViewCompat.postInvalidateOnAnimation(this);
1055     }
1056 
getGridColor()1057     public int getGridColor() {
1058         return mGridColor;
1059     }
1060 
setGridColor(int gridColor)1061     public void setGridColor(int gridColor) {
1062         mGridColor = gridColor;
1063         initPaints();
1064         ViewCompat.postInvalidateOnAnimation(this);
1065     }
1066 
getAxisThickness()1067     public float getAxisThickness() {
1068         return mAxisThickness;
1069     }
1070 
setAxisThickness(float axisThickness)1071     public void setAxisThickness(float axisThickness) {
1072         mAxisThickness = axisThickness;
1073         initPaints();
1074         ViewCompat.postInvalidateOnAnimation(this);
1075     }
1076 
getAxisColor()1077     public int getAxisColor() {
1078         return mAxisColor;
1079     }
1080 
setAxisColor(int axisColor)1081     public void setAxisColor(int axisColor) {
1082         mAxisColor = axisColor;
1083         initPaints();
1084         ViewCompat.postInvalidateOnAnimation(this);
1085     }
1086 
getDataThickness()1087     public float getDataThickness() {
1088         return mDataThickness;
1089     }
1090 
setDataThickness(float dataThickness)1091     public void setDataThickness(float dataThickness) {
1092         mDataThickness = dataThickness;
1093     }
1094 
getDataColor()1095     public int getDataColor() {
1096         return mDataColor;
1097     }
1098 
setDataColor(int dataColor)1099     public void setDataColor(int dataColor) {
1100         mDataColor = dataColor;
1101     }
1102 
1103     ////////////////////////////////////////////////////////////////////////////////////////////////
1104     //
1105     //     Methods and classes related to view state persistence.
1106     //
1107     ////////////////////////////////////////////////////////////////////////////////////////////////
1108 
1109     @Override
onSaveInstanceState()1110     public Parcelable onSaveInstanceState() {
1111         Parcelable superState = super.onSaveInstanceState();
1112         SavedState ss = new SavedState(superState);
1113         ss.viewport = mCurrentViewport;
1114         return ss;
1115     }
1116 
1117     @Override
onRestoreInstanceState(Parcelable state)1118     public void onRestoreInstanceState(Parcelable state) {
1119         if (!(state instanceof SavedState)) {
1120             super.onRestoreInstanceState(state);
1121             return;
1122         }
1123 
1124         SavedState ss = (SavedState) state;
1125         super.onRestoreInstanceState(ss.getSuperState());
1126 
1127         mCurrentViewport = ss.viewport;
1128     }
1129 
1130     /**
1131      * Persistent state that is saved by InteractiveLineGraphView.
1132      */
1133     public static class SavedState extends BaseSavedState {
1134         private RectF viewport;
1135 
SavedState(Parcelable superState)1136         public SavedState(Parcelable superState) {
1137             super(superState);
1138         }
1139 
1140         @Override
writeToParcel(Parcel out, int flags)1141         public void writeToParcel(Parcel out, int flags) {
1142             super.writeToParcel(out, flags);
1143             out.writeFloat(viewport.left);
1144             out.writeFloat(viewport.top);
1145             out.writeFloat(viewport.right);
1146             out.writeFloat(viewport.bottom);
1147         }
1148 
1149         @Override
toString()1150         public String toString() {
1151             return "InteractiveLineGraphView.SavedState{"
1152                     + Integer.toHexString(System.identityHashCode(this))
1153                     + " viewport=" + viewport.toString() + "}";
1154         }
1155 
1156         public static final Parcelable.Creator<SavedState> CREATOR
1157                 = ParcelableCompat.newCreator(new ParcelableCompatCreatorCallbacks<SavedState>() {
1158             @Override
1159             public SavedState createFromParcel(Parcel in, ClassLoader loader) {
1160                 return new SavedState(in);
1161             }
1162 
1163             @Override
1164             public SavedState[] newArray(int size) {
1165                 return new SavedState[size];
1166             }
1167         });
1168 
SavedState(Parcel in)1169         SavedState(Parcel in) {
1170             super(in);
1171             viewport = new RectF(in.readFloat(), in.readFloat(), in.readFloat(), in.readFloat());
1172         }
1173     }
1174 
1175     /**
1176      * A simple class representing axis label values.
1177      *
1178      * @see #computeAxisStops
1179      */
1180     private static class AxisStops {
1181         float[] stops = new float[]{};
1182         int numStops;
1183         int decimals;
1184     }
1185 }
1186