/******************************************************************************* * The MIT License (MIT) * * Copyright (c) 2013 Triggertrap Ltd * Author Neil Davies * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in * the Software without restriction, including without limitation the rights to * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of * the Software, and to permit persons to whom the Software is furnished to do so, * subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ******************************************************************************/ package com.triggertrap.seekarc; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.PorterDuff; import android.graphics.RectF; import android.graphics.drawable.Drawable; import android.support.v4.content.ContextCompat; import android.util.AttributeSet; import android.util.Log; import android.view.MotionEvent; import android.view.View; /** * * SeekArc.java * * This is a class that functions much like a SeekBar but * follows a circle path instead of a straight line. * * @author Neil Davies * */ public class SeekArc extends View { private static final String TAG = SeekArc.class.getSimpleName(); private static int INVALID_PROGRESS_VALUE = -1; // The initial rotational offset -90 means we start at 12 o'clock private final int mAngleOffset = -90; /** * The Drawable for the seek arc thumbnail */ private Drawable mThumb; /** * The Maximum value that this SeekArc can be set to */ private int mMax = 100; /** * The Current value that the SeekArc is set to */ private int mProgress = 0; /** * The width of the progress line for this SeekArc */ private int mProgressWidth = 4; /** * The Width of the background arc for the SeekArc */ private int mArcWidth = 2; /** * The Angle to start drawing this Arc from */ private int mStartAngle = 0; /** * The Angle through which to draw the arc (Max is 360) */ private int mSweepAngle = 360; /** * The rotation of the SeekArc- 0 is twelve o'clock */ private int mRotation = 0; /** * Give the SeekArc rounded edges */ private boolean mRoundedEdges = false; /** * Enable touch inside the SeekArc */ private boolean mTouchInside = true; /** * Will the progress increase clockwise or anti-clockwise */ private boolean mClockwise = true; /** * is the control enabled/touchable */ private boolean mEnabled = true; // Internal variables private int mArcRadius = 0; private float mProgressSweep = 0; private RectF mArcRect = new RectF(); private Paint mArcPaint; private Paint mProgressPaint; private int mTranslateX; private int mTranslateY; private int mThumbXPos; private int mThumbYPos; private double mTouchAngle; private float mTouchIgnoreRadius; private OnSeekArcChangeListener mOnSeekArcChangeListener; public interface OnSeekArcChangeListener { /** * Notification that the progress level has changed. Clients can use the * fromUser parameter to distinguish user-initiated changes from those * that occurred programmatically. * * @param seekArc * The SeekArc whose progress has changed * @param progress * The current progress level. This will be in the range * 0..max where max was set by * {@link SeekArc#setMax(int)}. (The default value for * max is 100.) * @param fromUser * True if the progress change was initiated by the user. */ void onProgressChanged(SeekArc seekArc, int progress, boolean fromUser); /** * Notification that the user has started a touch gesture. Clients may * want to use this to disable advancing the seekbar. * * @param seekArc * The SeekArc in which the touch gesture began */ void onStartTrackingTouch(SeekArc seekArc); /** * Notification that the user has finished a touch gesture. Clients may * want to use this to re-enable advancing the seekarc. * * @param seekArc * The SeekArc in which the touch gesture began */ void onStopTrackingTouch(SeekArc seekArc); } public SeekArc(Context context) { super(context); init(context, null, 0); } public SeekArc(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs, R.attr.seekArcStyle); } public SeekArc(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context, attrs, defStyle); } private void init(Context context, AttributeSet attrs, int defStyle) { Log.d(TAG, "Initialising SeekArc"); float density = context.getResources().getDisplayMetrics().density; // Defaults, may need to link this into theme settings int arcColor = ContextCompat.getColor(context, R.color.progress_gray); int progressColor = ContextCompat.getColor(context, R.color.default_blue_light); // Convert progress width to pixels for current density mProgressWidth = (int) (mProgressWidth * density); if (attrs != null) { // Attribute initialization final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SeekArc, defStyle, 0); arcColor = a.getColor(R.styleable.SeekArc_arcColor, arcColor); progressColor = a.getColor(R.styleable.SeekArc_progressColor, progressColor); Drawable thumb = a.getDrawable(R.styleable.SeekArc_thumb); if (thumb != null) { setThumb(thumb); mThumb.setColorFilter(progressColor, PorterDuff.Mode.SRC_IN); } else { setThumb(ContextCompat.getDrawable(context, R.drawable.seek_arc_control_selector)); } mMax = a.getInteger(R.styleable.SeekArc_max, mMax); mProgress = a.getInteger(R.styleable.SeekArc_progress, mProgress); mProgressWidth = (int) a.getDimension( R.styleable.SeekArc_progressWidth, mProgressWidth); mArcWidth = (int) a.getDimension(R.styleable.SeekArc_arcWidth, mArcWidth); mStartAngle = a.getInt(R.styleable.SeekArc_startAngle, mStartAngle); mSweepAngle = a.getInt(R.styleable.SeekArc_sweepAngle, mSweepAngle); mRotation = a.getInt(R.styleable.SeekArc_rotation, mRotation); mRoundedEdges = a.getBoolean(R.styleable.SeekArc_roundEdges, mRoundedEdges); mTouchInside = a.getBoolean(R.styleable.SeekArc_touchInside, mTouchInside); mClockwise = a.getBoolean(R.styleable.SeekArc_clockwise, mClockwise); mEnabled = a.getBoolean(R.styleable.SeekArc_enabled, mEnabled); a.recycle(); } mProgress = (mProgress > mMax) ? mMax : mProgress; mProgress = (mProgress < 0) ? 0 : mProgress; mSweepAngle = (mSweepAngle > 360) ? 360 : mSweepAngle; mSweepAngle = (mSweepAngle < 0) ? 0 : mSweepAngle; mProgressSweep = (float) mProgress / mMax * mSweepAngle; mStartAngle = (mStartAngle > 360) ? 0 : mStartAngle; mStartAngle = (mStartAngle < 0) ? 0 : mStartAngle; mArcPaint = new Paint(); mArcPaint.setColor(arcColor); mArcPaint.setAntiAlias(true); mArcPaint.setStyle(Paint.Style.STROKE); mArcPaint.setStrokeWidth(mArcWidth); //mArcPaint.setAlpha(45); mProgressPaint = new Paint(); mProgressPaint.setColor(progressColor); mProgressPaint.setAntiAlias(true); mProgressPaint.setStyle(Paint.Style.STROKE); mProgressPaint.setStrokeWidth(mProgressWidth); if (mRoundedEdges) { mArcPaint.setStrokeCap(Paint.Cap.ROUND); mProgressPaint.setStrokeCap(Paint.Cap.ROUND); } } @Override protected void onDraw(Canvas canvas) { if (!mClockwise) { canvas.scale(-1, 1, mArcRect.centerX(), mArcRect.centerY() ); } // Draw the arcs final int arcStart = mStartAngle + mAngleOffset + mRotation; final int arcSweep = mSweepAngle; canvas.drawArc(mArcRect, arcStart, arcSweep, false, mArcPaint); canvas.drawArc(mArcRect, arcStart, mProgressSweep, false, mProgressPaint); if (mEnabled) { // Draw the thumb nail canvas.translate(mTranslateX - mThumbXPos, mTranslateY - mThumbYPos); mThumb.draw(canvas); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int width = getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec); int height = getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec); int min = Math.min(width, height); mTranslateX = min / 2; mTranslateY = min / 2; int arcDiameter = min - getPaddingLeft() - getPaddingRight(); mArcRadius = arcDiameter / 2; int top = min / 2 - (arcDiameter / 2); int left = min / 2 - (arcDiameter / 2); mArcRect.set(left, top, left + arcDiameter, top + arcDiameter); int arcStart = (int) mProgressSweep + mStartAngle + mRotation - mAngleOffset; mThumbXPos = (int) (mArcRadius * Math.cos(Math.toRadians(arcStart))); mThumbYPos = (int) (mArcRadius * Math.sin(Math.toRadians(arcStart))); setTouchInSide(mTouchInside); setMeasuredDimension(min, min); } @Override public boolean onTouchEvent(MotionEvent event) { if (mEnabled) { getParent().requestDisallowInterceptTouchEvent(true); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: onStartTrackingTouch(); updateOnTouch(event); break; case MotionEvent.ACTION_MOVE: updateOnTouch(event); break; case MotionEvent.ACTION_UP: onStopTrackingTouch(); setPressed(false); getParent().requestDisallowInterceptTouchEvent(false); break; case MotionEvent.ACTION_CANCEL: onStopTrackingTouch(); setPressed(false); getParent().requestDisallowInterceptTouchEvent(false); break; } return true; } return false; } @Override protected void drawableStateChanged() { super.drawableStateChanged(); if (mThumb != null && mThumb.isStateful()) { int[] state = getDrawableState(); mThumb.setState(state); } invalidate(); } private void onStartTrackingTouch() { if (mOnSeekArcChangeListener != null) { mOnSeekArcChangeListener.onStartTrackingTouch(this); } } private void onStopTrackingTouch() { if (mOnSeekArcChangeListener != null) { mOnSeekArcChangeListener.onStopTrackingTouch(this); } } private void updateOnTouch(MotionEvent event) { boolean ignoreTouch = ignoreTouch(event.getX(), event.getY()); if (ignoreTouch) { return; } setPressed(true); mTouchAngle = getTouchDegrees(event.getX(), event.getY()); int progress = getProgressForAngle(mTouchAngle); onProgressRefresh(progress, true); } private boolean ignoreTouch(float xPos, float yPos) { boolean ignore = false; float x = xPos - mTranslateX; float y = yPos - mTranslateY; float touchRadius = (float) Math.sqrt(((x * x) + (y * y))); if (touchRadius < mTouchIgnoreRadius) { ignore = true; } return ignore; } private double getTouchDegrees(float xPos, float yPos) { float x = xPos - mTranslateX; float y = yPos - mTranslateY; //invert the x-coord if we are rotating anti-clockwise x = (mClockwise) ? x:-x; // convert to arc Angle double angle = Math.toDegrees(Math.atan2(y, x) + (Math.PI / 2) - Math.toRadians(mRotation)); if (angle < 0) { angle = 360 + angle; } angle -= mStartAngle; return angle; } private int getProgressForAngle(double angle) { int touchProgress = (int) Math.round(valuePerDegree() * angle); touchProgress = (touchProgress < 0) ? INVALID_PROGRESS_VALUE : touchProgress; touchProgress = (touchProgress > mMax) ? INVALID_PROGRESS_VALUE : touchProgress; return touchProgress; } private float valuePerDegree() { return (float) mMax / mSweepAngle; } private void onProgressRefresh(int progress, boolean fromUser) { updateProgress(progress, fromUser); } private void updateThumbPosition() { int thumbAngle = (int) (mStartAngle + mProgressSweep + mRotation + 90); mThumbXPos = (int) (mArcRadius * Math.cos(Math.toRadians(thumbAngle))); mThumbYPos = (int) (mArcRadius * Math.sin(Math.toRadians(thumbAngle))); } private void updateProgress(int progress, boolean fromUser) { if (progress == INVALID_PROGRESS_VALUE) { return; } progress = (progress > mMax) ? mMax : progress; progress = (progress < 0) ? 0 : progress; mProgress = progress; if (mOnSeekArcChangeListener != null) { mOnSeekArcChangeListener .onProgressChanged(this, progress, fromUser); } mProgressSweep = (float) progress / mMax * mSweepAngle; updateThumbPosition(); invalidate(); } /** * Sets a listener to receive notifications of changes to the SeekArc's * progress level. Also provides notifications of when the user starts and * stops a touch gesture within the SeekArc. * * @param l * The seek bar notification listener * * @see SeekArc.OnSeekArcChangeListener */ public void setOnSeekArcChangeListener(OnSeekArcChangeListener l) { mOnSeekArcChangeListener = l; } public void setThumb(Drawable thumb) { mThumb = thumb; int thumbHalfHeight = mThumb.getIntrinsicHeight() / 2; int thumbHalfWidth = mThumb.getIntrinsicWidth() / 2; mThumb.setBounds(-thumbHalfWidth, -thumbHalfHeight, thumbHalfWidth, thumbHalfHeight); } public Drawable getThumb() { return mThumb; } public void setProgress(int progress) { updateProgress(progress, false); } public int getProgress() { return mProgress; } public int getProgressWidth() { return mProgressWidth; } public void setProgressWidth(int mProgressWidth) { this.mProgressWidth = mProgressWidth; mProgressPaint.setStrokeWidth(mProgressWidth); } public int getArcWidth() { return mArcWidth; } public void setArcWidth(int mArcWidth) { this.mArcWidth = mArcWidth; mArcPaint.setStrokeWidth(mArcWidth); } public int getArcRotation() { return mRotation; } public void setArcRotation(int mRotation) { this.mRotation = mRotation; updateThumbPosition(); } public int getStartAngle() { return mStartAngle; } public void setStartAngle(int mStartAngle) { this.mStartAngle = mStartAngle; updateThumbPosition(); } public int getSweepAngle() { return mSweepAngle; } public void setSweepAngle(int mSweepAngle) { this.mSweepAngle = mSweepAngle; updateThumbPosition(); } public void setRoundedEdges(boolean isEnabled) { mRoundedEdges = isEnabled; if (mRoundedEdges) { mArcPaint.setStrokeCap(Paint.Cap.ROUND); mProgressPaint.setStrokeCap(Paint.Cap.ROUND); } else { mArcPaint.setStrokeCap(Paint.Cap.SQUARE); mProgressPaint.setStrokeCap(Paint.Cap.SQUARE); } } public void setTouchInSide(boolean isEnabled) { int thumbHalfHeight = mThumb.getIntrinsicHeight() / 2; int thumbHalfWidth = mThumb.getIntrinsicWidth() / 2; mTouchInside = isEnabled; if (mTouchInside) { mTouchIgnoreRadius = (float) mArcRadius / 4; } else { // Don't use the exact radius makes interaction too tricky mTouchIgnoreRadius = mArcRadius - Math.min(thumbHalfWidth, thumbHalfHeight); } } public void setClockwise(boolean isClockwise) { mClockwise = isClockwise; } public boolean isClockwise() { return mClockwise; } public boolean isEnabled() { return mEnabled; } public void setEnabled(boolean enabled) { this.mEnabled = enabled; } public int getProgressColor() { return mProgressPaint.getColor(); } public void setProgressColor(int color) { mProgressPaint.setColor(color); invalidate(); } public int getArcColor() { return mArcPaint.getColor(); } public void setArcColor(int color) { mArcPaint.setColor(color); invalidate(); } public int getMax() { return mMax; } public void setMax(int mMax) { this.mMax = mMax; } }