/* * * Copyright 2013 Matt Joseph * Copyright 2018 Tankery Chen * * 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. * * * * This custom view/widget was inspired and guided by: * * HoloCircleSeekBar - Copyright 2012 Jes�s Manzano * HoloColorPicker - Copyright 2012 Lars Werkman (Designed by Marie Schweiz) * * Although I did not used the code from either project directly, they were both used as * reference material, and as a result, were extremely helpful. */ package me.tankery.lib.circularseekbar; import android.content.Context; import android.content.res.TypedArray; import android.graphics.BlurMaskFilter; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PathMeasure; import android.graphics.RectF; import android.os.Build; import android.os.Bundle; import android.os.Parcelable; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; public class CircularSeekBar extends View { /** * Used to scale the dp units to pixels */ private final float DPTOPX_SCALE = getResources().getDisplayMetrics().density; /** * Minimum touch target size in DP. 48dp is the Android design recommendation */ private static final float MIN_TOUCH_TARGET_DP = 48; /** * For some case we need the degree to have small bias to avoid overflow. */ private static final float SMALL_DEGREE_BIAS = .1f; /** * Radius of progress glow, in dp unit. */ private static final float PROGRESS_GLOW_RADIUS_DP = 5f; // Default values private static final int DEFAULT_CIRCLE_STYLE = Paint.Cap.ROUND.ordinal(); private static final float DEFAULT_CIRCLE_X_RADIUS = 30f; private static final float DEFAULT_CIRCLE_Y_RADIUS = 30f; private static final float DEFAULT_POINTER_STROKE_WIDTH = 14f; private static final float DEFAULT_POINTER_HALO_WIDTH = 6f; private static final float DEFAULT_POINTER_HALO_BORDER_WIDTH = 0f; private static final float DEFAULT_CIRCLE_STROKE_WIDTH = 5f; private static final float DEFAULT_START_ANGLE = 270f; // Geometric (clockwise, relative to 3 o'clock) private static final float DEFAULT_END_ANGLE = 270f; // Geometric (clockwise, relative to 3 o'clock) private static final float DEFAULT_POINTER_ANGLE = 0; private static final int DEFAULT_MAX = 100; private static final int DEFAULT_PROGRESS = 0; private static final int DEFAULT_CIRCLE_COLOR = Color.DKGRAY; private static final int DEFAULT_CIRCLE_PROGRESS_COLOR = Color.argb(235, 74, 138, 255); private static final int DEFAULT_POINTER_COLOR = Color.argb(235, 74, 138, 255); private static final int DEFAULT_POINTER_HALO_COLOR = Color.argb(135, 74, 138, 255); private static final int DEFAULT_POINTER_HALO_COLOR_ONTOUCH = Color.argb(135, 74, 138, 255); private static final int DEFAULT_CIRCLE_FILL_COLOR = Color.TRANSPARENT; private static final int DEFAULT_POINTER_ALPHA = 135; private static final int DEFAULT_POINTER_ALPHA_ONTOUCH = 100; private static final boolean DEFAULT_USE_CUSTOM_RADII = false; private static final boolean DEFAULT_MAINTAIN_EQUAL_CIRCLE = true; private static final boolean DEFAULT_MOVE_OUTSIDE_CIRCLE = false; private static final boolean DEFAULT_LOCK_ENABLED = true; private static final boolean DEFAULT_DISABLE_POINTER = false; private static final boolean DEFAULT_NEGATIVE_ENABLED = false; private static final boolean DEFAULT_DISABLE_PROGRESS_GLOW = true; private static final boolean DEFAULT_CS_HIDE_PROGRESS_WHEN_EMPTY = false; /** * {@code Paint} instance used to draw the inactive circle. */ private Paint mCirclePaint; /** * {@code Paint} instance used to draw the circle fill. */ private Paint mCircleFillPaint; /** * {@code Paint} instance used to draw the active circle (represents progress). */ private Paint mCircleProgressPaint; /** * If progress glow is disabled, there is no glow from the progress bar when filled * * NOTE: To enable glow effect, please make sure this view is rendering with hardware * accelerate disabled. (Checkout this doc for details of hardware accelerate: * https://developer.android.com/guide/topics/graphics/hardware-accel) */ private boolean mDisableProgressGlow; /** * {@code Paint} instance used to draw the glow from the active circle. */ private Paint mCircleProgressGlowPaint; /** * {@code Paint} instance used to draw the center of the pointer. * Note: This is broken on 4.0+, as BlurMasks do not work with hardware acceleration. */ private Paint mPointerPaint; /** * {@code Paint} instance used to draw the halo of the pointer. * Note: The halo is the part that changes transparency. */ private Paint mPointerHaloPaint; /** * {@code Paint} instance used to draw the border of the pointer, outside of the halo. */ private Paint mPointerHaloBorderPaint; /** * The style of the circle, can be butt, round or square. */ private Paint.Cap mCircleStyle; /** * current in negative half cycle. */ private boolean mIsInNegativeHalf; /** * The width of the circle (in pixels). */ private float mCircleStrokeWidth; /** * The X radius of the circle (in pixels). */ private float mCircleXRadius; /** * The Y radius of the circle (in pixels). */ private float mCircleYRadius; /** * If disable pointer, we can't seek the progress. */ private boolean mDisablePointer; /** * The radius of the pointer (in pixels). */ private float mPointerStrokeWidth; /** * The width of the pointer halo (in pixels). */ private float mPointerHaloWidth; /** * The width of the pointer halo border (in pixels). */ private float mPointerHaloBorderWidth; /** * Angle of the pointer arc. * Default is 0, the pointer is a circle when angle is 0 and the style is round. * Can not less then 0. can not longer than 360. */ private float mPointerAngle; /** * Start angle of the CircularSeekBar. * Note: If mStartAngle and mEndAngle are set to the same angle, 0.1 is subtracted * from the mEndAngle to make the circle function properly. */ private float mStartAngle; /** * End angle of the CircularSeekBar. * Note: If mStartAngle and mEndAngle are set to the same angle, 0.1 is subtracted * from the mEndAngle to make the circle function properly. */ private float mEndAngle; /** * {@code RectF} that represents the circle (or ellipse) of the seekbar. */ private final RectF mCircleRectF = new RectF(); /** * Holds the color value for {@code mPointerPaint} before the {@code Paint} instance is created. */ private int mPointerColor = DEFAULT_POINTER_COLOR; /** * Holds the color value for {@code mPointerHaloPaint} before the {@code Paint} instance is created. */ private int mPointerHaloColor = DEFAULT_POINTER_HALO_COLOR; /** * Holds the color value for {@code mPointerHaloPaint} before the {@code Paint} instance is created. */ private int mPointerHaloColorOnTouch = DEFAULT_POINTER_HALO_COLOR_ONTOUCH; /** * Holds the color value for {@code mCirclePaint} before the {@code Paint} instance is created. */ private int mCircleColor = DEFAULT_CIRCLE_COLOR; /** * Holds the color value for {@code mCircleFillPaint} before the {@code Paint} instance is created. */ private int mCircleFillColor = DEFAULT_CIRCLE_FILL_COLOR; /** * Holds the color value for {@code mCircleProgressPaint} before the {@code Paint} instance is created. */ private int mCircleProgressColor = DEFAULT_CIRCLE_PROGRESS_COLOR; /** * Holds the alpha value for {@code mPointerHaloPaint}. */ private int mPointerAlpha = DEFAULT_POINTER_ALPHA; /** * Holds the OnTouch alpha value for {@code mPointerHaloPaint}. */ private int mPointerAlphaOnTouch = DEFAULT_POINTER_ALPHA_ONTOUCH; /** * Distance (in degrees) that the the circle/semi-circle makes up. * This amount represents the max of the circle in degrees. */ private float mTotalCircleDegrees; /** * Distance (in degrees) that the current progress makes up in the circle. */ private float mProgressDegrees; /** * {@code Path} used to draw the circle/semi-circle. */ private Path mCirclePath; /** * {@code Path} used to draw the progress on the circle. */ private Path mCircleProgressPath; /** * {@code Path} used to draw the pointer arc on the circle. */ private Path mCirclePonterPath; /** * Max value that this CircularSeekBar is representing. */ private float mMax; /** * Progress value that this CircularSeekBar is representing. */ private float mProgress; /** * Used for enabling/disabling the negative progress bar. * */ private boolean mNegativeEnabled; /** * If true, then the user can specify the X and Y radii. * If false, then the View itself determines the size of the CircularSeekBar. */ private boolean mCustomRadii; /** * Maintain a perfect circle (equal x and y radius), regardless of view or custom attributes. * The smaller of the two radii will always be used in this case. * The default is to be a circle and not an ellipse, due to the behavior of the ellipse. */ private boolean mMaintainEqualCircle; /** * Once a user has touched the circle, this determines if moving outside the circle is able * to change the position of the pointer (and in turn, the progress). */ private boolean mMoveOutsideCircle; /** * Used for enabling/disabling the lock option for easier hitting of the 0 progress mark. * */ private boolean mLockEnabled = true; /** * Used for when the user moves beyond the start of the circle when moving counter clockwise. * Makes it easier to hit the 0 progress mark. */ private boolean mLockAtStart = true; /** * Used for when the user moves beyond the end of the circle when moving clockwise. * Makes it easier to hit the 100% (max) progress mark. */ private boolean mLockAtEnd = false; /** * If progress is zero, hide the progress bar. */ private boolean mHideProgressWhenEmpty; /** * When the user is touching the circle on ACTION_DOWN, this is set to true. * Used when touching the CircularSeekBar. */ private boolean mUserIsMovingPointer = false; /** * The width of the circle used in the {@code RectF} that is used to draw it. * Based on either the View width or the custom X radius. */ private float mCircleWidth; /** * The height of the circle used in the {@code RectF} that is used to draw it. * Based on either the View width or the custom Y radius. */ private float mCircleHeight; /** * Represents the progress mark on the circle, in geometric degrees. * This is not provided by the user; it is calculated; */ private float mPointerPosition; /** * Pointer position in terms of X and Y coordinates. */ private final float[] mPointerPositionXY = new float[2]; /** * Listener. */ private OnCircularSeekBarChangeListener mOnCircularSeekBarChangeListener; /** * Initialize the CircularSeekBar with the attributes from the XML style. * Uses the defaults defined at the top of this file when an attribute is not specified by the user. * @param attrArray TypedArray containing the attributes. */ private void initAttributes(TypedArray attrArray) { mCircleXRadius = attrArray.getDimension(R.styleable.cs_CircularSeekBar_cs_circle_x_radius, DEFAULT_CIRCLE_X_RADIUS); mCircleYRadius = attrArray.getDimension(R.styleable.cs_CircularSeekBar_cs_circle_y_radius, DEFAULT_CIRCLE_Y_RADIUS); mPointerStrokeWidth = attrArray.getDimension(R.styleable.cs_CircularSeekBar_cs_pointer_stroke_width, DEFAULT_POINTER_STROKE_WIDTH); mPointerHaloWidth = attrArray.getDimension(R.styleable.cs_CircularSeekBar_cs_pointer_halo_width, DEFAULT_POINTER_HALO_WIDTH); mPointerHaloBorderWidth = attrArray.getDimension(R.styleable.cs_CircularSeekBar_cs_pointer_halo_border_width, DEFAULT_POINTER_HALO_BORDER_WIDTH); mCircleStrokeWidth = attrArray.getDimension(R.styleable.cs_CircularSeekBar_cs_circle_stroke_width, DEFAULT_CIRCLE_STROKE_WIDTH); int circleStyle = attrArray.getInt(R.styleable.cs_CircularSeekBar_cs_circle_style, DEFAULT_CIRCLE_STYLE); mCircleStyle = Paint.Cap.values()[circleStyle]; mPointerColor = attrArray.getColor(R.styleable.cs_CircularSeekBar_cs_pointer_color, DEFAULT_POINTER_COLOR); mPointerHaloColor = attrArray.getColor(R.styleable.cs_CircularSeekBar_cs_pointer_halo_color, DEFAULT_POINTER_HALO_COLOR); mPointerHaloColorOnTouch = attrArray.getColor(R.styleable.cs_CircularSeekBar_cs_pointer_halo_color_ontouch, DEFAULT_POINTER_HALO_COLOR_ONTOUCH); mCircleColor = attrArray.getColor(R.styleable.cs_CircularSeekBar_cs_circle_color, DEFAULT_CIRCLE_COLOR); mCircleProgressColor = attrArray.getColor(R.styleable.cs_CircularSeekBar_cs_circle_progress_color, DEFAULT_CIRCLE_PROGRESS_COLOR); mCircleFillColor = attrArray.getColor(R.styleable.cs_CircularSeekBar_cs_circle_fill, DEFAULT_CIRCLE_FILL_COLOR); mPointerAlpha = Color.alpha(mPointerHaloColor); mPointerAlphaOnTouch = attrArray.getInt(R.styleable.cs_CircularSeekBar_cs_pointer_alpha_ontouch, DEFAULT_POINTER_ALPHA_ONTOUCH); if (mPointerAlphaOnTouch > 255 || mPointerAlphaOnTouch < 0) { mPointerAlphaOnTouch = DEFAULT_POINTER_ALPHA_ONTOUCH; } mMax = attrArray.getInt(R.styleable.cs_CircularSeekBar_cs_max, DEFAULT_MAX); mProgress = attrArray.getInt(R.styleable.cs_CircularSeekBar_cs_progress, DEFAULT_PROGRESS); mCustomRadii = attrArray.getBoolean(R.styleable.cs_CircularSeekBar_cs_use_custom_radii, DEFAULT_USE_CUSTOM_RADII); mMaintainEqualCircle = attrArray.getBoolean(R.styleable.cs_CircularSeekBar_cs_maintain_equal_circle, DEFAULT_MAINTAIN_EQUAL_CIRCLE); mMoveOutsideCircle = attrArray.getBoolean(R.styleable.cs_CircularSeekBar_cs_move_outside_circle, DEFAULT_MOVE_OUTSIDE_CIRCLE); mLockEnabled = attrArray.getBoolean(R.styleable.cs_CircularSeekBar_cs_lock_enabled, DEFAULT_LOCK_ENABLED); mDisablePointer = attrArray.getBoolean(R.styleable.cs_CircularSeekBar_cs_disable_pointer, DEFAULT_DISABLE_POINTER); mNegativeEnabled = attrArray.getBoolean(R.styleable.cs_CircularSeekBar_cs_negative_enabled, DEFAULT_NEGATIVE_ENABLED); mIsInNegativeHalf = false; mDisableProgressGlow = attrArray.getBoolean(R.styleable.cs_CircularSeekBar_cs_disable_progress_glow, DEFAULT_DISABLE_PROGRESS_GLOW); mHideProgressWhenEmpty = attrArray.getBoolean(R.styleable.cs_CircularSeekBar_cs_hide_progress_when_empty, DEFAULT_CS_HIDE_PROGRESS_WHEN_EMPTY); // Modulo 360 right now to avoid constant conversion mStartAngle = ((360f + (attrArray.getFloat((R.styleable.cs_CircularSeekBar_cs_start_angle), DEFAULT_START_ANGLE) % 360f)) % 360f); mEndAngle = ((360f + (attrArray.getFloat((R.styleable.cs_CircularSeekBar_cs_end_angle), DEFAULT_END_ANGLE) % 360f)) % 360f); // Disable negative progress if is semi-oval. if (mStartAngle != mEndAngle) { mNegativeEnabled = false; } if (mStartAngle % 360f == mEndAngle % 360f) { //mStartAngle = mStartAngle + 1f; mEndAngle = mEndAngle - SMALL_DEGREE_BIAS; } // Modulo 360 right now to avoid constant conversion mPointerAngle = ((360f + (attrArray.getFloat((R.styleable.cs_CircularSeekBar_cs_pointer_angle), DEFAULT_POINTER_ANGLE) % 360f)) % 360f); if (mPointerAngle == 0f) { mPointerAngle = SMALL_DEGREE_BIAS; } if (mDisablePointer) { mPointerStrokeWidth = 0; mPointerHaloWidth = 0; mPointerHaloBorderWidth = 0; } } /** * Initializes the {@code Paint} objects with the appropriate styles. */ private void initPaints() { mCirclePaint = new Paint(); mCirclePaint.setAntiAlias(true); mCirclePaint.setDither(true); mCirclePaint.setColor(mCircleColor); mCirclePaint.setStrokeWidth(mCircleStrokeWidth); mCirclePaint.setStyle(Paint.Style.STROKE); mCirclePaint.setStrokeJoin(Paint.Join.ROUND); mCirclePaint.setStrokeCap(mCircleStyle); mCircleFillPaint = new Paint(); mCircleFillPaint.setAntiAlias(true); mCircleFillPaint.setDither(true); mCircleFillPaint.setColor(mCircleFillColor); mCircleFillPaint.setStyle(Paint.Style.FILL); mCircleProgressPaint = new Paint(); mCircleProgressPaint.setAntiAlias(true); mCircleProgressPaint.setDither(true); mCircleProgressPaint.setColor(mCircleProgressColor); mCircleProgressPaint.setStrokeWidth(mCircleStrokeWidth); mCircleProgressPaint.setStyle(Paint.Style.STROKE); mCircleProgressPaint.setStrokeJoin(Paint.Join.ROUND); mCircleProgressPaint.setStrokeCap(mCircleStyle); if (!mDisableProgressGlow) { mCircleProgressGlowPaint = new Paint(); mCircleProgressGlowPaint.set(mCircleProgressPaint); mCircleProgressGlowPaint.setMaskFilter(new BlurMaskFilter((PROGRESS_GLOW_RADIUS_DP * DPTOPX_SCALE), BlurMaskFilter.Blur.NORMAL)); } mPointerPaint = new Paint(); mPointerPaint.setAntiAlias(true); mPointerPaint.setDither(true); mPointerPaint.setColor(mPointerColor); mPointerPaint.setStrokeWidth(mPointerStrokeWidth); mPointerPaint.setStyle(Paint.Style.STROKE); mPointerPaint.setStrokeJoin(Paint.Join.ROUND); mPointerPaint.setStrokeCap(mCircleStyle); mPointerHaloPaint = new Paint(); mPointerHaloPaint.set(mPointerPaint); mPointerHaloPaint.setColor(mPointerHaloColor); mPointerHaloPaint.setAlpha(mPointerAlpha); mPointerHaloPaint.setStrokeWidth(mPointerStrokeWidth + mPointerHaloWidth * 2f); mPointerHaloBorderPaint = new Paint(); mPointerHaloBorderPaint.set(mPointerPaint); mPointerHaloBorderPaint.setStrokeWidth(mPointerHaloBorderWidth); mPointerHaloBorderPaint.setStyle(Paint.Style.STROKE); } /** * Calculates the total degrees between mStartAngle and mEndAngle, and sets mTotalCircleDegrees * to this value. */ private void calculateTotalDegrees() { mTotalCircleDegrees = (360f - (mStartAngle - mEndAngle)) % 360f; // Length of the entire circle/arc if (mTotalCircleDegrees <= 0f) { mTotalCircleDegrees = 360f; } } /** * Calculate the degrees that the progress represents. Also called the sweep angle. * Sets mProgressDegrees to that value. */ private void calculateProgressDegrees() { mProgressDegrees = mIsInNegativeHalf ? mStartAngle - mPointerPosition : mPointerPosition - mStartAngle; // Verified mProgressDegrees = (mProgressDegrees < 0 ? 360f + mProgressDegrees : mProgressDegrees); // Verified } /** * Calculate the pointer position (and the end of the progress arc) in degrees. * Sets mPointerPosition to that value. */ private void calculatePointerPosition() { float progressPercent = mProgress / mMax; float progressDegree = (progressPercent * mTotalCircleDegrees); mPointerPosition = mStartAngle + (mIsInNegativeHalf ? -progressDegree : progressDegree); mPointerPosition = (mPointerPosition < 0 ? 360f + mPointerPosition : mPointerPosition) % 360f; } private void calculatePointerXYPosition() { PathMeasure pm = new PathMeasure(mCircleProgressPath, false); boolean returnValue = pm.getPosTan(pm.getLength(), mPointerPositionXY, null); if (!returnValue) { pm = new PathMeasure(mCirclePath, false); pm.getPosTan(0, mPointerPositionXY, null); } } /** * Initialize the {@code Path} objects. */ private void initPaths() { mCirclePath = new Path(); mCircleProgressPath = new Path(); mCirclePonterPath = new Path(); } /** * Reset the {@code Path} objects with the appropriate values. */ private void resetPaths() { if (mIsInNegativeHalf) { mCirclePath.reset(); mCirclePath.addArc(mCircleRectF, mStartAngle - mTotalCircleDegrees, mTotalCircleDegrees); // beside progress path it self, we also draw a extend arc to math the pointer arc. float extendStart = mStartAngle - mProgressDegrees - mPointerAngle / 2.0f; float extendDegrees = mProgressDegrees + mPointerAngle; if (extendDegrees >= 360f) { extendDegrees = 360f - SMALL_DEGREE_BIAS; } mCircleProgressPath.reset(); mCircleProgressPath.addArc(mCircleRectF, extendStart, extendDegrees); float pointerStart = mPointerPosition - mPointerAngle / 2.0f; mCirclePonterPath.reset(); mCirclePonterPath.addArc(mCircleRectF, pointerStart, mPointerAngle); } else { mCirclePath.reset(); mCirclePath.addArc(mCircleRectF, mStartAngle, mTotalCircleDegrees); // beside progress path it self, we also draw a extend arc to math the pointer arc. float extendStart = mStartAngle - mPointerAngle / 2.0f; float extendDegrees = mProgressDegrees + mPointerAngle; if (extendDegrees >= 360f) { extendDegrees = 360f - SMALL_DEGREE_BIAS; } mCircleProgressPath.reset(); mCircleProgressPath.addArc(mCircleRectF, extendStart, extendDegrees); float pointerStart = mPointerPosition - mPointerAngle / 2.0f; mCirclePonterPath.reset(); mCirclePonterPath.addArc(mCircleRectF, pointerStart, mPointerAngle); } } /** * Initialize the {@code RectF} objects with the appropriate values. */ private void resetRects() { mCircleRectF.set(-mCircleWidth, -mCircleHeight, mCircleWidth, mCircleHeight); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); canvas.translate(getWidth() / 2f, getHeight() / 2f); canvas.drawPath(mCirclePath, mCircleFillPaint); canvas.drawPath(mCirclePath, mCirclePaint); boolean ableToGoNegative = mNegativeEnabled && Math.abs(mTotalCircleDegrees - 360f) < SMALL_DEGREE_BIAS * 2; // Hide progress bar when progress is 0 // Also make sure we still draw progress when has pointer or able to go negative boolean shouldHideProgress = mHideProgressWhenEmpty && mProgressDegrees == 0f && mDisablePointer && !ableToGoNegative; if (!shouldHideProgress) { if (!mDisableProgressGlow) { canvas.drawPath(mCircleProgressPath, mCircleProgressGlowPaint); } canvas.drawPath(mCircleProgressPath, mCircleProgressPaint); } if (!mDisablePointer) { if (mUserIsMovingPointer) { canvas.drawPath(mCirclePonterPath, mPointerHaloPaint); } canvas.drawPath(mCirclePonterPath, mPointerPaint); // TODO, find a good way to draw halo border. // if (mUserIsMovingPointer) { // canvas.drawCircle(mPointerPositionXY[0], mPointerPositionXY[1], // (mPointerStrokeWidth /2f) + mPointerHaloWidth + (mPointerHaloBorderWidth / 2f), // mPointerHaloBorderPaint); // } } } /** * Get the progress of the CircularSeekBar. * @return The progress of the CircularSeekBar. */ public float getProgress() { float progress = mMax * mProgressDegrees / mTotalCircleDegrees; return mIsInNegativeHalf ? -progress : progress; } /** * Set the progress of the CircularSeekBar. * If the progress is the same, then any listener will not receive a onProgressChanged event. * @param progress The progress to set the CircularSeekBar to. */ public void setProgress(float progress) { if (mProgress != progress) { if (mNegativeEnabled) { if (progress < 0) { mProgress = -progress; mIsInNegativeHalf = true; } else { mProgress = progress; mIsInNegativeHalf = false; } } else { mProgress = progress; } if (mOnCircularSeekBarChangeListener != null) { mOnCircularSeekBarChangeListener.onProgressChanged(this, progress, false); } recalculateAll(); invalidate(); } } private void setProgressBasedOnAngle(float angle) { mPointerPosition = angle; calculateProgressDegrees(); mProgress = mMax * mProgressDegrees / mTotalCircleDegrees; } private void recalculateAll() { calculateTotalDegrees(); calculatePointerPosition(); calculateProgressDegrees(); resetRects(); resetPaths(); calculatePointerXYPosition(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int height = getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec); int width = getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec); if (height == 0) height = width; if (width == 0) width = height; if (mMaintainEqualCircle) { int min = Math.min(width, height); setMeasuredDimension(min, min); } else { setMeasuredDimension(width, height); } boolean isHardwareAccelerated = Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB && isHardwareAccelerated() && getLayerType() != View.LAYER_TYPE_SOFTWARE; boolean hasGlowEffect = !mDisableProgressGlow && !isHardwareAccelerated; // Set the circle width and height based on the view for the moment float padding = Math.max(mCircleStrokeWidth / 2f, mPointerStrokeWidth / 2 + mPointerHaloWidth + mPointerHaloBorderWidth) + (hasGlowEffect ? PROGRESS_GLOW_RADIUS_DP * DPTOPX_SCALE : 0f); mCircleHeight = height / 2f - padding; mCircleWidth = width / 2f - padding; // If it is not set to use custom if (mCustomRadii) { // Check to make sure the custom radii are not out of the view. If they are, just use the view values if ((mCircleYRadius - padding) < mCircleHeight) { mCircleHeight = mCircleYRadius - padding; } if ((mCircleXRadius - padding) < mCircleWidth) { mCircleWidth = mCircleXRadius - padding; } } if (mMaintainEqualCircle) { // Applies regardless of how the values were determined float min = Math.min(mCircleHeight, mCircleWidth); mCircleHeight = min; mCircleWidth = min; } recalculateAll(); } public boolean isLockEnabled() { return mLockEnabled; } public void setLockEnabled(boolean lockEnabled) { this.mLockEnabled = lockEnabled; } public boolean isNegativeEnabled() { return mNegativeEnabled; } public void setNegativeEnabled(boolean negativeEnabled) { this.mNegativeEnabled = negativeEnabled; } @Override public boolean onTouchEvent(MotionEvent event) { if (mDisablePointer || !isEnabled()) return false; // Convert coordinates to our internal coordinate system float x = event.getX() - getWidth() / 2; float y = event.getY() - getHeight() / 2; // Get the distance from the center of the circle in terms of x and y float distanceX = mCircleRectF.centerX() - x; float distanceY = mCircleRectF.centerY() - y; // Get the distance from the center of the circle in terms of a radius float touchEventRadius = (float) Math.sqrt((Math.pow(distanceX, 2) + Math.pow(distanceY, 2))); float minimumTouchTarget = MIN_TOUCH_TARGET_DP * DPTOPX_SCALE; // Convert minimum touch target into px float additionalRadius; // Either uses the minimumTouchTarget size or larger if the ring/pointer is larger if (mCircleStrokeWidth < minimumTouchTarget) { // If the width is less than the minimumTouchTarget, use the minimumTouchTarget additionalRadius = minimumTouchTarget / 2; } else { additionalRadius = mCircleStrokeWidth / 2; // Otherwise use the width } float outerRadius = Math.max(mCircleHeight, mCircleWidth) + additionalRadius; // Max outer radius of the circle, including the minimumTouchTarget or wheel width float innerRadius = Math.min(mCircleHeight, mCircleWidth) - additionalRadius; // Min inner radius of the circle, including the minimumTouchTarget or wheel width if (mPointerStrokeWidth < (minimumTouchTarget / 2)) { // If the pointer radius is less than the minimumTouchTarget, use the minimumTouchTarget additionalRadius = minimumTouchTarget / 2; } else { additionalRadius = mPointerStrokeWidth; // Otherwise use the radius } float touchAngle; touchAngle = (float) ((Math.atan2(y, x) / Math.PI * 180) % 360); // Verified touchAngle = (touchAngle < 0 ? 360 + touchAngle : touchAngle); // Verified /* Represents the clockwise distance from {@code mStartAngle} to the touch angle. Used when touching the CircularSeekBar. */ float cwDistanceFromStart; /* Represents the counter-clockwise distance from {@code mStartAngle} to the touch angle. Used when touching the CircularSeekBar. */ float ccwDistanceFromStart; /* Represents the clockwise distance from {@code mEndAngle} to the touch angle. Used when touching the CircularSeekBar. */ float cwDistanceFromEnd; /* Represents the counter-clockwise distance from {@code mEndAngle} to the touch angle. Used when touching the CircularSeekBar. Currently unused, but kept just in case. */ float ccwDistanceFromEnd; /* Represents the clockwise distance from {@code mPointerPosition} to the touch angle. Used when touching the CircularSeekBar. */ float cwDistanceFromPointer; /* Represents the counter-clockwise distance from {@code mPointerPosition} to the touch angle. Used when touching the CircularSeekBar. */ float ccwDistanceFromPointer; cwDistanceFromStart = touchAngle - mStartAngle; // Verified cwDistanceFromStart = (cwDistanceFromStart < 0 ? 360f + cwDistanceFromStart : cwDistanceFromStart); // Verified ccwDistanceFromStart = 360f - cwDistanceFromStart; // Verified cwDistanceFromEnd = touchAngle - mEndAngle; // Verified cwDistanceFromEnd = (cwDistanceFromEnd < 0 ? 360f + cwDistanceFromEnd : cwDistanceFromEnd); // Verified ccwDistanceFromEnd = 360f - cwDistanceFromEnd; // Verified switch (event.getAction()) { case MotionEvent.ACTION_DOWN: // These are only used for ACTION_DOWN for handling if the pointer was the part that was touched float pointerRadiusDegrees = (float) ((mPointerStrokeWidth * 180) / (Math.PI * Math.max(mCircleHeight, mCircleWidth))); float pointerDegrees = Math.max( pointerRadiusDegrees, (mPointerAngle / 2f) ); cwDistanceFromPointer = touchAngle - mPointerPosition; cwDistanceFromPointer = (cwDistanceFromPointer < 0 ? 360f + cwDistanceFromPointer : cwDistanceFromPointer); ccwDistanceFromPointer = 360f - cwDistanceFromPointer; // This is for if the first touch is on the actual pointer. if ( ((touchEventRadius >= innerRadius) && (touchEventRadius <= outerRadius)) && ((cwDistanceFromPointer <= pointerDegrees) || (ccwDistanceFromPointer <= pointerDegrees)) ) { setProgressBasedOnAngle(mPointerPosition); mPointerHaloPaint.setAlpha(mPointerAlphaOnTouch); mPointerHaloPaint.setColor(mPointerHaloColorOnTouch); recalculateAll(); invalidate(); if (mOnCircularSeekBarChangeListener != null) { mOnCircularSeekBarChangeListener.onStartTrackingTouch(this); } mUserIsMovingPointer = true; mLockAtEnd = false; mLockAtStart = false; } else if (cwDistanceFromStart > mTotalCircleDegrees) { // If the user is touching outside of the start AND end mUserIsMovingPointer = false; return false; } else if ((touchEventRadius >= innerRadius) && (touchEventRadius <= outerRadius)) { // If the user is touching near the circle setProgressBasedOnAngle(touchAngle); mPointerHaloPaint.setAlpha(mPointerAlphaOnTouch); mPointerHaloPaint.setColor(mPointerHaloColorOnTouch); recalculateAll(); invalidate(); if (mOnCircularSeekBarChangeListener != null) { mOnCircularSeekBarChangeListener.onStartTrackingTouch(this); mOnCircularSeekBarChangeListener.onProgressChanged(this, getProgress(), true); } mUserIsMovingPointer = true; mLockAtEnd = false; mLockAtStart = false; } else { // If the user is not touching near the circle mUserIsMovingPointer = false; return false; } break; case MotionEvent.ACTION_MOVE: if (mUserIsMovingPointer) { float smallInCircle = mTotalCircleDegrees / 3f; float cwPointerFromStart = mPointerPosition - mStartAngle; cwPointerFromStart = cwPointerFromStart < 0 ? cwPointerFromStart + 360f : cwPointerFromStart; boolean touchOverStart = ccwDistanceFromStart < smallInCircle; boolean touchOverEnd = cwDistanceFromEnd < smallInCircle; boolean pointerNearStart = cwPointerFromStart < smallInCircle; boolean pointerNearEnd = cwPointerFromStart > (mTotalCircleDegrees - smallInCircle); boolean progressNearZero = mProgress < mMax / 3f; boolean progressNearMax = mProgress > mMax / 3f * 2f; if (progressNearMax) { // logic for end lock. if (pointerNearStart) { // negative end mLockAtEnd = touchOverStart; } else if (pointerNearEnd) { // positive end mLockAtEnd = touchOverEnd; } } else if (progressNearZero && mNegativeEnabled) { // logic for negative flip if (touchOverEnd) mIsInNegativeHalf = false; else if (touchOverStart) { mIsInNegativeHalf = true; } } else if (progressNearZero) { // logic for start lock if (pointerNearStart) { mLockAtStart = touchOverStart; } } if (mLockAtStart && mLockEnabled) { // TODO: Add a check if mProgress is already 0, in which case don't call the listener mProgress = 0; recalculateAll(); invalidate(); if (mOnCircularSeekBarChangeListener != null) { mOnCircularSeekBarChangeListener.onProgressChanged(this, getProgress(), true); } } else if (mLockAtEnd && mLockEnabled) { mProgress = mMax; recalculateAll(); invalidate(); if (mOnCircularSeekBarChangeListener != null) { mOnCircularSeekBarChangeListener.onProgressChanged(this, getProgress(), true); } } else if ((mMoveOutsideCircle) || (touchEventRadius <= outerRadius)) { if (!(cwDistanceFromStart > mTotalCircleDegrees)) { setProgressBasedOnAngle(touchAngle); } recalculateAll(); invalidate(); if (mOnCircularSeekBarChangeListener != null) { mOnCircularSeekBarChangeListener.onProgressChanged(this, getProgress(), true); } } else { break; } } else { return false; } break; case MotionEvent.ACTION_UP: mPointerHaloPaint.setAlpha(mPointerAlpha); mPointerHaloPaint.setColor(mPointerHaloColor); if (mUserIsMovingPointer) { mUserIsMovingPointer = false; invalidate(); if (mOnCircularSeekBarChangeListener != null) { mOnCircularSeekBarChangeListener.onStopTrackingTouch(this); } } else { return false; } break; case MotionEvent.ACTION_CANCEL: // Used when the parent view intercepts touches for things like scrolling mPointerHaloPaint.setAlpha(mPointerAlpha); mPointerHaloPaint.setColor(mPointerHaloColor); mUserIsMovingPointer = false; invalidate(); break; } if (event.getAction() == MotionEvent.ACTION_MOVE && getParent() != null) { getParent().requestDisallowInterceptTouchEvent(true); } return true; } private void init(AttributeSet attrs, int defStyle) { final TypedArray attrArray = getContext().obtainStyledAttributes(attrs, R.styleable.cs_CircularSeekBar, defStyle, 0); initAttributes(attrArray); attrArray.recycle(); initPaints(); initPaths(); } public CircularSeekBar(Context context) { super(context); init(null, 0); } public CircularSeekBar(Context context, AttributeSet attrs) { super(context, attrs); init(attrs, 0); } public CircularSeekBar(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(attrs, defStyle); } @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); Bundle state = new Bundle(); state.putParcelable("PARENT", superState); state.putFloat("MAX", mMax); state.putFloat("PROGRESS", mProgress); state.putInt("mCircleColor", mCircleColor); state.putInt("mCircleProgressColor", mCircleProgressColor); state.putInt("mPointerColor", mPointerColor); state.putInt("mPointerHaloColor", mPointerHaloColor); state.putInt("mPointerHaloColorOnTouch", mPointerHaloColorOnTouch); state.putInt("mPointerAlpha", mPointerAlpha); state.putInt("mPointerAlphaOnTouch", mPointerAlphaOnTouch); state.putFloat("mPointerAngle", mPointerAngle); state.putBoolean("mDisablePointer", mDisablePointer); state.putBoolean("mLockEnabled", mLockEnabled); state.putBoolean("mNegativeEnabled", mNegativeEnabled); state.putBoolean("mDisableProgressGlow", mDisableProgressGlow); state.putBoolean("mIsInNegativeHalf", mIsInNegativeHalf); state.putInt("mCircleStyle", mCircleStyle.ordinal()); state.putBoolean("mHideProgressWhenEmpty", mHideProgressWhenEmpty); return state; } @Override protected void onRestoreInstanceState(Parcelable state) { Bundle savedState = (Bundle) state; Parcelable superState = savedState.getParcelable("PARENT"); super.onRestoreInstanceState(superState); mMax = savedState.getFloat("MAX"); mProgress = savedState.getFloat("PROGRESS"); mCircleColor = savedState.getInt("mCircleColor"); mCircleProgressColor = savedState.getInt("mCircleProgressColor"); mPointerColor = savedState.getInt("mPointerColor"); mPointerHaloColor = savedState.getInt("mPointerHaloColor"); mPointerHaloColorOnTouch = savedState.getInt("mPointerHaloColorOnTouch"); mPointerAlpha = savedState.getInt("mPointerAlpha"); mPointerAlphaOnTouch = savedState.getInt("mPointerAlphaOnTouch"); mPointerAngle = savedState.getFloat("mPointerAngle"); mDisablePointer = savedState.getBoolean("mDisablePointer"); mLockEnabled = savedState.getBoolean("mLockEnabled"); mNegativeEnabled = savedState.getBoolean("mNegativeEnabled"); mDisableProgressGlow = savedState.getBoolean("mDisableProgressGlow"); mIsInNegativeHalf = savedState.getBoolean("mIsInNegativeHalf"); mCircleStyle = Paint.Cap.values()[savedState.getInt("mCircleStyle")]; mHideProgressWhenEmpty = savedState.getBoolean("mHideProgressWhenEmpty"); initPaints(); recalculateAll(); } public void setOnSeekBarChangeListener(OnCircularSeekBarChangeListener l) { mOnCircularSeekBarChangeListener = l; } /** * Listener for the CircularSeekBar. Implements the same methods as the normal OnSeekBarChangeListener. */ public interface OnCircularSeekBarChangeListener { public abstract void onProgressChanged(CircularSeekBar circularSeekBar, float progress, boolean fromUser); public abstract void onStopTrackingTouch(CircularSeekBar seekBar); public abstract void onStartTrackingTouch(CircularSeekBar seekBar); } public Paint.Cap getCircleStyle() { return mCircleStyle; } public void setCircleStyle(Paint.Cap style) { mCircleStyle = style; initPaints(); recalculateAll(); invalidate(); } /** * Sets the circle stroke width. * @param width the width of the circle */ public void setCircleStrokeWidth(float width) { mCircleStrokeWidth = width; initPaints(); recalculateAll(); invalidate(); } public float getCircleStrokeWidth() { return mCircleStrokeWidth; } public float getEndAngle() { return mEndAngle; } public void setEndAngle(float angle) { mEndAngle = angle; if (mStartAngle % 360f == mEndAngle % 360f) { //mStartAngle = mStartAngle + 1f; mEndAngle = mEndAngle - SMALL_DEGREE_BIAS; } recalculateAll(); invalidate(); } public float getStartAngle() { return mStartAngle; } public void setStartAngle(float angle) { mStartAngle = angle; if (mStartAngle % 360f == mEndAngle % 360f) { //mStartAngle = mStartAngle + 1f; mEndAngle = mEndAngle - SMALL_DEGREE_BIAS; } recalculateAll(); invalidate(); } /** * Sets the circle color. * @param color the color of the circle */ public void setCircleColor(int color) { mCircleColor = color; mCirclePaint.setColor(mCircleColor); invalidate(); } /** * Gets the circle color. * @return An integer color value for the circle */ public int getCircleColor() { return mCircleColor; } /** * Sets the pointer pointer stroke width. * @param width the width of the pointer */ public void setPointerStrokeWidth(float width) { mPointerStrokeWidth = width; initPaints(); recalculateAll(); invalidate(); } public float getPointerStrokeWidth() { return mPointerStrokeWidth; } /** * Sets the circle progress color. * @param color the color of the circle progress */ public void setCircleProgressColor(int color) { mCircleProgressColor = color; mCircleProgressPaint.setColor(mCircleProgressColor); invalidate(); } /** * Gets the circle progress color. * @return An integer color value for the circle progress */ public int getCircleProgressColor() { return mCircleProgressColor; } /** * Sets the pointer color. * @param color the color of the pointer */ public void setPointerColor(int color) { mPointerColor = color; mPointerPaint.setColor(mPointerColor); invalidate(); } /** * Gets the pointer color. * @return An integer color value for the pointer */ public int getPointerColor() { return mPointerColor; } /** * Sets the pointer halo color. * @param color the color of the pointer halo */ public void setPointerHaloColor(int color) { mPointerHaloColor = color; mPointerHaloPaint.setColor(mPointerHaloColor); invalidate(); } /** * Gets the pointer halo color. * @return An integer color value for the pointer halo */ public int getPointerHaloColor() { return mPointerHaloColor; } /** * Sets the pointer alpha. * @param alpha the alpha of the pointer */ public void setPointerAlpha(int alpha) { if (alpha >=0 && alpha <= 255) { mPointerAlpha = alpha; mPointerHaloPaint.setAlpha(mPointerAlpha); invalidate(); } } /** * Gets the pointer alpha value. * @return An integer alpha value for the pointer (0..255) */ public int getPointerAlpha() { return mPointerAlpha; } /** * Sets the pointer alpha when touched. * @param alpha the alpha of the pointer (0..255) when touched */ public void setPointerAlphaOnTouch(int alpha) { if (alpha >=0 && alpha <= 255) { mPointerAlphaOnTouch = alpha; } } /** * Gets the pointer alpha value when touched. * @return An integer alpha value for the pointer (0..255) when touched */ public int getPointerAlphaOnTouch() { return mPointerAlphaOnTouch; } /** * Sets the pointer angle. * @param angle the angle of the pointer */ public void setPointerAngle(float angle) { // Modulo 360 right now to avoid constant conversion angle = ((360f + (angle % 360f)) % 360f); if (angle == 0f) { angle = SMALL_DEGREE_BIAS; } if (angle != mPointerAngle) { mPointerAngle = angle; recalculateAll(); invalidate(); } } /** * Gets the pointer angle. * @return Angle for the pointer (0..360) */ public float getPointerAngle() { return mPointerAngle; } /** * Sets the circle fill color. * @param color the color of the circle fill */ public void setCircleFillColor(int color) { mCircleFillColor = color; mCircleFillPaint.setColor(mCircleFillColor); invalidate(); } /** * Gets the circle fill color. * @return An integer color value for the circle fill */ public int getCircleFillColor() { return mCircleFillColor; } /** * Set the max of the CircularSeekBar. * If the new max is less than the current progress, then the progress will be set to zero. * If the progress is changed as a result, then any listener will receive a onProgressChanged event. * @param max The new max for the CircularSeekBar. */ public void setMax(float max) { if (max > 0) { // Check to make sure it's greater than zero if (max <= mProgress) { mProgress = 0; // If the new max is less than current progress, set progress to zero if (mOnCircularSeekBarChangeListener != null) { mOnCircularSeekBarChangeListener.onProgressChanged(this, mIsInNegativeHalf ? -mProgress : mProgress, false); } } mMax = max; recalculateAll(); invalidate(); } } /** * Get the current max of the CircularSeekBar. * @return Synchronized integer value of the max. */ public synchronized float getMax() { return mMax; } public RectF getPathCircle() { return mCircleRectF; } }