/* * Copyright 2014 Diogo Bernardino * * 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.db.chart.view; import java.text.DecimalFormat; import java.util.ArrayList; import com.db.williamchart.R; import com.db.chart.listener.OnEntryClickListener; import com.db.chart.model.ChartEntry; import com.db.chart.model.ChartSet; import com.db.chart.view.animation.Animation; import com.db.chart.view.animation.style.BaseStyleAnimation; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.Region; import android.graphics.Typeface; import android.util.AttributeSet; import android.util.Log; import android.util.Pair; import android.view.MotionEvent; import android.view.View; import android.view.ViewTreeObserver.OnPreDrawListener; import android.widget.RelativeLayout; /** * Abstract class to be extend to define any chart that implies axis. */ public abstract class ChartView extends RelativeLayout{ private static final String TAG = "com.db.chart.view.ChartView"; public static enum GridType { FULL, VERTICAL, HORIZONTAL } public static enum Orientation { HORIZONTAL, VERTICAL } protected Orientation orientation; /** Chart borders */ protected int chartTop; protected int chartBottom; protected int chartLeft; protected int chartRight; /** Horizontal and Vertical position controllers */ protected XController horController; protected YController verController; /** Chart data to be displayed */ protected ArrayList<ChartSet> data; /** Style applied to chart */ protected Style style; /** Threshold limit line value */ private float mThresholdValue; /** Chart data to be displayed */ private ArrayList<ArrayList<Region>> mRegions; /** Index of last point clicked */ private int mIndexClicked; private int mSetClicked; /** Listeners to for touch events */ private OnEntryClickListener mEntryListener; private OnClickListener mChartListener; /** Drawing flag */ private boolean mReadyToDraw; /** Drawing flag */ private boolean mIsDrawing; /** Chart animation */ private Animation mAnim; /** Keep record of data updates to be done */ private ArrayList<Pair<Integer, float []>> mToUpdateValues; /** * Executed only before the chart is drawn for the first time. * . borders are defined * . digestData(data), to process the data to be drawn * . defineRegions(), if listener has been registered * this will define the chart regions to handle by onTouchEvent */ private OnPreDrawListener drawListener = new OnPreDrawListener(){ @SuppressLint("NewApi") @Override public boolean onPreDraw() { ChartView.this.getViewTreeObserver().removeOnPreDrawListener(this); // Define chart frame chartTop = getPaddingTop() + verController.getLabelHeight()/2; chartBottom = getMeasuredHeight() - getPaddingBottom(); chartLeft = getPaddingLeft(); chartRight = getMeasuredWidth() - getPaddingRight(); // Initialize controllers now that we have the measures verController.init(); mThresholdValue = verController.parsePos(0, mThresholdValue); // Mandatory: X axis after Y axis! horController.init(); // Process data to define screen positions digestData(); // Tells view to execute code before starting drawing onPreDrawChart(data); // Sets listener if needed if(mEntryListener != null) mRegions = defineRegions(data); // Prepares the animation if needed and gets the first dump // of data to be drawn if(mAnim != null) data = mAnim.prepareEnterAnimation(ChartView.this); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB) ChartView.this.setLayerType(LAYER_TYPE_SOFTWARE, null); return mReadyToDraw = true; } }; public ChartView(Context context, AttributeSet attrs) { super(context, attrs); horController = new XController(this, context.getTheme().obtainStyledAttributes(attrs, R.styleable.ChartAttrs, 0, 0)); verController = new YController(this, context.getTheme().obtainStyledAttributes(attrs, R.styleable.ChartAttrs, 0, 0)); style = new Style(context.getTheme().obtainStyledAttributes(attrs, R.styleable.ChartAttrs, 0, 0)); init(); } public ChartView(Context context) { super(context); horController = new XController(this); verController = new YController(this); style = new Style(); init(); } private void init(){ mReadyToDraw = false; mSetClicked = -1; mIndexClicked = -1; mThresholdValue = 0; mIsDrawing = false; data = new ArrayList<ChartSet>(); mRegions = new ArrayList<ArrayList<Region>>(); mToUpdateValues = new ArrayList<Pair<Integer,float[]>>(); } @Override public void onAttachedToWindow(){ super.onAttachedToWindow(); this.setWillNotDraw(false); style.init(); } @Override public void onDetachedFromWindow(){ super.onDetachedFromWindow(); style.clean(); } /* * ----------------------- * Methods to be overriden * ----------------------- */ /** * Convert {@link ChartEntry} values into screen points. */ private void digestData() { int nEntries = data.get(0).size(); for(ChartSet set: data){ for(int i = 0; i < nEntries; i++){ set.getEntry(i) .setCoordinates(horController.parsePos(i, set.getValue(i)), verController.parsePos(i, set.getValue(i))); } } } /** * (Optional) To be overriden in case the view needs to execute some code before * starting the drawing. * * @param data Array of {@link ChartSet} to do the necessary preparation just before onDraw */ protected void onPreDrawChart(ArrayList<ChartSet> data){} /** * (Optional) To be overridden in order for each chart to define its own clickable regions. * This way, classes extending ChartView will only define their clickable regions. * * Important: the returned vector must match the order of the data passed * by the user. This ensures that onTouchEvent will return the correct index. * * @param data {@link java.util.ArrayList} of {@link com.db.chart.model.ChartSet} * to use while defining each region of a {@link com.db.chart.view.BarChartView} * @return {@link java.util.ArrayList} of {@link android.graphics.Region} with regions * where click will be detected */ protected ArrayList<ArrayList<Region>> defineRegions(ArrayList<ChartSet> data){ return mRegions; }; /** * Method responsible to draw bars with the parsed screen points. * * @param canvas The canvas to draw on * @param data {@link java.util.ArrayList} of {@link com.db.chart.model.ChartSet} * to use while drawing the Chart */ abstract protected void onDrawChart(Canvas canvas, ArrayList<ChartSet> data); /** * Set new data to the chart and invalidates the view to be then drawn. * * @param set {@link ChartSet} object. */ public void addData(ChartSet set){ if(!data.isEmpty() && set.size() != data.get(0).size()) Log.e(TAG, "The number of labels between sets doesn't match.", new IllegalArgumentException()); data.add(set); } /** * Add full chart data. * @param data An array of {@link ChartSet} */ public void addData(ArrayList<ChartSet> data){ this.data = data; } /** * Base method when a show chart occurs */ private void display(){ this.getViewTreeObserver().addOnPreDrawListener(drawListener); postInvalidate(); } /** * Show chart data */ public void show(){ for(ChartSet set : data) set.setVisible(true); display(); } /** * Show only a specific chart dataset. * * @param setIndex Dataset's index to be displayed */ public void show(int setIndex){ data.get(setIndex).setVisible(true); display(); } /** * Starts the animation given as parameter. * * @param anim Animation used while showing and updating sets */ public void show(Animation anim){ mAnim = anim; show(); } /** * Dismiss chart data. */ public void dismiss(){ data.clear(); invalidate(); } /** * Dismiss a specific chart dataset. * * @param setIndex Dataset's index to be dismissed */ public void dismiss(int setIndex){ data.get(setIndex).setVisible(false); invalidate(); } /** * Dismiss chart data with animation. * * @param anim Animation used to exit */ public void dismiss(Animation anim){ mAnim = anim; final Runnable endAction = mAnim.getEndAction(); mAnim.setEndAction(new Runnable() { @Override public void run() { if(endAction != null) endAction.run(); dismiss(); } }); data = mAnim.prepareExitAnimation(this); invalidate(); } /** * Method not expected to be used often. More for testing. * Resets chart state to insert new configuration. */ public void reset(){ if(mAnim != null && mAnim.isPlaying()) mAnim.cancel(); data.clear(); mRegions.clear(); mToUpdateValues.clear(); verController.minLabelValue = 0; verController.maxLabelValue = 0; if(horController.mandatoryBorderSpacing != 0) horController.mandatoryBorderSpacing = 1; style.thresholdPaint = null; style.gridPaint = null; style.hasHorizontalGrid = false; style.hasVerticalGrid = false; } /** * Update set values. Animation support in case previously added. * * @param setIndex Index of set to be updated * @param values Array of new values. Array length must match current data */ public ChartView updateValues(int setIndex, float[] values){ if(values.length != data.get(setIndex).size()) Log.e(TAG, "New values size doesn't match current dataset size.", new IllegalArgumentException()); data.get(setIndex).updateValues(values); return this; } /** * Notify ChartView about updated values. ChartView will be validated. */ public void notifyDataUpdate(){ ArrayList<float[][]> oldCoords = new ArrayList<float[][]>(data.size()); ArrayList<float[][]> newCoords = new ArrayList<float[][]>(data.size()); for(ChartSet set : data) oldCoords.add(set.getScreenPoints()); digestData(); for(ChartSet set : data) newCoords.add(set.getScreenPoints()); mRegions = defineRegions(data); if(mAnim != null) data = mAnim.prepareAnimation(this, oldCoords, newCoords); mToUpdateValues.clear(); invalidate(); } /** * Adds a tooltip to ChartView. If is not the case already, * the whole tooltip is forced to be inside ChartView bounds. * * @param tooltip Tooltip view to be added * @param bool False if the tooltip should not be forced to be inside ChartView. * You may want to take care of it */ public void showTooltip(View tooltip, boolean bool){ if(bool){ final LayoutParams layoutParams = (LayoutParams) tooltip.getLayoutParams(); if(layoutParams.leftMargin < chartLeft - getPaddingLeft()) layoutParams.leftMargin = (int) chartLeft - getPaddingLeft(); if(layoutParams.topMargin < chartTop - getPaddingTop()) layoutParams.topMargin = (int) chartTop - getPaddingTop(); if(layoutParams.leftMargin + layoutParams.width > chartRight - getPaddingRight()) layoutParams.leftMargin -= layoutParams.width - (chartRight - getPaddingRight() - layoutParams.leftMargin); if(layoutParams.topMargin + layoutParams.height > getInnerChartBottom() - getPaddingBottom()) layoutParams.topMargin -= layoutParams.height - (getInnerChartBottom() - getPaddingBottom() - layoutParams.topMargin); tooltip.setLayoutParams(layoutParams); } this.addView(tooltip); } /** * Adds a tooltip to ChartView. If is not the case already, * the whole tooltip is forced to be inside ChartView bounds. * * @param tooltip Tooltip view to be added */ public void showTooltip(View tooltip){ showTooltip(tooltip, true); } /** * Removes tooltip from ChartView. * * @param tooltip View to be removed */ public void dismissTooltip(View tooltip){ this.removeView(tooltip); } /** * Removes all tooltips from ChartView. */ public void dismissAllTooltips(){ this.removeAllViews(); } /** * Animate {@link ChartSet}. * * @param index Position of {@link ChartSet} * @param anim Animation extending {@link BaseStyleAnimation} */ public void animateSet(int index, BaseStyleAnimation anim){ if (this.data.size() > 1) anim.play(this, this.data.get(index)); } /** * Asks the view if it is able to draw now. */ public boolean canIPleaseAskYouToDraw(){ return !mIsDrawing; } /* * ------------- * Draw Methods * ------------- */ @Override protected void onDraw(Canvas canvas) { mIsDrawing = true; super.onDraw(canvas); if(mReadyToDraw){ //long time = System.currentTimeMillis(); // Draw grid if(style.hasVerticalGrid) drawVerticalGrid(canvas); if(style.hasHorizontalGrid) drawHorizontalGrid(canvas); // Draw Axis Y verController.draw(canvas); // Draw data if(!data.isEmpty()) onDrawChart(canvas, data); // Draw axis X horController.draw(canvas); if(style.thresholdPaint != null) drawThresholdLine(canvas); //System.out.println("Time drawing "+(System.currentTimeMillis() - time)); } mIsDrawing = false; } private void drawThresholdLine(Canvas canvas) { canvas.drawLine(getInnerChartLeft(), mThresholdValue, getInnerChartRight(), mThresholdValue, style.thresholdPaint); } private void drawVerticalGrid(Canvas canvas){ // Draw vertical grid lines for(Float pos : horController.labelsPos){ canvas.drawLine(pos, getInnerChartBottom(), pos, getInnerChartTop(), style.gridPaint); } // If border diff than 0 inner chart sides must have lines if(horController.borderSpacing != 0 || horController.mandatoryBorderSpacing != 0){ if(verController.labelsPositioning == YController.LabelPosition.NONE) canvas.drawLine(getInnerChartLeft(), getInnerChartBottom(), getInnerChartLeft(), getInnerChartTop(), style.gridPaint); canvas.drawLine(getInnerChartRight(), getInnerChartBottom(), getInnerChartRight(), getInnerChartTop(), style.gridPaint); } } private void drawHorizontalGrid(Canvas canvas){ // Draw horizontal grid lines for(Float pos : verController.labelsPos){ canvas.drawLine(getInnerChartLeft(), pos, getInnerChartRight(), pos, style.gridPaint); } // If there's no axis if(!horController.hasAxis) canvas.drawLine(getInnerChartLeft(), getInnerChartBottom(), getInnerChartRight(), getInnerChartBottom(), style.gridPaint); } /* * -------------- * Click Handler * -------------- */ /** * The method listens chart clicks and checks whether it intercepts * a known Region. It will then use the registered Listener.onClick * to return the region's index. */ @Override public boolean onTouchEvent(MotionEvent event) { if(mAnim == null || !mAnim.isPlaying()) if(event.getAction() == MotionEvent.ACTION_DOWN && mEntryListener != null && mRegions != null){ //Check if ACTION_DOWN over any ScreenPoint region. int nSets = mRegions.size(); int nEntries = mRegions.get(0).size(); for(int i = 0; i < nSets ; i++){ for(int j = 0; j < nEntries; j++){ if(mRegions.get(i).get(j) .contains((int) event.getX(), (int) event.getY())){ mSetClicked = i; mIndexClicked = j; } } } }else if(event.getAction() == MotionEvent.ACTION_UP){ if(mEntryListener != null && mSetClicked != -1 && mIndexClicked != -1){ if(mRegions.get(mSetClicked).get(mIndexClicked) .contains((int)event.getX(), (int)event.getY())){ mEntryListener.onClick(mSetClicked, mIndexClicked, new Rect(mRegions.get(mSetClicked) .get(mIndexClicked) .getBounds().left - getPaddingLeft(), mRegions.get(mSetClicked) .get(mIndexClicked) .getBounds().top - getPaddingTop(), mRegions.get(mSetClicked) .get(mIndexClicked) .getBounds().right - getPaddingLeft(), mRegions.get(mSetClicked) .get(mIndexClicked) .getBounds().bottom - getPaddingTop())); } mSetClicked = -1; mIndexClicked = -1; }else if(mChartListener != null){ mChartListener.onClick(this); } } return true; } /* * -------- * Getters * -------- */ public Orientation getOrientation(){ return orientation; } /** * Inner Chart refers only to the area where chart data will be draw, * excluding labels, axis, etc. * * @return Position of the inner bottom side of the chart */ public float getInnerChartBottom(){ return verController.getInnerChartBottom(); } /** * Inner Chart refers only to the area where chart data will be draw, * excluding labels, axis, etc. * * @return Position of the inner left side of the chart */ public float getInnerChartLeft(){ return verController.getInnerChartLeft(); } /** * Inner Chart refers only to the area where chart data will be draw, * excluding labels, axis, etc. * * @return Position of the inner right side of the chart */ public float getInnerChartRight(){ return horController.getInnerChartRight(); } /** * Inner Chart refers only to the area where chart data will be draw, * excluding labels, axis, etc. * * @return Position of the inner top side of the chart */ public float getInnerChartTop(){ return chartTop; } /** * Returns the position of 0 value on chart. * * @return Position of 0 value on chart */ public float getZeroPosition(){ if(orientation == Orientation.VERTICAL) return verController.parsePos(0, 0); else return horController.parsePos(0, 0); } /** * Get the step used between Y values. * * @return Step */ protected int getStep(){ if(orientation == Orientation.VERTICAL) return verController.step; else return horController.step; } public ArrayList<ChartSet> getData(){ return data; } /* * -------- * Setters * -------- */ /** * Sets the chart's orientation. * * @param orien Orientation.HORIZONTAL | Orientation.VERTICAL */ protected void setOrientation(Orientation orien){ orientation = orien; if(orientation == Orientation.VERTICAL) { verController.handleValues = true; }else{ horController.handleValues = true; } } /** * Show/Hide Y labels and respective axis. * * @param position NONE - No labels * OUTSIDE - Labels will be positioned outside the chart * INSIDE - Labels will be positioned inside the chart */ public ChartView setYLabels(YController.LabelPosition position){ verController.labelsPositioning = position; return this; } /** * Show/Hide X labels and respective axis. * * @param position NONE - No labels * OUTSIDE - Labels will be positioned outside the chart * INSIDE - Labels will be positioned inside the chart */ public ChartView setXLabels(XController.LabelPosition position){ horController.labelsPositioning = position; return this; } /** * Show/Hide X axis. * * @param bool If true axis won't be visible */ public ChartView setXAxis(boolean bool){ horController.hasAxis = bool; return this; } /** * Show/Hide Y axis. * * @param bool If true axis won't be visible */ public ChartView setYAxis(boolean bool){ verController.hasAxis = bool; return this; } /** * A step is seen as the step to be defined between 2 labels. As an * example a step of 2 with a maxAxisValue of 6 will end up with * {0, 2, 4, 6} as labels. * * @param maxValue The maximum value that Y axis will have as a label * @param step (real) value distance from every label */ public ChartView setAxisBorderValues(int minValue, int maxValue, int step){ //if((maxValue - minValue) % step != 0) // Log.e(TAG, "Step value must be a divisor of distance between " + // "minValue and maxValue", new IllegalArgumentException()); if(orientation == Orientation.VERTICAL) { verController.maxLabelValue = maxValue; verController.minLabelValue = minValue; verController.step = step; }else { horController.maxLabelValue = maxValue; horController.minLabelValue = minValue; horController.step = step; } return this; } /** * A step is seen as the step to be defined between 2 labels. * As an example a step of 2 with a max label value of 6 will end * up with {0, 2, 4, 6} as labels. * * @param step (real) value distance from every label */ public ChartView setStep(int step){ if(step <= 0) Log.e(TAG, "Step can't be lower or equal to 0", new IllegalArgumentException()); if(orientation == Orientation.VERTICAL) verController.step = step; else horController.step = step; return this; } /** * Register a listener to be called when the chart is clicked. * * @param listener */ public void setOnEntryClickListener(OnEntryClickListener listener){ this.mEntryListener = listener; } /** * Register a listener to be called when the chart is clicked. * * @param listener */ @Override public void setOnClickListener(OnClickListener listener){ this.mChartListener = listener; } public ChartView setLabelColor(int color) { style.labelColor = color; return this; } public ChartView setFontSize(int size) { style.fontSize = size; return this; } public ChartView setTypeface(Typeface typeface) { style.typeface = typeface; return this; } /** * * @param spacing Spacing between left/right of the chart and the first/last label */ public ChartView setBorderSpacing(float spacing){ if(orientation == Orientation.VERTICAL) horController.borderSpacing = spacing; else verController.borderSpacing = spacing; return this; } /** * * @param spacing Spacing between top of the chart and the first label */ public ChartView setTopSpacing(float spacing){ if(orientation == Orientation.VERTICAL) verController.topSpacing = spacing; else horController.borderSpacing = spacing; return this; } /** * Apply grid to chart. * * @param type {@link GridType} for grid * @param paint The Paint instance that will be used to draw the grid * If null the grid won't be drawn */ public ChartView setGrid(GridType type, Paint paint){ if(type.compareTo(GridType.FULL) == 0){ style.hasVerticalGrid = true; style.hasHorizontalGrid = true; }else if(type.compareTo(GridType.VERTICAL) == 0){ style.hasVerticalGrid = true; }else{ style.hasHorizontalGrid = true; } style.gridPaint = paint; return this; } /** * To set a threshold line to the chart. * * @param value Threshold value. * @param paint The Paint instance that will be used to draw the grid * If null the grid won't be drawn */ public ChartView setThresholdLine(float value, Paint paint){ mThresholdValue = value; style.thresholdPaint = paint; return this; } /** * Mandatory horizontal border when necessary (ex: BarCharts) * Sets the attribute depending on the chart's orientation. * e.g. If orientation is VERTICAL it means that this attribute must be handled * by horizontal axis and not the vertical axis. */ protected ChartView setMandatoryBorderSpacing(){ if(orientation == Orientation.VERTICAL) horController.mandatoryBorderSpacing = 1; else verController.mandatoryBorderSpacing = 1; return this; } /** * Set the format to be added to Y labels. * * @param format Format to be applied */ public ChartView setLabelsFormat(DecimalFormat format){ verController.labelFormat = format; return this; } /* * ---------- * Style * ---------- */ /** * Class responsible to style the Graph! * Can be instantiated with or without attributes. */ class Style { private final static int DEFAULT_COLOR = -16777216; /** Chart */ protected Paint chartPaint; protected float axisThickness; protected int axisColor; /** Grid */ protected Paint gridPaint; protected boolean hasHorizontalGrid; protected boolean hasVerticalGrid; /** Threshold Line **/ private Paint thresholdPaint; /** Font */ protected Paint labelPaint; protected int labelColor; protected float fontSize; protected Typeface typeface; protected Style() { hasHorizontalGrid = false; hasVerticalGrid = false; axisColor = DEFAULT_COLOR; axisThickness = (float) getResources().getDimension(R.dimen.grid_thickness); labelColor = DEFAULT_COLOR; fontSize = getResources().getDimension(R.dimen.font_size); } protected Style(TypedArray attrs) { hasHorizontalGrid = false; hasVerticalGrid = false; axisColor = attrs.getColor( R.styleable.ChartAttrs_chart_axisColor, DEFAULT_COLOR); axisThickness = attrs.getDimension( R.styleable.ChartAttrs_chart_axisThickness, getResources().getDimension(R.dimen.axis_thickness)); labelColor = attrs.getColor( R.styleable.ChartAttrs_chart_labelColor, DEFAULT_COLOR); fontSize = attrs.getDimension( R.styleable.ChartAttrs_chart_fontSize, getResources().getDimension(R.dimen.font_size)); String typefaceName = attrs.getString(R.styleable.ChartAttrs_chart_typeface); if (typefaceName != null) typeface = Typeface.createFromAsset(getResources(). getAssets(), typefaceName); } private void init(){ chartPaint = new Paint(); chartPaint.setColor(axisColor); chartPaint.setStyle(Paint.Style.STROKE); chartPaint.setStrokeWidth(axisThickness); chartPaint.setAntiAlias(true); labelPaint = new Paint(); labelPaint.setColor(labelColor); labelPaint.setStyle(Paint.Style.FILL_AND_STROKE); labelPaint.setAntiAlias(true); labelPaint.setTextSize(fontSize); labelPaint.setTypeface(typeface); } public void clean() { chartPaint = null; labelPaint = null; gridPaint = null; thresholdPaint = null; } protected int getTextHeightBounds(String character){ if(character != ""){ Rect bounds = new Rect(); style.labelPaint .getTextBounds(character, 0, 1, bounds); return bounds.height(); } return 0; } } }