Last active
December 1, 2017 22:23
-
-
Save arvkmr/77bfcfcd067a630697ed4b7353b47e3e to your computer and use it in GitHub Desktop.
Circular Progress view with visualizer clip art
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="utf-8"?> | |
<resources> | |
<declare-styleable name="CircularProgressView"> | |
<attr name="cpv_progress" format="float" /> | |
<attr name="cpv_maxProgress" format="float" /> | |
<attr name="cpv_animDuration" format="integer" /> | |
<attr name="cpv_animSwoopDuration" format="integer" /> | |
<attr name="cpv_animSyncDuration" format="integer" /> | |
<attr name="cpv_color" format="color"/> | |
<attr name="cpv_thickness" format="dimension"/> | |
<attr name="cpv_isPlaying" format="boolean"/> | |
<attr name="cpv_indeterminate" format="boolean" /> | |
<attr name="cpv_animAutostart" format="boolean" /> | |
<attr name="cpv_animSteps" format="integer" /> | |
<attr name="cpv_startAngle" format="float" /> | |
</declare-styleable> | |
</resources> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Custom Visualizer progress View. | |
* Set progress via {@link CircularProgressView#setProgress(float)} | |
* Set state via {@link CircularProgressView#isPlaying(boolean)} | |
*/ | |
public class CircularProgressView extends View { | |
private static final float INDETERMINANT_MIN_SWEEP = 15f; | |
private Paint progressPaint; | |
private Paint blackPaint; | |
private Paint whitePaint; | |
private Paint transparentPaint; | |
int eqCols = 8; | |
int eqRows = 8; | |
private int size = 0; | |
private boolean isPlaying = false; | |
private Bitmap playBitmap; | |
private RectF bounds; | |
private RectF whiteCircleBounds; | |
private RectF eqBounds; | |
private RectF bitMapRect; | |
private List<List<RectF>> rectList = new ArrayList<>(); | |
private List<Integer> AnimStateValue = new ArrayList<>(); | |
private Random rn = new Random(); | |
private Timer timer = null; | |
private boolean isIndeterminate, autostartAnimation; | |
private float currentProgress, maxProgress, indeterminateSweep, indeterminateRotateOffset; | |
private int thickness, color, animDuration, animSwoopDuration, animSyncDuration, animSteps; | |
// Animation related stuff | |
private float startAngle; | |
private float actualProgress; | |
private ValueAnimator startAngleRotate; | |
private ValueAnimator progressAnimator; | |
private AnimatorSet indeterminateAnimator; | |
private float initialStartAngle; | |
private boolean isVisualizerDirty = true; | |
public CircularProgressView(Context context) { | |
super(context); | |
init(null, 0); | |
} | |
public CircularProgressView(Context context, AttributeSet attrs) { | |
super(context, attrs); | |
init(attrs, 0); | |
} | |
public CircularProgressView(Context context, AttributeSet attrs, int defStyle) { | |
super(context, attrs, defStyle); | |
init(attrs, defStyle); | |
} | |
protected void init(AttributeSet attrs, int defStyle) { | |
initAttributes(attrs, defStyle); | |
progressPaint = new Paint(Paint.ANTI_ALIAS_FLAG); | |
blackPaint = new Paint(Paint.ANTI_ALIAS_FLAG); | |
whitePaint = new Paint(Paint.ANTI_ALIAS_FLAG); | |
transparentPaint = new Paint(Paint.ANTI_ALIAS_FLAG); | |
updatePaint(); | |
bounds = new RectF(); | |
eqBounds = new RectF(); | |
whiteCircleBounds = new RectF(); | |
for(int i = 0; i < (eqCols); i++){ | |
List<RectF> list = new ArrayList<>(); | |
for (int z = 0; z< eqRows; z++) | |
{ | |
list.add(new RectF()); | |
} | |
rectList.add(list); | |
} | |
playBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.play_white); | |
} | |
private void initAttributes(AttributeSet attrs, int defStyle) | |
{ | |
final TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.CircularProgressView, defStyle, 0); | |
Resources resources = getResources(); | |
// Initialize attributes from styleable attributes | |
currentProgress = a.getFloat(R.styleable.CircularProgressView_cpv_progress, | |
resources.getInteger(R.integer.cpv_default_progress)); | |
maxProgress = a.getFloat(R.styleable.CircularProgressView_cpv_maxProgress, | |
resources.getInteger(R.integer.cpv_default_max_progress)); | |
thickness = a.getDimensionPixelSize(R.styleable.CircularProgressView_cpv_thickness, | |
resources.getDimensionPixelSize(R.dimen.cpv_default_thickness)); | |
isIndeterminate = a.getBoolean(R.styleable.CircularProgressView_cpv_indeterminate, | |
resources.getBoolean(R.bool.cpv_default_is_indeterminate)); | |
autostartAnimation = a.getBoolean(R.styleable.CircularProgressView_cpv_animAutostart, | |
resources.getBoolean(R.bool.cpv_default_anim_autostart)); | |
initialStartAngle = a.getFloat(R.styleable.CircularProgressView_cpv_startAngle, | |
resources.getInteger(R.integer.cpv_default_start_angle)); | |
startAngle = initialStartAngle; | |
int accentColor = getContext().getResources().getIdentifier("colorAccent", "attr", getContext().getPackageName()); | |
// If color explicitly provided | |
if (a.hasValue(R.styleable.CircularProgressView_cpv_color)) { | |
color = a.getColor(R.styleable.CircularProgressView_cpv_color, resources.getColor(R.color.cpv_default_color)); | |
} | |
// If using support library v7 accentColor | |
else if(accentColor != 0) { | |
TypedValue t = new TypedValue(); | |
getContext().getTheme().resolveAttribute(accentColor, t, true); | |
color = t.data; | |
} | |
// If using native accentColor (SDK >21) | |
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { | |
TypedArray t = getContext().obtainStyledAttributes(new int[] { android.R.attr.colorAccent }); | |
color = t.getColor(0, resources.getColor(R.color.cpv_default_color)); | |
t.recycle(); | |
} | |
else { | |
//Use default color | |
color = resources.getColor(R.color.cpv_default_color); | |
} | |
animDuration = a.getInteger(R.styleable.CircularProgressView_cpv_animDuration, | |
resources.getInteger(R.integer.cpv_default_anim_duration)); | |
animSwoopDuration = a.getInteger(R.styleable.CircularProgressView_cpv_animSwoopDuration, | |
resources.getInteger(R.integer.cpv_default_anim_swoop_duration)); | |
animSyncDuration = a.getInteger(R.styleable.CircularProgressView_cpv_animSyncDuration, | |
resources.getInteger(R.integer.cpv_default_anim_sync_duration)); | |
animSteps = a.getInteger(R.styleable.CircularProgressView_cpv_animSteps, | |
resources.getInteger(R.integer.cpv_default_anim_steps)); | |
a.recycle(); | |
} | |
@Override | |
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { | |
super.onMeasure(widthMeasureSpec, heightMeasureSpec); | |
int xPad = getPaddingLeft() + getPaddingRight(); | |
int yPad = getPaddingTop() + getPaddingBottom(); | |
int width = getMeasuredWidth() - xPad; | |
int height = getMeasuredHeight() - yPad; | |
size = (width < height) ? width : height; | |
setMeasuredDimension(size + xPad, size + yPad); | |
} | |
@Override | |
protected void onSizeChanged(int w, int h, int oldw, int oldh) { | |
super.onSizeChanged(w, h, oldw, oldh); | |
size = (w < h) ? w : h; | |
updateBounds(); | |
} | |
private void updateBounds() | |
{ | |
int spacing = 5; | |
int paddingLeft = getPaddingLeft(); | |
int paddingTop = getPaddingTop(); | |
bounds.set(paddingLeft + thickness, paddingTop + thickness, size - paddingLeft - thickness, size - paddingTop - thickness); | |
// This defines the inset of visualizer, the actual value should be root 2 or 1.41 but 1.35 looks better here. | |
float eqSize = (float)(size / 1.35 ); | |
whiteCircleBounds.set(bounds); | |
whiteCircleBounds.inset(thickness/2, thickness/2); | |
eqBounds.set(bounds); | |
eqBounds.inset(size - eqSize/2, size - eqSize/2); | |
float a = (eqBounds.left - eqBounds.right )/ eqCols; | |
float b = (eqBounds.top - eqBounds.bottom)/ eqRows; | |
for (int i = 0; i < eqCols; i++) { | |
List<RectF> list = rectList.get(i); | |
for (int z = 0; z < eqRows; z++) { | |
RectF r = list.get(z); | |
r.left = eqBounds.right + i*a + spacing; | |
r.right = eqBounds.right + a +i*a; | |
r.top = eqBounds.top - b - b*z + spacing ; | |
r.bottom = eqBounds.top - b*z ; | |
} | |
} | |
bitMapRect = new RectF(eqBounds); | |
int w = playBitmap.getWidth(); | |
float w1 = eqBounds.left - eqBounds.right; | |
float w2 = w - w1; | |
bitMapRect.inset(w2/2,w2/2); | |
} | |
private void updatePaint() | |
{ | |
progressPaint.setColor(color); | |
progressPaint.setStyle(Paint.Style.STROKE); | |
progressPaint.setStrokeWidth(thickness); | |
progressPaint.setStrokeCap(Paint.Cap.BUTT); | |
blackPaint.setColor(Color.BLACK); | |
blackPaint.setStyle(Paint.Style.FILL); | |
whitePaint.setStyle(Paint.Style.FILL); | |
whitePaint.setColor(Color.WHITE); | |
transparentPaint.setStyle(Paint.Style.STROKE); | |
transparentPaint.setStrokeWidth(thickness); | |
transparentPaint.setColor(Color.parseColor("#60000000")); | |
} | |
@Override | |
protected void onDraw(Canvas canvas) { | |
super.onDraw(canvas); | |
// Draw the arc | |
float sweepAngle = (isInEditMode()) ? currentProgress/maxProgress*360 : actualProgress/maxProgress*360; | |
if(!isIndeterminate) { | |
canvas.drawOval(bounds,transparentPaint); | |
canvas.drawOval(whiteCircleBounds, whitePaint); | |
canvas.drawArc(bounds, startAngle, sweepAngle, false, progressPaint); | |
} | |
else { | |
canvas.drawOval(bounds,transparentPaint); | |
canvas.drawOval(whiteCircleBounds, whitePaint); | |
canvas.drawArc(bounds, startAngle + indeterminateRotateOffset, indeterminateSweep, false, progressPaint); | |
} | |
// Draw Equalizer | |
if(isVisualizerDirty){ | |
isVisualizerDirty = false; | |
AnimStateValue.clear(); | |
int lastInt = 1; | |
for (int i = 0;i < eqCols; i++){ | |
lastInt = rn.nextInt(eqCols - lastInt +1) + lastInt; | |
if(lastInt > eqCols) | |
lastInt = eqCols; | |
if (lastInt <1) | |
lastInt =1; | |
AnimStateValue.add(rn.nextInt(eqCols) + 1); | |
} | |
} | |
if(isPlaying) { | |
for (int i = 0; i < AnimStateValue.size(); i++) { | |
List<RectF> list = rectList.get(i); | |
for (int j = 0; j < AnimStateValue.get(i); j++) { | |
RectF rectF = list.get(j); | |
canvas.drawRect(rectF, blackPaint); | |
} | |
} | |
} | |
else { | |
canvas.drawBitmap(playBitmap,bitMapRect.right , bitMapRect.bottom , whitePaint); | |
} | |
} | |
/** | |
* Returns the mode of this view (determinate or indeterminate). | |
* @return true if this view is in indeterminate mode. | |
*/ | |
public boolean isIndeterminate() { | |
return isIndeterminate; | |
} | |
/** | |
* Sets whether this CircularProgressView is indeterminate or not. | |
* It will reset the animation if the mode has changed. | |
* @param isIndeterminate True if indeterminate. | |
*/ | |
public void setIndeterminate(boolean isIndeterminate) { | |
boolean old = this.isIndeterminate; | |
boolean reset = this.isIndeterminate != isIndeterminate; | |
this.isIndeterminate = isIndeterminate; | |
if (reset) | |
resetAnimation(); | |
} | |
/** | |
* Get the thickness of the progress bar arc. | |
* @return the thickness of the progress bar arc | |
*/ | |
public int getThickness() { | |
return thickness; | |
} | |
/** | |
* Sets the thickness of the progress bar arc. | |
* @param thickness the thickness of the progress bar arc | |
*/ | |
public void setThickness(int thickness) { | |
this.thickness = thickness; | |
updatePaint(); | |
updateBounds(); | |
invalidate(); | |
} | |
/** | |
* | |
* @return the color of the progress bar | |
*/ | |
public int getColor() { | |
return color; | |
} | |
/** | |
* Sets the color of the progress bar. | |
* @param color the color of the progress bar | |
*/ | |
public void setColor(int color) { | |
this.color = color; | |
updatePaint(); | |
invalidate(); | |
} | |
/** | |
* Gets the progress value considered to be 100% of the progress bar. | |
* @return the maximum progress | |
*/ | |
public float getMaxProgress() { | |
return maxProgress; | |
} | |
/** | |
* Sets the progress value considered to be 100% of the progress bar. | |
* @param maxProgress the maximum progress | |
*/ | |
public void setMaxProgress(float maxProgress) { | |
if(this.maxProgress != maxProgress) { | |
this.maxProgress = maxProgress; | |
invalidate(); | |
} | |
} | |
public void isPlaying(boolean isMusicPlaying){ | |
if(isMusicPlaying != isPlaying) | |
{ | |
isPlaying = isMusicPlaying; | |
resetAnimation(); | |
} | |
} | |
/** | |
* @return current progress | |
*/ | |
public float getProgress() { | |
return currentProgress; | |
} | |
/** | |
* Sets the progress of the progress bar. | |
* | |
* @param currentProgress the new progress. | |
*/ | |
public void setProgress(final float currentProgress) { | |
this.currentProgress = currentProgress; | |
// Reset the determinate animation to approach the new currentProgress | |
if (!isIndeterminate ) { | |
if (progressAnimator != null && progressAnimator.isRunning()) | |
progressAnimator.cancel(); | |
progressAnimator = ValueAnimator.ofFloat(actualProgress, currentProgress); | |
progressAnimator.setDuration(animSyncDuration); | |
progressAnimator.setInterpolator(new LinearInterpolator()); | |
progressAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { | |
@Override | |
public void onAnimationUpdate(ValueAnimator animation) { | |
actualProgress = (Float) animation.getAnimatedValue(); | |
invalidate(); | |
} | |
}); | |
progressAnimator.start(); | |
} | |
invalidate(); | |
} | |
/** | |
* Starts the progress bar animation. | |
* (This is an alias of resetAnimation() so it does the same thing.) | |
*/ | |
public void startAnimation() { | |
resetAnimation(); | |
} | |
/** | |
* Resets the animation. | |
*/ | |
public void resetAnimation() { | |
// Cancel all the old animators | |
if(timer !=null){ | |
timer.cancel(); | |
timer.purge(); | |
} | |
if(startAngleRotate != null && startAngleRotate.isRunning()) | |
startAngleRotate.cancel(); | |
if(progressAnimator != null && progressAnimator.isRunning()) | |
progressAnimator.cancel(); | |
if(indeterminateAnimator != null && indeterminateAnimator.isRunning()) | |
indeterminateAnimator.cancel(); | |
if(isPlaying){ | |
createVisualizerTimer(300); | |
} | |
// Determinate animation | |
if(!isIndeterminate) | |
{ | |
// The cool 360 swoop animation at the start of the animation | |
startAngle = initialStartAngle; | |
startAngleRotate = ValueAnimator.ofFloat(startAngle, startAngle + 360); | |
startAngleRotate.setDuration(animSwoopDuration); | |
startAngleRotate.setInterpolator(new DecelerateInterpolator(2)); | |
startAngleRotate.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { | |
@Override | |
public void onAnimationUpdate(ValueAnimator animation) { | |
startAngle = (Float) animation.getAnimatedValue(); | |
invalidate(); | |
} | |
}); | |
startAngleRotate.start(); | |
// The linear animation shown when progress is updated | |
actualProgress = 0f; | |
progressAnimator = ValueAnimator.ofFloat(actualProgress, currentProgress); | |
progressAnimator.setDuration(animSyncDuration); | |
progressAnimator.setInterpolator(new LinearInterpolator()); | |
progressAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { | |
@Override | |
public void onAnimationUpdate(ValueAnimator animation) { | |
actualProgress = (Float) animation.getAnimatedValue(); | |
invalidate(); | |
} | |
}); | |
progressAnimator.start(); | |
} | |
// Indeterminate animation | |
else | |
{ | |
indeterminateSweep = INDETERMINANT_MIN_SWEEP; | |
// Build the whole AnimatorSet | |
indeterminateAnimator = new AnimatorSet(); | |
AnimatorSet prevSet = null, nextSet; | |
for(int k=0;k<animSteps;k++) | |
{ | |
nextSet = createIndeterminateAnimator(k); | |
AnimatorSet.Builder builder = indeterminateAnimator.play(nextSet); | |
if(prevSet != null) | |
builder.after(prevSet); | |
prevSet = nextSet; | |
} | |
// Listen to end of animation so we can infinitely loop | |
indeterminateAnimator.addListener(new AnimatorListenerAdapter() { | |
boolean wasCancelled = false; | |
@Override | |
public void onAnimationCancel(Animator animation) { | |
wasCancelled = true; | |
} | |
@Override | |
public void onAnimationEnd(Animator animation) { | |
if(!wasCancelled) | |
resetAnimation(); | |
} | |
}); | |
indeterminateAnimator.start(); | |
} | |
} | |
/** | |
* Stops the animation | |
*/ | |
public void stopAnimation() { | |
if(startAngleRotate != null) { | |
startAngleRotate.cancel(); | |
startAngleRotate = null; | |
} | |
if(progressAnimator != null) { | |
progressAnimator.cancel(); | |
progressAnimator = null; | |
} | |
if(indeterminateAnimator != null) { | |
indeterminateAnimator.cancel(); | |
indeterminateAnimator = null; | |
} | |
if(timer !=null){ | |
timer.cancel(); | |
timer.purge(); | |
} | |
} | |
class UpdateBallTask extends TimerTask { | |
public void run() { | |
isVisualizerDirty = true; | |
invalidate(); | |
} | |
} | |
private void createVisualizerTimer(int delay){ | |
timer = new Timer(); | |
TimerTask updateVisual = new UpdateBallTask(); | |
timer.scheduleAtFixedRate(updateVisual, delay, delay); | |
} | |
// Creates the animators for one step of the animation | |
private AnimatorSet createIndeterminateAnimator(float step) | |
{ | |
final float maxSweep = 360f*(animSteps-1)/animSteps + INDETERMINANT_MIN_SWEEP; | |
final float start = -90f + step*(maxSweep-INDETERMINANT_MIN_SWEEP); | |
// Extending the front of the arc | |
ValueAnimator frontEndExtend = ValueAnimator.ofFloat(INDETERMINANT_MIN_SWEEP, maxSweep); | |
frontEndExtend.setDuration(animDuration/animSteps/2); | |
frontEndExtend.setInterpolator(new DecelerateInterpolator(1)); | |
frontEndExtend.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { | |
@Override | |
public void onAnimationUpdate(ValueAnimator animation) { | |
indeterminateSweep = (Float) animation.getAnimatedValue(); | |
invalidate(); | |
} | |
}); | |
// Overall rotation | |
ValueAnimator rotateAnimator1 = ValueAnimator.ofFloat(step*720f/animSteps, (step+.5f)*720f/animSteps); | |
rotateAnimator1.setDuration(animDuration/animSteps/2); | |
rotateAnimator1.setInterpolator(new LinearInterpolator()); | |
rotateAnimator1.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { | |
@Override | |
public void onAnimationUpdate(ValueAnimator animation) { | |
indeterminateRotateOffset = (Float) animation.getAnimatedValue(); | |
} | |
}); | |
// Followed by... | |
// Retracting the back end of the arc | |
ValueAnimator backEndRetract = ValueAnimator.ofFloat(start, start+maxSweep-INDETERMINANT_MIN_SWEEP); | |
backEndRetract.setDuration(animDuration/animSteps/2); | |
backEndRetract.setInterpolator(new DecelerateInterpolator(1)); | |
backEndRetract.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { | |
@Override | |
public void onAnimationUpdate(ValueAnimator animation) { | |
startAngle = (Float) animation.getAnimatedValue(); | |
indeterminateSweep = maxSweep - startAngle + start; | |
invalidate(); | |
} | |
}); | |
// More overall rotation | |
ValueAnimator rotateAnimator2 = ValueAnimator.ofFloat((step + .5f) * 720f / animSteps, (step + 1) * 720f / animSteps); | |
rotateAnimator2.setDuration(animDuration / animSteps / 2); | |
rotateAnimator2.setInterpolator(new LinearInterpolator()); | |
rotateAnimator2.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { | |
@Override | |
public void onAnimationUpdate(ValueAnimator animation) { | |
indeterminateRotateOffset = (Float) animation.getAnimatedValue(); | |
} | |
}); | |
AnimatorSet set = new AnimatorSet(); | |
set.play(frontEndExtend).with(rotateAnimator1); | |
set.play(backEndRetract).with(rotateAnimator2).after(rotateAnimator1); | |
return set; | |
} | |
@Override | |
protected void onAttachedToWindow() { | |
super.onAttachedToWindow(); | |
if(autostartAnimation) | |
startAnimation(); | |
} | |
@Override | |
protected void onDetachedFromWindow() { | |
super.onDetachedFromWindow(); | |
stopAnimation(); | |
} | |
@Override | |
public void setVisibility(int visibility) { | |
int currentVisibility = getVisibility(); | |
super.setVisibility(visibility); | |
if (visibility != currentVisibility) { | |
if (visibility == View.VISIBLE){ | |
resetAnimation(); | |
} else if (visibility == View.GONE || visibility == View.INVISIBLE) { | |
stopAnimation(); | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="utf-8"?> | |
<resources> | |
<dimen name="text_margin">16dp</dimen> | |
<color name="cpv_default_color">#2196F3</color> | |
<dimen name="cpv_default_thickness">10dp</dimen> | |
<integer name="cpv_default_progress">0</integer> | |
<integer name="cpv_default_max_progress">100</integer> | |
<bool name="cpv_default_is_indeterminate">false</bool> | |
<bool name="cpv_default_anim_autostart">false</bool> | |
<integer name="cpv_default_anim_duration">3000</integer> | |
<integer name="cpv_default_anim_swoop_duration">4000</integer> | |
<integer name="cpv_default_anim_sync_duration">500</integer> | |
<integer name="cpv_default_anim_steps">3</integer> | |
<integer name="cpv_default_start_angle">-90</integer> | |
</resources> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<com.package.CircularProgressView | |
android:id="@+id/circular_progress" | |
android:layout_width="80dp" | |
android:layout_height="80dp" | |
android:layout_centerHorizontal="true" | |
android:layout_centerVertical="true" | |
android:visibility="invisible" | |
android:layout_marginTop="68dp" | |
app:cpv_animAutostart="true" | |
app:cpv_color="@color/my_accent_material_light" | |
app:cpv_progress="0" | |
app:cpv_thickness="8dp" /> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment