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 */ 180 protected static float fun(float x) { 181 return (float) Math.pow(x, 3) - x / 4; 182 } 183 184 public InteractiveLineGraphView(Context context) { 185 this(context, null, 0); 186 } 187 188 public InteractiveLineGraphView(Context context, AttributeSet attrs) { 189 this(context, attrs, 0); 190 } 191 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 */ 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 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 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 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 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 */ 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 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 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 */ 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 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 */ 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 */ 952 public RectF getCurrentViewport() { 953 return new RectF(mCurrentViewport); 954 } 955 956 /** 957 * Sets the chart's current viewport. 958 * 959 * @see #getCurrentViewport() 960 */ 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 */ 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 */ 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 */ 996 public void panLeft() { 997 fling((int) (-PAN_VELOCITY_FACTOR * getWidth()), 0); 998 } 999 1000 /** 1001 * Smoothly pans the chart right one step. 1002 */ 1003 public void panRight() { 1004 fling((int) (PAN_VELOCITY_FACTOR * getWidth()), 0); 1005 } 1006 1007 /** 1008 * Smoothly pans the chart up one step. 1009 */ 1010 public void panUp() { 1011 fling(0, (int) (-PAN_VELOCITY_FACTOR * getHeight())); 1012 } 1013 1014 /** 1015 * Smoothly pans the chart down one step. 1016 */ 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 1027 public float getLabelTextSize() { 1028 return mLabelTextSize; 1029 } 1030 1031 public void setLabelTextSize(float labelTextSize) { 1032 mLabelTextSize = labelTextSize; 1033 initPaints(); 1034 ViewCompat.postInvalidateOnAnimation(this); 1035 } 1036 1037 public int getLabelTextColor() { 1038 return mLabelTextColor; 1039 } 1040 1041 public void setLabelTextColor(int labelTextColor) { 1042 mLabelTextColor = labelTextColor; 1043 initPaints(); 1044 ViewCompat.postInvalidateOnAnimation(this); 1045 } 1046 1047 public float getGridThickness() { 1048 return mGridThickness; 1049 } 1050 1051 public void setGridThickness(float gridThickness) { 1052 mGridThickness = gridThickness; 1053 initPaints(); 1054 ViewCompat.postInvalidateOnAnimation(this); 1055 } 1056 1057 public int getGridColor() { 1058 return mGridColor; 1059 } 1060 1061 public void setGridColor(int gridColor) { 1062 mGridColor = gridColor; 1063 initPaints(); 1064 ViewCompat.postInvalidateOnAnimation(this); 1065 } 1066 1067 public float getAxisThickness() { 1068 return mAxisThickness; 1069 } 1070 1071 public void setAxisThickness(float axisThickness) { 1072 mAxisThickness = axisThickness; 1073 initPaints(); 1074 ViewCompat.postInvalidateOnAnimation(this); 1075 } 1076 1077 public int getAxisColor() { 1078 return mAxisColor; 1079 } 1080 1081 public void setAxisColor(int axisColor) { 1082 mAxisColor = axisColor; 1083 initPaints(); 1084 ViewCompat.postInvalidateOnAnimation(this); 1085 } 1086 1087 public float getDataThickness() { 1088 return mDataThickness; 1089 } 1090 1091 public void setDataThickness(float dataThickness) { 1092 mDataThickness = dataThickness; 1093 } 1094 1095 public int getDataColor() { 1096 return mDataColor; 1097 } 1098 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 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 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 1136 public SavedState(Parcelable superState) { 1137 super(superState); 1138 } 1139 1140 @Override 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 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 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