/*
* Copyright (C) 2015 Brent Marriott
*
* 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.hookedonplay.decoviewlib;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Path;
import android.graphics.RectF;
import android.os.Build;
import android.support.annotation.NonNull;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import com.hookedonplay.decoviewlib.charts.ChartSeries;
import com.hookedonplay.decoviewlib.charts.DecoDrawEffect;
import com.hookedonplay.decoviewlib.charts.LineArcSeries;
import com.hookedonplay.decoviewlib.charts.LineSeries;
import com.hookedonplay.decoviewlib.charts.PieSeries;
import com.hookedonplay.decoviewlib.charts.SeriesItem;
import com.hookedonplay.decoviewlib.events.DecoEvent;
import com.hookedonplay.decoviewlib.events.DecoEventManager;
import com.hookedonplay.decoviewlib.util.GenericFunctions;
import java.util.ArrayList;
/**
* Android Custom View for displaying animated Arc based charts
*/
@SuppressWarnings("unused")
public class DecoView extends View implements DecoEventManager.ArcEventManagerListener {
private final String TAG = getClass().getSimpleName();
/**
* Gravity settings
*/
private VertGravity mVertGravity = VertGravity.GRAVITY_VERTICAL_CENTER;
private HorizGravity mHorizGravity = HorizGravity.GRAVITY_HORIZONTAL_CENTER;
/**
* List of arcs to draw for this view. Generally this will be 1 series for the background arc
* and then 1 or more for the data being presented
*/
private ArrayList<ChartSeries> mChartSeries;
/**
* Width/Height of the view
*/
private int mCanvasWidth = -1;
private int mCanvasHeight = -1;
/**
* Bounds for drawing the arcs
*/
private RectF mArcBounds;
/**
* The default line width used for the arcs
*/
private float mDefaultLineWidth = 30;
/**
* RotateAngle adjusts the angle of the start point for drawing. It should be noted that the
* behavior is different based on if the arc is a full circle or a part circle. If it is a
* full circle the default position starts at 270 degrees while if it is a part circle the
* default start point is 90 degrees. This is by design to provide the most common positions
* as the defaults
*/
private int mRotateAngle;
/**
* Total angle of the orb. 360 = full circle, < 360 horseshoe/arc shape
*/
private int mTotalAngle = 360;
/**
* Event manager that controls the timing of events to be executed on the
* {@link DecoView}
*/
private DecoEventManager mDecoEventManager;
private float[] mMeasureViewableArea;
public DecoView(Context context) {
super(context);
initView();
}
public DecoView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.getTheme().obtainStyledAttributes(
attrs,
R.styleable.DecoView,
0, 0);
int rotateAngle = 0;
try {
mDefaultLineWidth = a.getDimension(R.styleable.DecoView_dv_lineWidth, 30f);
rotateAngle = a.getInt(R.styleable.DecoView_dv_rotateAngle, 0);
mTotalAngle = a.getInt(R.styleable.DecoView_dv_totalAngle, 360);
mVertGravity = VertGravity.values()[a.getInt(R.styleable.DecoView_dv_arc_gravity_vertical, VertGravity.GRAVITY_VERTICAL_CENTER.ordinal())];
mHorizGravity = HorizGravity.values()[a.getInt(R.styleable.DecoView_dv_arc_gravity_horizontal, HorizGravity.GRAVITY_HORIZONTAL_CENTER.ordinal())];
} finally {
a.recycle();
}
configureAngles(mTotalAngle, rotateAngle);
initView();
}
public DecoView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initView();
}
/**
* Alter the total degrees of the ArcView and applies a rotation angle to change the start
* position. If this is 360 then the view is a full circle. 270 degrees is 3/4 of a circle
*
* @param totalAngle Total angle of the view in degrees
* @param rotateAngle Number of degrees to rotate the start position
*/
public void configureAngles(int totalAngle, int rotateAngle) {
if (totalAngle <= 0) {
throw new IllegalArgumentException("Total angle of the arc must be > 0");
}
final int circleStartPosition = 270;
final int arcStartPosition = 90;
final int degreesInCircle = 360;
mTotalAngle = totalAngle;
mRotateAngle = (circleStartPosition + rotateAngle) % degreesInCircle;
if (mTotalAngle < degreesInCircle) {
mRotateAngle = ((arcStartPosition + (degreesInCircle - totalAngle) / 2) + rotateAngle) % degreesInCircle;
}
if (mChartSeries != null) {
for (ChartSeries chartSeries : mChartSeries) {
chartSeries.setupView(mTotalAngle, mRotateAngle);
}
}
}
private void initView() {
GenericFunctions.initialize(getContext());
enableCompatibilityMode();
createVisualEditorTrack();
}
/**
* Retrieve event manager for delayed events
*
* @return event manager
*/
private DecoEventManager getEventManager() {
if (mDecoEventManager == null) {
mDecoEventManager = new DecoEventManager(this);
}
return mDecoEventManager;
}
/**
* Determines if any arcs have been added to the view
*
* @return true if one or more arcs have been added to the view
*/
public boolean isEmpty() {
return mChartSeries == null || mChartSeries.isEmpty();
}
/**
* Add a new item to the ArcView. An ArcView may have any number of arcs
*
* @param seriesItem orb item attributes
* @return index into orb item list
*/
public int addSeries(@NonNull SeriesItem seriesItem) {
if (mChartSeries == null) {
mChartSeries = new ArrayList<>();
}
seriesItem.addArcSeriesItemListener(new SeriesItem.SeriesItemListener() {
@Override
public void onSeriesItemAnimationProgress(float percentComplete, float currentPosition) {
invalidate();
}
@Override
public void onSeriesItemDisplayProgress(float percentComplete) {
invalidate();
}
});
if (seriesItem.getLineWidth() < 0) {
seriesItem.setLineWidth(mDefaultLineWidth);
}
ChartSeries chartSeries;
switch (seriesItem.getChartStyle()) {
case STYLE_DONUT:
chartSeries = new LineArcSeries(seriesItem, mTotalAngle, mRotateAngle);
break;
case STYLE_PIE:
chartSeries = new PieSeries(seriesItem, mTotalAngle, mRotateAngle);
break;
case STYLE_LINE_HORIZONTAL:
case STYLE_LINE_VERTICAL:
Log.w(TAG, "STYLE_LINE_* is currently experimental");
LineSeries lineSeries = new LineSeries(seriesItem, mTotalAngle, mRotateAngle);
lineSeries.setHorizGravity(mHorizGravity);
lineSeries.setVertGravity(mVertGravity);
chartSeries = lineSeries;
break;
default:
throw new IllegalStateException("Chart Style not implemented");
}
mChartSeries.add(mChartSeries.size(), chartSeries);
mMeasureViewableArea = new float[mChartSeries.size()];
recalcLayout();
return mChartSeries.size() - 1;
}
/**
* When displaying this view in the visual design editor we just mock up a background
* series. This will not be executed when your app is run
*/
private void createVisualEditorTrack() {
if (isInEditMode()) {
addSeries(new SeriesItem.Builder(Color.argb(255, 218, 218, 218))
.setRange(0, 100, 100)
.setLineWidth(mDefaultLineWidth)
.build());
addSeries(new SeriesItem.Builder(Color.argb(255, 255, 64, 64))
.setRange(0, 100, 25)
.setLineWidth(mDefaultLineWidth)
.build());
}
}
@Override
protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
super.onSizeChanged(width, height, oldWidth, oldHeight);
mCanvasWidth = width;
mCanvasHeight = height;
recalcLayout();
}
/**
* Calculate the bounds based on the size of the view and the maximum width of any of the
* ArcSeries. Must be called when:
* <p/>
* (a) OnSizeChanged() is called
* (b) A new series of data is added
*/
private void recalcLayout() {
if (mCanvasWidth <= 0 || mCanvasHeight <= 0) {
return;
}
float offsetLineWidth = getWidestLine() / 2;
float offsetX = 0;
float offsetY = 0;
if (mCanvasWidth != mCanvasHeight) {
if (mCanvasWidth > mCanvasHeight) {
offsetX = (mCanvasWidth - mCanvasHeight) / 2;
} else {
offsetY = (mCanvasHeight - mCanvasWidth) / 2;
}
}
if (mVertGravity == VertGravity.GRAVITY_VERTICAL_FILL) {
offsetY = 0;
}
if (mHorizGravity == HorizGravity.GRAVITY_HORIZONTAL_FILL) {
offsetX = 0;
}
/**
* Respect the padding of the view and ensure we have at least that amount of
* space on each edge
*/
float paddingLeft = offsetX + getPaddingLeft();
float paddingTop = offsetY + getPaddingTop();
float paddingRight = offsetX + getPaddingRight();
float paddingBottom = offsetY + getPaddingBottom();
mArcBounds = new RectF(offsetLineWidth + paddingLeft,
offsetLineWidth + paddingTop,
mCanvasWidth - offsetLineWidth - paddingRight,
mCanvasHeight - offsetLineWidth - paddingBottom);
if (mVertGravity == VertGravity.GRAVITY_VERTICAL_TOP) {
mArcBounds.offset(0, -offsetY);
} else if (mVertGravity == VertGravity.GRAVITY_VERTICAL_BOTTOM) {
mArcBounds.offset(0, offsetY);
}
if (mHorizGravity == HorizGravity.GRAVITY_HORIZONTAL_LEFT) {
mArcBounds.offset(-offsetX, 0);
} else if (mHorizGravity == HorizGravity.GRAVITY_HORIZONTAL_RIGHT) {
mArcBounds.offset(offsetX, 0);
}
}
/**
* find the width of the widest line used for any of the series of data
*
* @return widest arc line
*/
private float getWidestLine() {
if (mChartSeries == null) {
return 0;
}
float widest = 0;
for (ChartSeries chartSeries : mChartSeries) {
widest = Math.max(chartSeries.getSeriesItem().getLineWidth(), widest);
}
return widest;
}
/**
* Drawing routine that is called automatically when the view needs to be redrawn
*
* @param canvas the canvas on which the view will be drawn
*/
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mArcBounds == null || mArcBounds.isEmpty()) {
return;
}
if (mChartSeries != null) {
boolean labelsSupported = true;
for (int i = 0; i < mChartSeries.size(); i++) {
ChartSeries chartSeries = mChartSeries.get(i);
chartSeries.draw(canvas, mArcBounds);
// labels Unsupported if one or more series run anticlockwise
labelsSupported &= (!chartSeries.isVisible() || chartSeries.getSeriesItem().getSpinClockwise());
mMeasureViewableArea[i] = getLabelPosition(i);
}
// Draw the labels as a second pass as we want all labels to be on top of all
// series data
if (labelsSupported) {
for (int i = 0; i < mMeasureViewableArea.length; i++) {
if (mMeasureViewableArea[i] >= 0f) {
ChartSeries chartSeries = mChartSeries.get(i);
chartSeries.drawLabel(canvas, mArcBounds, mMeasureViewableArea[i]);
//TODO: Keep bounds of all labels and don't allow overlap
}
}
}
}
}
/**
* Determine where a label should be displayed given its position and the position of all
* other data series
*
* @param index position of index in {@link #mChartSeries} array
* @return < 0 if label not visible, else 0f .. 1.0f to indicate position on circle
*/
private float getLabelPosition(final int index) {
float max = 0.0f;
ChartSeries chartSeries = mChartSeries.get(index);
// We only need to check those series drawn after this series
for (int i = index + 1; i < mChartSeries.size(); i++) {
ChartSeries innerSeries = mChartSeries.get(i);
if (innerSeries.isVisible() && max < innerSeries.getPositionPercent()) {
max = innerSeries.getPositionPercent();
}
}
if (max < chartSeries.getPositionPercent()) {
// Adjust for incomplete circles
float adjusted = ((chartSeries.getPositionPercent() + max) / 2) * ((float) mTotalAngle / 360f);
// Adjust for rotation of start point
float adjust = adjusted + (((float) mRotateAngle + 90f) / 360f);
// Normalize
while (adjust > 1.0f) {
adjust -= 1.0f;
}
return adjust;
}
return -1f;
}
/**
* Execute a move event
*
* @param event Event to execute
*/
private void executeMove(@NonNull DecoEvent event) {
if ((event.getEventType() != DecoEvent.EventType.EVENT_MOVE) &&
(event.getEventType() != DecoEvent.EventType.EVENT_COLOR_CHANGE)) {
return;
}
if (mChartSeries != null) {
if (mChartSeries.size() <= event.getIndexPosition()) {
throw new IllegalArgumentException("Invalid index: Position out of range (Index: " + event.getIndexPosition() + " Series Count: " + mChartSeries.size() + ")");
}
final int index = event.getIndexPosition();
if (index >= 0 && index < mChartSeries.size()) {
ChartSeries item = mChartSeries.get(event.getIndexPosition());
if (event.getEventType() == DecoEvent.EventType.EVENT_COLOR_CHANGE) {
item.startAnimateColorChange(event);
} else {
item.startAnimateMove(event);
}
} else {
Log.e(TAG, "Ignoring move request: Invalid array index. Index: " + index + " Size: " + mChartSeries.size());
}
}
}
/**
* Add an event to the DynamicArcViews {@link DecoEventManager} for processing. This can be
* executed immediately or if the event has a {@link DecoEvent#mDelay} set then it will be
* executed at a future time.
* <p/>
* When this event is to be executed the {@link DecoEventManager.ArcEventManagerListener#onExecuteEventStart(DecoEvent)}
* callback will be executed
* <p/>
* To create an event see {@link DecoEvent.Builder}
*
* @param event Event to be processed
*/
public void addEvent(@NonNull DecoEvent event) {
getEventManager().add(event);
}
/**
* Basic wrapper function to create an event with all defaults for the arc and simply execute
* a move for the current position of the arc. If you want to customize the move (such as delay,
* speed, interpolator...) then you need to use create an {@link DecoEvent} and call
* {@link #addEvent(DecoEvent)}
*
* @param index index of the arc series to apply the move
* @param position position of the arc
*/
public void moveTo(int index, float position) {
addEvent(new DecoEvent.Builder(position).setIndex(index).build());
}
/**
* Basic wrapper function to create an event with all defaults for the arc and simply execute
* a move for the current position of the arc. If you want to customize the move (such as delay,
* speed, interpolator...) then you need to use create an {@link DecoEvent} and call
* {@link #addEvent(DecoEvent)}
* <p/>
* This function will not create a {@link DecoEvent} if you pass 0 as the duration
*
* @param index index of the arc series to apply the move
* @param position position of the arc
* @param duration duration of the move
*/
public void moveTo(int index, float position, int duration) {
if (duration == 0) {
getChartSeries(index).setPosition(position);
invalidate();
return;
}
addEvent(new DecoEvent.Builder(position).setIndex(index).setDuration(duration).build());
}
/**
* Reset all arcs back to the start positions and remove all queued events
*/
public void executeReset() {
if (mDecoEventManager != null) {
mDecoEventManager.resetEvents();
}
if (mChartSeries != null) {
for (ChartSeries chartSeries : mChartSeries) {
chartSeries.reset();
}
}
}
/**
* Remove all scheduled events and all data series
*/
public void deleteAll() {
if (mDecoEventManager != null) {
mDecoEventManager.resetEvents();
}
mChartSeries = null;
}
/**
* Process event reveal as required
*
* @param event DecoEvent to process
* @return true if handled
*/
@SuppressWarnings("UnusedReturnValue")
private boolean executeReveal(@NonNull DecoEvent event) {
if ((event.getEventType() != DecoEvent.EventType.EVENT_SHOW) &&
(event.getEventType() != DecoEvent.EventType.EVENT_HIDE)) {
return false;
}
if (event.getEventType() == DecoEvent.EventType.EVENT_SHOW) {
setVisibility(View.VISIBLE);
}
if (mChartSeries != null) {
for (int i = 0; i < mChartSeries.size(); i++) {
if ((event.getIndexPosition() == i) || (event.getIndexPosition() < 0)) {
ChartSeries chartSeries = mChartSeries.get(i);
chartSeries.startAnimateHideShow(event, event.getEventType() == DecoEvent.EventType.EVENT_SHOW);
}
}
}
return true;
}
/**
* Process event effect as required
*
* @param event DecoEvent to process
* @return true is handled
*/
@SuppressWarnings("UnusedReturnValue")
private boolean executeEffect(@NonNull DecoEvent event) {
if (event.getEventType() != DecoEvent.EventType.EVENT_EFFECT) {
return false;
}
if (mChartSeries == null) {
return false;
}
if (event.getIndexPosition() < 0) {
Log.e(TAG, "EffectType " + event.getEventType().toString() + " must specify valid data series index");
return false;
}
/**
* The EFFECT_SPIRAL_EXPLODE is a special case where different operations are applied to
* different series automatically. Must specify a valid series to use this effect
*/
if (event.getEffectType() == DecoDrawEffect.EffectType.EFFECT_SPIRAL_EXPLODE) {
// hide all series, except the one to apply the effect
for (int i = 0; i < mChartSeries.size(); i++) {
ChartSeries chartSeries = mChartSeries.get(i);
if (i != event.getIndexPosition()) {
chartSeries.startAnimateHideShow(event, false);
} else {
chartSeries.startAnimateEffect(event);
}
}
return true;
}
for (int i = 0; i < mChartSeries.size(); i++) {
if ((event.getIndexPosition() == i) || event.getIndexPosition() < 0) {
ChartSeries chartSeries = mChartSeries.get(i);
chartSeries.startAnimateEffect(event);
}
}
return true;
}
/**
* This is called when the view is detached from a window. At this point it no longer has a
* surface for drawing, so we need to remove all scheduled events from the event manager
*/
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mDecoEventManager != null) {
mDecoEventManager.resetEvents();
}
}
/**
* Event Manager wants to start an event. It is this classes responsibility to execute the
* event
*
* @param event Event to be executed
*/
@Override
public void onExecuteEventStart(@NonNull DecoEvent event) {
executeMove(event);
executeReveal(event);
executeEffect(event);
}
/**
* Set the Vertical gravity of the DecoView
*
* @param vertGravity Vertical Gravity
*/
public void setVertGravity(VertGravity vertGravity) {
mVertGravity = vertGravity;
}
/**
* Set the Horizontal Gravity of the DecoView
*
* @param horizGravity Horizontal Gravity
*/
public void setHorizGravity(HorizGravity horizGravity) {
mHorizGravity = horizGravity;
}
/**
* Allows your app to use the EdgeDetail decoration by disabling Hardware acceleration
* for the view on android API 11 - 17.
* <p/>
* Calling this function will do nothing on all other API versions
* <p/>
* This will turn off Hardware Acceleration for this view only
* <p/>
* If you do not call this function and you use EdgeDetails they will not display on the
* affected API versions.
* <p/>
* For more information about Hardware acceleration and this issue
* {@see http://developer.android.com/guide/topics/graphics/hardware-accel.html}
* <p/>
* The function causing the incompatibility is
* {@link Canvas#clipPath(Path)}
* This is used to clip the drawing rectangle to help render the Edge details decorations
*/
public void enableCompatibilityMode() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
setLayerType(LAYER_TYPE_SOFTWARE, null);
}
}
/**
* Allows DecoView to draw drop shadows. This should be enabled if you plan on using the
* feature SeriesItem.setShadowSize(float) to add a drop shadow on one or more of your arc
* series. Calling this function need only be done once.
*/
public void disableHardwareAccelerationForDecoView() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
setLayerType(LAYER_TYPE_SOFTWARE, null);
}
}
/**
* Retrieve the {@link SeriesItem} based on the index
*
* @param index index of the series item
* @return SeriesItem
*/
@Deprecated
public SeriesItem getSeriesItem(int index) {
if (index >= 0 && index < mChartSeries.size()) {
return mChartSeries.get(index).getSeriesItem();
}
return null;
}
/**
* Retrieve the {@link SeriesItem} based on the index
*
* @param index index of the series item
* @return ChartSeries at given index
*/
public ChartSeries getChartSeries(int index) {
if (index >= 0 && index < mChartSeries.size()) {
return mChartSeries.get(index);
}
return null;
}
/**
* Vertical positioning values
*/
public enum VertGravity {
GRAVITY_VERTICAL_TOP,
GRAVITY_VERTICAL_CENTER,
GRAVITY_VERTICAL_BOTTOM,
GRAVITY_VERTICAL_FILL
}
/**
* Horizontal positioning values
*/
public enum HorizGravity {
GRAVITY_HORIZONTAL_LEFT,
GRAVITY_HORIZONTAL_CENTER,
GRAVITY_HORIZONTAL_RIGHT,
GRAVITY_HORIZONTAL_FILL
}
}