/*
* Copyright 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.android.interactivechart;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.v4.os.ParcelableCompat;
import android.support.v4.os.ParcelableCompatCreatorCallbacks;
import android.support.v4.view.GestureDetectorCompat;
import android.support.v4.view.ViewCompat;
import android.support.v4.widget.EdgeEffectCompat;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import android.widget.OverScroller;
/**
* A view representing a simple yet interactive line chart for the function x^3 - x/4
.
*
* This view isn't all that useful on its own; rather it serves as an example of how to correctly * implement these types of gestures to perform zooming and scrolling with interesting content * types. *
* The view is interactive in that it can be zoomed and panned using * typical gestures such * as double-touch, drag, pinch-open, and pinch-close. This is done using the * {@link ScaleGestureDetector}, {@link GestureDetector}, and {@link OverScroller} classes. Note * that the platform-provided view scrolling behavior (e.g. {@link View#scrollBy(int, int)} is NOT * used. *
* The view also demonstrates the correct use of * touch feedback to * indicate to users that they've reached the content edges after a pan or fling gesture. This * is done using the {@link EdgeEffectCompat} class. *
* Finally, this class demonstrates the basics of creating a custom view, including support for * custom attributes (see the constructors), a simple implementation for * {@link #onMeasure(int, int)}, an implementation for {@link #onSaveInstanceState()} and a fairly * straightforward {@link Canvas}-based rendering implementation in * {@link #onDraw(android.graphics.Canvas)}. *
* Note that this view doesn't automatically support directional navigation or other accessibility * methods. Activities using this view should generally provide alternate navigation controls. * Activities using this view should also present an alternate, text-based representation of this * view's content for vision-impaired users. */ public class InteractiveLineGraphView extends View { private static final String TAG = "InteractiveLineGraphView"; /** * The number of individual points (samples) in the chart series to draw onscreen. */ private static final int DRAW_STEPS = 30; /** * Initial fling velocity for pan operations, in screen widths (or heights) per second. * * @see #panLeft() * @see #panRight() * @see #panUp() * @see #panDown() */ private static final float PAN_VELOCITY_FACTOR = 2f; /** * The scaling factor for a single zoom 'step'. * * @see #zoomIn() * @see #zoomOut() */ private static final float ZOOM_AMOUNT = 0.25f; // Viewport extremes. See mCurrentViewport for a discussion of the viewport. private static final float AXIS_X_MIN = -1f; private static final float AXIS_X_MAX = 1f; private static final float AXIS_Y_MIN = -1f; private static final float AXIS_Y_MAX = 1f; /** * The current viewport. This rectangle represents the currently visible chart domain * and range. The currently visible chart X values are from this rectangle's left to its right. * The currently visible chart Y values are from this rectangle's top to its bottom. *
* Note that this rectangle's top is actually the smaller Y value, and its bottom is the larger
* Y value. Since the chart is drawn onscreen in such a way that chart Y values increase
* towards the top of the screen (decreasing pixel Y positions), this rectangle's "top" is drawn
* above this rectangle's "bottom" value.
*
* @see #mContentRect
*/
private RectF mCurrentViewport = new RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX);
/**
* The current destination rectangle (in pixel coordinates) into which the chart data should
* be drawn. Chart labels are drawn outside this area.
*
* @see #mCurrentViewport
*/
private Rect mContentRect = new Rect();
// Current attribute values and Paints.
private float mLabelTextSize;
private int mLabelSeparation;
private int mLabelTextColor;
private Paint mLabelTextPaint;
private int mMaxLabelWidth;
private int mLabelHeight;
private float mGridThickness;
private int mGridColor;
private Paint mGridPaint;
private float mAxisThickness;
private int mAxisColor;
private Paint mAxisPaint;
private float mDataThickness;
private int mDataColor;
private Paint mDataPaint;
// State objects and values related to gesture tracking.
private ScaleGestureDetector mScaleGestureDetector;
private GestureDetectorCompat mGestureDetector;
private OverScroller mScroller;
private Zoomer mZoomer;
private PointF mZoomFocalPoint = new PointF();
private RectF mScrollerStartViewport = new RectF(); // Used only for zooms and flings.
// Edge effect / overscroll tracking objects.
private EdgeEffectCompat mEdgeEffectTop;
private EdgeEffectCompat mEdgeEffectBottom;
private EdgeEffectCompat mEdgeEffectLeft;
private EdgeEffectCompat mEdgeEffectRight;
private boolean mEdgeEffectTopActive;
private boolean mEdgeEffectBottomActive;
private boolean mEdgeEffectLeftActive;
private boolean mEdgeEffectRightActive;
// Buffers for storing current X and Y stops. See the computeAxisStops method for more details.
private final AxisStops mXStopsBuffer = new AxisStops();
private final AxisStops mYStopsBuffer = new AxisStops();
// Buffers used during drawing. These are defined as fields to avoid allocation during
// draw calls.
private float[] mAxisXPositionsBuffer = new float[]{};
private float[] mAxisYPositionsBuffer = new float[]{};
private float[] mAxisXLinesBuffer = new float[]{};
private float[] mAxisYLinesBuffer = new float[]{};
private float[] mSeriesLinesBuffer = new float[(DRAW_STEPS + 1) * 4];
private final char[] mLabelBuffer = new char[100];
private Point mSurfaceSizeBuffer = new Point();
/**
* The simple math function Y = fun(X) to draw on the chart.
* @param x The X value
* @return The Y value
*/
protected static float fun(float x) {
return (float) Math.pow(x, 3) - x / 4;
}
public InteractiveLineGraphView(Context context) {
this(context, null, 0);
}
public InteractiveLineGraphView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public InteractiveLineGraphView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
TypedArray a = context.getTheme().obtainStyledAttributes(
attrs, R.styleable.InteractiveLineGraphView, defStyle, defStyle);
try {
mLabelTextColor = a.getColor(
R.styleable.InteractiveLineGraphView_labelTextColor, mLabelTextColor);
mLabelTextSize = a.getDimension(
R.styleable.InteractiveLineGraphView_labelTextSize, mLabelTextSize);
mLabelSeparation = a.getDimensionPixelSize(
R.styleable.InteractiveLineGraphView_labelSeparation, mLabelSeparation);
mGridThickness = a.getDimension(
R.styleable.InteractiveLineGraphView_gridThickness, mGridThickness);
mGridColor = a.getColor(
R.styleable.InteractiveLineGraphView_gridColor, mGridColor);
mAxisThickness = a.getDimension(
R.styleable.InteractiveLineGraphView_axisThickness, mAxisThickness);
mAxisColor = a.getColor(
R.styleable.InteractiveLineGraphView_axisColor, mAxisColor);
mDataThickness = a.getDimension(
R.styleable.InteractiveLineGraphView_dataThickness, mDataThickness);
mDataColor = a.getColor(
R.styleable.InteractiveLineGraphView_dataColor, mDataColor);
} finally {
a.recycle();
}
initPaints();
// Sets up interactions
mScaleGestureDetector = new ScaleGestureDetector(context, mScaleGestureListener);
mGestureDetector = new GestureDetectorCompat(context, mGestureListener);
mScroller = new OverScroller(context);
mZoomer = new Zoomer(context);
// Sets up edge effects
mEdgeEffectLeft = new EdgeEffectCompat(context);
mEdgeEffectTop = new EdgeEffectCompat(context);
mEdgeEffectRight = new EdgeEffectCompat(context);
mEdgeEffectBottom = new EdgeEffectCompat(context);
}
/**
* (Re)initializes {@link Paint} objects based on current attribute values.
*/
private void initPaints() {
mLabelTextPaint = new Paint();
mLabelTextPaint.setAntiAlias(true);
mLabelTextPaint.setTextSize(mLabelTextSize);
mLabelTextPaint.setColor(mLabelTextColor);
mLabelHeight = (int) Math.abs(mLabelTextPaint.getFontMetrics().top);
mMaxLabelWidth = (int) mLabelTextPaint.measureText("0000");
mGridPaint = new Paint();
mGridPaint.setStrokeWidth(mGridThickness);
mGridPaint.setColor(mGridColor);
mGridPaint.setStyle(Paint.Style.STROKE);
mAxisPaint = new Paint();
mAxisPaint.setStrokeWidth(mAxisThickness);
mAxisPaint.setColor(mAxisColor);
mAxisPaint.setStyle(Paint.Style.STROKE);
mDataPaint = new Paint();
mDataPaint.setStrokeWidth(mDataThickness);
mDataPaint.setColor(mDataColor);
mDataPaint.setStyle(Paint.Style.STROKE);
mDataPaint.setAntiAlias(true);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mContentRect.set(
getPaddingLeft() + mMaxLabelWidth + mLabelSeparation,
getPaddingTop(),
getWidth() - getPaddingRight(),
getHeight() - getPaddingBottom() - mLabelHeight - mLabelSeparation);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int minChartSize = getResources().getDimensionPixelSize(R.dimen.min_chart_size);
setMeasuredDimension(
Math.max(getSuggestedMinimumWidth(),
resolveSize(minChartSize + getPaddingLeft() + mMaxLabelWidth
+ mLabelSeparation + getPaddingRight(),
widthMeasureSpec)),
Math.max(getSuggestedMinimumHeight(),
resolveSize(minChartSize + getPaddingTop() + mLabelHeight
+ mLabelSeparation + getPaddingBottom(),
heightMeasureSpec)));
}
////////////////////////////////////////////////////////////////////////////////////////////////
//
// Methods and objects related to drawing
//
////////////////////////////////////////////////////////////////////////////////////////////////
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// Draws axes and text labels
drawAxes(canvas);
// Clips the next few drawing operations to the content area
int clipRestoreCount = canvas.save();
canvas.clipRect(mContentRect);
drawDataSeriesUnclipped(canvas);
drawEdgeEffectsUnclipped(canvas);
// Removes clipping rectangle
canvas.restoreToCount(clipRestoreCount);
// Draws chart container
canvas.drawRect(mContentRect, mAxisPaint);
}
/**
* Draws the chart axes and labels onto the canvas.
*/
private void drawAxes(Canvas canvas) {
// Computes axis stops (in terms of numerical value and position on screen)
int i;
computeAxisStops(
mCurrentViewport.left,
mCurrentViewport.right,
mContentRect.width() / mMaxLabelWidth / 2,
mXStopsBuffer);
computeAxisStops(
mCurrentViewport.top,
mCurrentViewport.bottom,
mContentRect.height() / mLabelHeight / 2,
mYStopsBuffer);
// Avoid unnecessary allocations during drawing. Re-use allocated
// arrays and only reallocate if the number of stops grows.
if (mAxisXPositionsBuffer.length < mXStopsBuffer.numStops) {
mAxisXPositionsBuffer = new float[mXStopsBuffer.numStops];
}
if (mAxisYPositionsBuffer.length < mYStopsBuffer.numStops) {
mAxisYPositionsBuffer = new float[mYStopsBuffer.numStops];
}
if (mAxisXLinesBuffer.length < mXStopsBuffer.numStops * 4) {
mAxisXLinesBuffer = new float[mXStopsBuffer.numStops * 4];
}
if (mAxisYLinesBuffer.length < mYStopsBuffer.numStops * 4) {
mAxisYLinesBuffer = new float[mYStopsBuffer.numStops * 4];
}
// Compute positions
for (i = 0; i < mXStopsBuffer.numStops; i++) {
mAxisXPositionsBuffer[i] = getDrawX(mXStopsBuffer.stops[i]);
}
for (i = 0; i < mYStopsBuffer.numStops; i++) {
mAxisYPositionsBuffer[i] = getDrawY(mYStopsBuffer.stops[i]);
}
// Draws grid lines using drawLines (faster than individual drawLine calls)
for (i = 0; i < mXStopsBuffer.numStops; i++) {
mAxisXLinesBuffer[i * 4 + 0] = (float) Math.floor(mAxisXPositionsBuffer[i]);
mAxisXLinesBuffer[i * 4 + 1] = mContentRect.top;
mAxisXLinesBuffer[i * 4 + 2] = (float) Math.floor(mAxisXPositionsBuffer[i]);
mAxisXLinesBuffer[i * 4 + 3] = mContentRect.bottom;
}
canvas.drawLines(mAxisXLinesBuffer, 0, mXStopsBuffer.numStops * 4, mGridPaint);
for (i = 0; i < mYStopsBuffer.numStops; i++) {
mAxisYLinesBuffer[i * 4 + 0] = mContentRect.left;
mAxisYLinesBuffer[i * 4 + 1] = (float) Math.floor(mAxisYPositionsBuffer[i]);
mAxisYLinesBuffer[i * 4 + 2] = mContentRect.right;
mAxisYLinesBuffer[i * 4 + 3] = (float) Math.floor(mAxisYPositionsBuffer[i]);
}
canvas.drawLines(mAxisYLinesBuffer, 0, mYStopsBuffer.numStops * 4, mGridPaint);
// Draws X labels
int labelOffset;
int labelLength;
mLabelTextPaint.setTextAlign(Paint.Align.CENTER);
for (i = 0; i < mXStopsBuffer.numStops; i++) {
// Do not use String.format in high-performance code such as onDraw code.
labelLength = formatFloat(mLabelBuffer, mXStopsBuffer.stops[i], mXStopsBuffer.decimals);
labelOffset = mLabelBuffer.length - labelLength;
canvas.drawText(
mLabelBuffer, labelOffset, labelLength,
mAxisXPositionsBuffer[i],
mContentRect.bottom + mLabelHeight + mLabelSeparation,
mLabelTextPaint);
}
// Draws Y labels
mLabelTextPaint.setTextAlign(Paint.Align.RIGHT);
for (i = 0; i < mYStopsBuffer.numStops; i++) {
// Do not use String.format in high-performance code such as onDraw code.
labelLength = formatFloat(mLabelBuffer, mYStopsBuffer.stops[i], mYStopsBuffer.decimals);
labelOffset = mLabelBuffer.length - labelLength;
canvas.drawText(
mLabelBuffer, labelOffset, labelLength,
mContentRect.left - mLabelSeparation,
mAxisYPositionsBuffer[i] + mLabelHeight / 2,
mLabelTextPaint);
}
}
/**
* Rounds the given number to the given number of significant digits. Based on an answer on
* Stack Overflow.
*/
private static float roundToOneSignificantFigure(double num) {
final float d = (float) Math.ceil((float) Math.log10(num < 0 ? -num : num));
final int power = 1 - (int) d;
final float magnitude = (float) Math.pow(10, power);
final long shifted = Math.round(num * magnitude);
return shifted / magnitude;
}
private static final int POW10[] = {1, 10, 100, 1000, 10000, 100000, 1000000};
/**
* Formats a float value to the given number of decimals. Returns the length of the string.
* The string begins at out.length - [return value].
*/
private static int formatFloat(final char[] out, float val, int digits) {
boolean negative = false;
if (val == 0) {
out[out.length - 1] = '0';
return 1;
}
if (val < 0) {
negative = true;
val = -val;
}
if (digits > POW10.length) {
digits = POW10.length - 1;
}
val *= POW10[digits];
long lval = Math.round(val);
int index = out.length - 1;
int charCount = 0;
while (lval != 0 || charCount < (digits + 1)) {
int digit = (int) (lval % 10);
lval = lval / 10;
out[index--] = (char) (digit + '0');
charCount++;
if (charCount == digits) {
out[index--] = '.';
charCount++;
}
}
if (negative) {
out[index--] = '-';
charCount++;
}
return charCount;
}
/**
* Computes the set of axis labels to show given start and stop boundaries and an ideal number
* of stops between these boundaries.
*
* @param start The minimum extreme (e.g. the left edge) for the axis.
* @param stop The maximum extreme (e.g. the right edge) for the axis.
* @param steps The ideal number of stops to create. This should be based on available screen
* space; the more space there is, the more stops should be shown.
* @param outStops The destination {@link AxisStops} object to populate.
*/
private static void computeAxisStops(float start, float stop, int steps, AxisStops outStops) {
double range = stop - start;
if (steps == 0 || range <= 0) {
outStops.stops = new float[]{};
outStops.numStops = 0;
return;
}
double rawInterval = range / steps;
double interval = roundToOneSignificantFigure(rawInterval);
double intervalMagnitude = Math.pow(10, (int) Math.log10(interval));
int intervalSigDigit = (int) (interval / intervalMagnitude);
if (intervalSigDigit > 5) {
// Use one order of magnitude higher, to avoid intervals like 0.9 or 90
interval = Math.floor(10 * intervalMagnitude);
}
double first = Math.ceil(start / interval) * interval;
double last = Math.nextUp(Math.floor(stop / interval) * interval);
double f;
int i;
int n = 0;
for (f = first; f <= last; f += interval) {
++n;
}
outStops.numStops = n;
if (outStops.stops.length < n) {
// Ensure stops contains at least numStops elements.
outStops.stops = new float[n];
}
for (f = first, i = 0; i < n; f += interval, ++i) {
outStops.stops[i] = (float) f;
}
if (interval < 1) {
outStops.decimals = (int) Math.ceil(-Math.log10(interval));
} else {
outStops.decimals = 0;
}
}
/**
* Computes the pixel offset for the given X chart value. This may be outside the view bounds.
*/
private float getDrawX(float x) {
return mContentRect.left
+ mContentRect.width()
* (x - mCurrentViewport.left) / mCurrentViewport.width();
}
/**
* Computes the pixel offset for the given Y chart value. This may be outside the view bounds.
*/
private float getDrawY(float y) {
return mContentRect.bottom
- mContentRect.height()
* (y - mCurrentViewport.top) / mCurrentViewport.height();
}
/**
* Draws the currently visible portion of the data series defined by {@link #fun(float)} to the
* canvas. This method does not clip its drawing, so users should call {@link Canvas#clipRect
* before calling this method.
*/
private void drawDataSeriesUnclipped(Canvas canvas) {
mSeriesLinesBuffer[0] = mContentRect.left;
mSeriesLinesBuffer[1] = getDrawY(fun(mCurrentViewport.left));
mSeriesLinesBuffer[2] = mSeriesLinesBuffer[0];
mSeriesLinesBuffer[3] = mSeriesLinesBuffer[1];
float x;
for (int i = 1; i <= DRAW_STEPS; i++) {
mSeriesLinesBuffer[i * 4 + 0] = mSeriesLinesBuffer[(i - 1) * 4 + 2];
mSeriesLinesBuffer[i * 4 + 1] = mSeriesLinesBuffer[(i - 1) * 4 + 3];
x = (mCurrentViewport.left + (mCurrentViewport.width() / DRAW_STEPS * i));
mSeriesLinesBuffer[i * 4 + 2] = getDrawX(x);
mSeriesLinesBuffer[i * 4 + 3] = getDrawY(fun(x));
}
canvas.drawLines(mSeriesLinesBuffer, mDataPaint);
}
/**
* Draws the overscroll "glow" at the four edges of the chart region, if necessary. The edges
* of the chart region are stored in {@link #mContentRect}.
*
* @see EdgeEffectCompat
*/
private void drawEdgeEffectsUnclipped(Canvas canvas) {
// The methods below rotate and translate the canvas as needed before drawing the glow,
// since EdgeEffectCompat always draws a top-glow at 0,0.
boolean needsInvalidate = false;
if (!mEdgeEffectTop.isFinished()) {
final int restoreCount = canvas.save();
canvas.translate(mContentRect.left, mContentRect.top);
mEdgeEffectTop.setSize(mContentRect.width(), mContentRect.height());
if (mEdgeEffectTop.draw(canvas)) {
needsInvalidate = true;
}
canvas.restoreToCount(restoreCount);
}
if (!mEdgeEffectBottom.isFinished()) {
final int restoreCount = canvas.save();
canvas.translate(2 * mContentRect.left - mContentRect.right, mContentRect.bottom);
canvas.rotate(180, mContentRect.width(), 0);
mEdgeEffectBottom.setSize(mContentRect.width(), mContentRect.height());
if (mEdgeEffectBottom.draw(canvas)) {
needsInvalidate = true;
}
canvas.restoreToCount(restoreCount);
}
if (!mEdgeEffectLeft.isFinished()) {
final int restoreCount = canvas.save();
canvas.translate(mContentRect.left, mContentRect.bottom);
canvas.rotate(-90, 0, 0);
mEdgeEffectLeft.setSize(mContentRect.height(), mContentRect.width());
if (mEdgeEffectLeft.draw(canvas)) {
needsInvalidate = true;
}
canvas.restoreToCount(restoreCount);
}
if (!mEdgeEffectRight.isFinished()) {
final int restoreCount = canvas.save();
canvas.translate(mContentRect.right, mContentRect.top);
canvas.rotate(90, 0, 0);
mEdgeEffectRight.setSize(mContentRect.height(), mContentRect.width());
if (mEdgeEffectRight.draw(canvas)) {
needsInvalidate = true;
}
canvas.restoreToCount(restoreCount);
}
if (needsInvalidate) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
////////////////////////////////////////////////////////////////////////////////////////////////
//
// Methods and objects related to gesture handling
//
////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Finds the chart point (i.e. within the chart's domain and range) represented by the
* given pixel coordinates, if that pixel is within the chart region described by
* {@link #mContentRect}. If the point is found, the "dest" argument is set to the point and
* this function returns true. Otherwise, this function returns false and "dest" is unchanged.
*/
private boolean hitTest(float x, float y, PointF dest) {
if (!mContentRect.contains((int) x, (int) y)) {
return false;
}
dest.set(
mCurrentViewport.left
+ mCurrentViewport.width()
* (x - mContentRect.left) / mContentRect.width(),
mCurrentViewport.top
+ mCurrentViewport.height()
* (y - mContentRect.bottom) / -mContentRect.height());
return true;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean retVal = mScaleGestureDetector.onTouchEvent(event);
retVal = mGestureDetector.onTouchEvent(event) || retVal;
return retVal || super.onTouchEvent(event);
}
/**
* The scale listener, used for handling multi-finger scale gestures.
*/
private final ScaleGestureDetector.OnScaleGestureListener mScaleGestureListener
= new ScaleGestureDetector.SimpleOnScaleGestureListener() {
/**
* This is the active focal point in terms of the viewport. Could be a local
* variable but kept here to minimize per-frame allocations.
*/
private PointF viewportFocus = new PointF();
private float lastSpanX;
private float lastSpanY;
@Override
public boolean onScaleBegin(ScaleGestureDetector scaleGestureDetector) {
lastSpanX = ScaleGestureDetectorCompat.getCurrentSpanX(scaleGestureDetector);
lastSpanY = ScaleGestureDetectorCompat.getCurrentSpanY(scaleGestureDetector);
return true;
}
@Override
public boolean onScale(ScaleGestureDetector scaleGestureDetector) {
float spanX = ScaleGestureDetectorCompat.getCurrentSpanX(scaleGestureDetector);
float spanY = ScaleGestureDetectorCompat.getCurrentSpanY(scaleGestureDetector);
float newWidth = lastSpanX / spanX * mCurrentViewport.width();
float newHeight = lastSpanY / spanY * mCurrentViewport.height();
float focusX = scaleGestureDetector.getFocusX();
float focusY = scaleGestureDetector.getFocusY();
hitTest(focusX, focusY, viewportFocus);
mCurrentViewport.set(
viewportFocus.x
- newWidth * (focusX - mContentRect.left)
/ mContentRect.width(),
viewportFocus.y
- newHeight * (mContentRect.bottom - focusY)
/ mContentRect.height(),
0,
0);
mCurrentViewport.right = mCurrentViewport.left + newWidth;
mCurrentViewport.bottom = mCurrentViewport.top + newHeight;
constrainViewport();
ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this);
lastSpanX = spanX;
lastSpanY = spanY;
return true;
}
};
/**
* Ensures that current viewport is inside the viewport extremes defined by {@link #AXIS_X_MIN},
* {@link #AXIS_X_MAX}, {@link #AXIS_Y_MIN} and {@link #AXIS_Y_MAX}.
*/
private void constrainViewport() {
mCurrentViewport.left = Math.max(AXIS_X_MIN, mCurrentViewport.left);
mCurrentViewport.top = Math.max(AXIS_Y_MIN, mCurrentViewport.top);
mCurrentViewport.bottom = Math.max(Math.nextUp(mCurrentViewport.top),
Math.min(AXIS_Y_MAX, mCurrentViewport.bottom));
mCurrentViewport.right = Math.max(Math.nextUp(mCurrentViewport.left),
Math.min(AXIS_X_MAX, mCurrentViewport.right));
}
/**
* The gesture listener, used for handling simple gestures such as double touches, scrolls,
* and flings.
*/
private final GestureDetector.SimpleOnGestureListener mGestureListener
= new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
releaseEdgeEffects();
mScrollerStartViewport.set(mCurrentViewport);
mScroller.forceFinished(true);
ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this);
return true;
}
@Override
public boolean onDoubleTap(MotionEvent e) {
mZoomer.forceFinished(true);
if (hitTest(e.getX(), e.getY(), mZoomFocalPoint)) {
mZoomer.startZoom(ZOOM_AMOUNT);
}
ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this);
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
// Scrolling uses math based on the viewport (as opposed to math using pixels).
/**
* Pixel offset is the offset in screen pixels, while viewport offset is the
* offset within the current viewport. For additional information on surface sizes
* and pixel offsets, see the docs for {@link computeScrollSurfaceSize()}. For
* additional information about the viewport, see the comments for
* {@link mCurrentViewport}.
*/
float viewportOffsetX = distanceX * mCurrentViewport.width() / mContentRect.width();
float viewportOffsetY = -distanceY * mCurrentViewport.height() / mContentRect.height();
computeScrollSurfaceSize(mSurfaceSizeBuffer);
int scrolledX = (int) (mSurfaceSizeBuffer.x
* (mCurrentViewport.left + viewportOffsetX - AXIS_X_MIN)
/ (AXIS_X_MAX - AXIS_X_MIN));
int scrolledY = (int) (mSurfaceSizeBuffer.y
* (AXIS_Y_MAX - mCurrentViewport.bottom - viewportOffsetY)
/ (AXIS_Y_MAX - AXIS_Y_MIN));
boolean canScrollX = mCurrentViewport.left > AXIS_X_MIN
|| mCurrentViewport.right < AXIS_X_MAX;
boolean canScrollY = mCurrentViewport.top > AXIS_Y_MIN
|| mCurrentViewport.bottom < AXIS_Y_MAX;
setViewportBottomLeft(
mCurrentViewport.left + viewportOffsetX,
mCurrentViewport.bottom + viewportOffsetY);
if (canScrollX && scrolledX < 0) {
mEdgeEffectLeft.onPull(scrolledX / (float) mContentRect.width());
mEdgeEffectLeftActive = true;
}
if (canScrollY && scrolledY < 0) {
mEdgeEffectTop.onPull(scrolledY / (float) mContentRect.height());
mEdgeEffectTopActive = true;
}
if (canScrollX && scrolledX > mSurfaceSizeBuffer.x - mContentRect.width()) {
mEdgeEffectRight.onPull((scrolledX - mSurfaceSizeBuffer.x + mContentRect.width())
/ (float) mContentRect.width());
mEdgeEffectRightActive = true;
}
if (canScrollY && scrolledY > mSurfaceSizeBuffer.y - mContentRect.height()) {
mEdgeEffectBottom.onPull((scrolledY - mSurfaceSizeBuffer.y + mContentRect.height())
/ (float) mContentRect.height());
mEdgeEffectBottomActive = true;
}
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
fling((int) -velocityX, (int) -velocityY);
return true;
}
};
private void releaseEdgeEffects() {
mEdgeEffectLeftActive
= mEdgeEffectTopActive
= mEdgeEffectRightActive
= mEdgeEffectBottomActive
= false;
mEdgeEffectLeft.onRelease();
mEdgeEffectTop.onRelease();
mEdgeEffectRight.onRelease();
mEdgeEffectBottom.onRelease();
}
private void fling(int velocityX, int velocityY) {
releaseEdgeEffects();
// Flings use math in pixels (as opposed to math based on the viewport).
computeScrollSurfaceSize(mSurfaceSizeBuffer);
mScrollerStartViewport.set(mCurrentViewport);
int startX = (int) (mSurfaceSizeBuffer.x * (mScrollerStartViewport.left - AXIS_X_MIN) / (
AXIS_X_MAX - AXIS_X_MIN));
int startY = (int) (mSurfaceSizeBuffer.y * (AXIS_Y_MAX - mScrollerStartViewport.bottom) / (
AXIS_Y_MAX - AXIS_Y_MIN));
mScroller.forceFinished(true);
mScroller.fling(
startX,
startY,
velocityX,
velocityY,
0, mSurfaceSizeBuffer.x - mContentRect.width(),
0, mSurfaceSizeBuffer.y - mContentRect.height(),
mContentRect.width() / 2,
mContentRect.height() / 2);
ViewCompat.postInvalidateOnAnimation(this);
}
/**
* Computes the current scrollable surface size, in pixels. For example, if the entire chart
* area is visible, this is simply the current size of {@link #mContentRect}. If the chart
* is zoomed in 200% in both directions, the returned size will be twice as large horizontally
* and vertically.
*/
private void computeScrollSurfaceSize(Point out) {
out.set(
(int) (mContentRect.width() * (AXIS_X_MAX - AXIS_X_MIN)
/ mCurrentViewport.width()),
(int) (mContentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN)
/ mCurrentViewport.height()));
}
@Override
public void computeScroll() {
super.computeScroll();
boolean needsInvalidate = false;
if (mScroller.computeScrollOffset()) {
// The scroller isn't finished, meaning a fling or programmatic pan operation is
// currently active.
computeScrollSurfaceSize(mSurfaceSizeBuffer);
int currX = mScroller.getCurrX();
int currY = mScroller.getCurrY();
boolean canScrollX = (mCurrentViewport.left > AXIS_X_MIN
|| mCurrentViewport.right < AXIS_X_MAX);
boolean canScrollY = (mCurrentViewport.top > AXIS_Y_MIN
|| mCurrentViewport.bottom < AXIS_Y_MAX);
if (canScrollX
&& currX < 0
&& mEdgeEffectLeft.isFinished()
&& !mEdgeEffectLeftActive) {
mEdgeEffectLeft.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller));
mEdgeEffectLeftActive = true;
needsInvalidate = true;
} else if (canScrollX
&& currX > (mSurfaceSizeBuffer.x - mContentRect.width())
&& mEdgeEffectRight.isFinished()
&& !mEdgeEffectRightActive) {
mEdgeEffectRight.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller));
mEdgeEffectRightActive = true;
needsInvalidate = true;
}
if (canScrollY
&& currY < 0
&& mEdgeEffectTop.isFinished()
&& !mEdgeEffectTopActive) {
mEdgeEffectTop.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller));
mEdgeEffectTopActive = true;
needsInvalidate = true;
} else if (canScrollY
&& currY > (mSurfaceSizeBuffer.y - mContentRect.height())
&& mEdgeEffectBottom.isFinished()
&& !mEdgeEffectBottomActive) {
mEdgeEffectBottom.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller));
mEdgeEffectBottomActive = true;
needsInvalidate = true;
}
float currXRange = AXIS_X_MIN + (AXIS_X_MAX - AXIS_X_MIN)
* currX / mSurfaceSizeBuffer.x;
float currYRange = AXIS_Y_MAX - (AXIS_Y_MAX - AXIS_Y_MIN)
* currY / mSurfaceSizeBuffer.y;
setViewportBottomLeft(currXRange, currYRange);
}
if (mZoomer.computeZoom()) {
// Performs the zoom since a zoom is in progress (either programmatically or via
// double-touch).
float newWidth = (1f - mZoomer.getCurrZoom()) * mScrollerStartViewport.width();
float newHeight = (1f - mZoomer.getCurrZoom()) * mScrollerStartViewport.height();
float pointWithinViewportX = (mZoomFocalPoint.x - mScrollerStartViewport.left)
/ mScrollerStartViewport.width();
float pointWithinViewportY = (mZoomFocalPoint.y - mScrollerStartViewport.top)
/ mScrollerStartViewport.height();
mCurrentViewport.set(
mZoomFocalPoint.x - newWidth * pointWithinViewportX,
mZoomFocalPoint.y - newHeight * pointWithinViewportY,
mZoomFocalPoint.x + newWidth * (1 - pointWithinViewportX),
mZoomFocalPoint.y + newHeight * (1 - pointWithinViewportY));
constrainViewport();
needsInvalidate = true;
}
if (needsInvalidate) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
/**
* Sets the current viewport (defined by {@link #mCurrentViewport}) to the given
* X and Y positions. Note that the Y value represents the topmost pixel position, and thus
* the bottom of the {@link #mCurrentViewport} rectangle. For more details on why top and
* bottom are flipped, see {@link #mCurrentViewport}.
*/
private void setViewportBottomLeft(float x, float y) {
/**
* Constrains within the scroll range. The scroll range is simply the viewport extremes
* (AXIS_X_MAX, etc.) minus the viewport size. For example, if the extrema were 0 and 10,
* and the viewport size was 2, the scroll range would be 0 to 8.
*/
float curWidth = mCurrentViewport.width();
float curHeight = mCurrentViewport.height();
x = Math.max(AXIS_X_MIN, Math.min(x, AXIS_X_MAX - curWidth));
y = Math.max(AXIS_Y_MIN + curHeight, Math.min(y, AXIS_Y_MAX));
mCurrentViewport.set(x, y - curHeight, x + curWidth, y);
ViewCompat.postInvalidateOnAnimation(this);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//
// Methods for programmatically changing the viewport
//
////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Returns the current viewport (visible extremes for the chart domain and range.)
*/
public RectF getCurrentViewport() {
return new RectF(mCurrentViewport);
}
/**
* Sets the chart's current viewport.
*
* @see #getCurrentViewport()
*/
public void setCurrentViewport(RectF viewport) {
mCurrentViewport = viewport;
constrainViewport();
ViewCompat.postInvalidateOnAnimation(this);
}
/**
* Smoothly zooms the chart in one step.
*/
public void zoomIn() {
mScrollerStartViewport.set(mCurrentViewport);
mZoomer.forceFinished(true);
mZoomer.startZoom(ZOOM_AMOUNT);
mZoomFocalPoint.set(
(mCurrentViewport.right + mCurrentViewport.left) / 2,
(mCurrentViewport.bottom + mCurrentViewport.top) / 2);
ViewCompat.postInvalidateOnAnimation(this);
}
/**
* Smoothly zooms the chart out one step.
*/
public void zoomOut() {
mScrollerStartViewport.set(mCurrentViewport);
mZoomer.forceFinished(true);
mZoomer.startZoom(-ZOOM_AMOUNT);
mZoomFocalPoint.set(
(mCurrentViewport.right + mCurrentViewport.left) / 2,
(mCurrentViewport.bottom + mCurrentViewport.top) / 2);
ViewCompat.postInvalidateOnAnimation(this);
}
/**
* Smoothly pans the chart left one step.
*/
public void panLeft() {
fling((int) (-PAN_VELOCITY_FACTOR * getWidth()), 0);
}
/**
* Smoothly pans the chart right one step.
*/
public void panRight() {
fling((int) (PAN_VELOCITY_FACTOR * getWidth()), 0);
}
/**
* Smoothly pans the chart up one step.
*/
public void panUp() {
fling(0, (int) (-PAN_VELOCITY_FACTOR * getHeight()));
}
/**
* Smoothly pans the chart down one step.
*/
public void panDown() {
fling(0, (int) (PAN_VELOCITY_FACTOR * getHeight()));
}
////////////////////////////////////////////////////////////////////////////////////////////////
//
// Methods related to custom attributes
//
////////////////////////////////////////////////////////////////////////////////////////////////
public float getLabelTextSize() {
return mLabelTextSize;
}
public void setLabelTextSize(float labelTextSize) {
mLabelTextSize = labelTextSize;
initPaints();
ViewCompat.postInvalidateOnAnimation(this);
}
public int getLabelTextColor() {
return mLabelTextColor;
}
public void setLabelTextColor(int labelTextColor) {
mLabelTextColor = labelTextColor;
initPaints();
ViewCompat.postInvalidateOnAnimation(this);
}
public float getGridThickness() {
return mGridThickness;
}
public void setGridThickness(float gridThickness) {
mGridThickness = gridThickness;
initPaints();
ViewCompat.postInvalidateOnAnimation(this);
}
public int getGridColor() {
return mGridColor;
}
public void setGridColor(int gridColor) {
mGridColor = gridColor;
initPaints();
ViewCompat.postInvalidateOnAnimation(this);
}
public float getAxisThickness() {
return mAxisThickness;
}
public void setAxisThickness(float axisThickness) {
mAxisThickness = axisThickness;
initPaints();
ViewCompat.postInvalidateOnAnimation(this);
}
public int getAxisColor() {
return mAxisColor;
}
public void setAxisColor(int axisColor) {
mAxisColor = axisColor;
initPaints();
ViewCompat.postInvalidateOnAnimation(this);
}
public float getDataThickness() {
return mDataThickness;
}
public void setDataThickness(float dataThickness) {
mDataThickness = dataThickness;
}
public int getDataColor() {
return mDataColor;
}
public void setDataColor(int dataColor) {
mDataColor = dataColor;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//
// Methods and classes related to view state persistence.
//
////////////////////////////////////////////////////////////////////////////////////////////////
@Override
public Parcelable onSaveInstanceState() {
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState);
ss.viewport = mCurrentViewport;
return ss;
}
@Override
public void onRestoreInstanceState(Parcelable state) {
if (!(state instanceof SavedState)) {
super.onRestoreInstanceState(state);
return;
}
SavedState ss = (SavedState) state;
super.onRestoreInstanceState(ss.getSuperState());
mCurrentViewport = ss.viewport;
}
/**
* Persistent state that is saved by InteractiveLineGraphView.
*/
public static class SavedState extends BaseSavedState {
private RectF viewport;
public SavedState(Parcelable superState) {
super(superState);
}
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
out.writeFloat(viewport.left);
out.writeFloat(viewport.top);
out.writeFloat(viewport.right);
out.writeFloat(viewport.bottom);
}
@Override
public String toString() {
return "InteractiveLineGraphView.SavedState{"
+ Integer.toHexString(System.identityHashCode(this))
+ " viewport=" + viewport.toString() + "}";
}
public static final Parcelable.Creator