package org.solovyev.android.views; import android.animation.ObjectAnimator; import android.annotation.TargetApi; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.os.Build; import android.util.AttributeSet; import android.view.animation.DecelerateInterpolator; import android.widget.SeekBar; import org.solovyev.android.Check; import org.solovyev.android.calculator.R; /** * SeekBar for discrete values with a label displayed underneath the active tick */ public class DiscreteSeekBar extends SeekBar { // Duration of how quick the SeekBar thumb should snap to its destination value private static final int THUMB_SNAP_DURATION_TIME = 100; private final Paint mPaint = new Paint(); private ObjectAnimator mObjectAnimator; private OnChangeListener mOnChangeListener; private int mCurrentTick = 0; private CharSequence[] mTickLabels; private ColorStateList mLabelColor; public DiscreteSeekBar(Context context) { super(context); init(context, null, 0); } public DiscreteSeekBar(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs, 0); } public DiscreteSeekBar(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context, attrs, defStyle); } private void init(Context context, AttributeSet attrs, int defStyle) { final TypedArray a = context.obtainStyledAttributes( attrs, R.styleable.DiscreteSeekBar, defStyle, 0); mTickLabels = a.getTextArray(R.styleable.DiscreteSeekBar_values); final int labelsSize = a.getDimensionPixelSize(R.styleable.DiscreteSeekBar_labelsSize, 0); final ColorStateList labelsColor = a.getColorStateList(R.styleable.DiscreteSeekBar_labelsColor); a.recycle(); Check.isNotNull(mTickLabels); Check.isTrue(mTickLabels.length > 0); Check.isTrue(labelsSize > 0); mPaint.setStyle(Paint.Style.FILL); mPaint.setFlags(Paint.ANTI_ALIAS_FLAG); mPaint.setTextSize(labelsSize); if (labelsColor != null) { setLabelColor(labelsColor); } else { mPaint.setColor(Color.BLACK); } // Extend the bottom padding to include tick label height (including descent in order to not // clip glyphs that extends below the baseline). Paint.FontMetricsInt fi = mPaint.getFontMetricsInt(); setPadding(getPaddingLeft(), getPaddingTop(), getPaddingRight(), getPaddingBottom() + labelsSize + fi.descent); super.setOnSeekBarChangeListener(new OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { } @Override public void onStartTrackingTouch(SeekBar seekBar) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { cancelAnimator(); } } @TargetApi(Build.VERSION_CODES.HONEYCOMB) private void cancelAnimator() { if (mObjectAnimator != null) { mObjectAnimator.cancel(); } } @Override public void onStopTrackingTouch(SeekBar seekBar) { mCurrentTick = getClosestTick(seekBar.getProgress()); final int endProgress = getProgressForTick(mCurrentTick); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { startAnimator(seekBar, endProgress); } else { seekBar.setProgress(endProgress); } if (mOnChangeListener != null) { mOnChangeListener.onValueChanged(mCurrentTick); } } @TargetApi(Build.VERSION_CODES.HONEYCOMB) private void startAnimator(SeekBar seekBar, int endProgress) { mObjectAnimator = ObjectAnimator.ofInt( seekBar, "progress", seekBar.getProgress(), endProgress); mObjectAnimator.setInterpolator(new DecelerateInterpolator()); mObjectAnimator.setDuration(THUMB_SNAP_DURATION_TIME); mObjectAnimator.start(); } }); } private int getClosestTick(int progress) { float normalizedValue = (float) progress / getMax(); return Math.round(normalizedValue * getMaxTick()); } private int getProgressForTick(int tick) { return (getMax() / getMaxTick()) * tick; } @Override public void setOnSeekBarChangeListener(OnSeekBarChangeListener seekBarChangeListener) { // It doesn't make sense to expose the interface for listening to intermediate changes. Check.isTrue(false); } /** * Get the largest tick value the SeekBar can represent * * @return maximum tick value */ public int getMaxTick() { return mTickLabels.length - 1; } /** * Set listener for observing value changes * * @param onChangeListener listener that should receive updates */ public void setOnChangeListener(OnChangeListener onChangeListener) { mOnChangeListener = onChangeListener; } /** * Set tick value * * @param tick tick value in range [0, maxTick] */ public void setCurrentTick(int tick) { Check.isTrue(tick >= 0); Check.isTrue(tick <= getMaxTick()); mCurrentTick = tick; setProgress(getProgressForTick(mCurrentTick)); } public int getCurrentTick() { return mCurrentTick; } public void setLabelColor(int color) { mLabelColor = ColorStateList.valueOf(color); updateLabelColor(); } public void setLabelColor(ColorStateList colors) { mLabelColor = colors; updateLabelColor(); } private void updateLabelColor() { int color = mLabelColor.getColorForState(getDrawableState(), Color.BLACK); if (color != mPaint.getColor()) { mPaint.setColor(color); invalidate(); } } @Override protected void drawableStateChanged() { super.drawableStateChanged(); updateLabelColor(); } @Override public void onDraw(Canvas canvas) { super.onDraw(canvas); final float sliderWidth = getWidth() - getPaddingRight() - getPaddingLeft(); final float sliderStepSize = sliderWidth / getMaxTick(); int closestTick = getClosestTick(getProgress()); String text = mTickLabels[closestTick].toString(); final float startOffset = getPaddingLeft(); final float tickLabelWidth = mPaint.measureText(text); final float tickPos = sliderStepSize * closestTick; final float labelOffset; // First step description text should be anchored with its left edge just // below the slider start tick. The last step description should be anchored // to the right just under the end tick. Tick labels in between are centered below // each tick. if (closestTick == 0) { labelOffset = startOffset; } else if (closestTick == getMaxTick()) { labelOffset = startOffset + sliderWidth - tickLabelWidth; } else { labelOffset = startOffset + tickPos - tickLabelWidth / 2; } // Text position is drawn from bottom left, with bottom at the font baseline. We need to // offset by the descent to cover e.g 'g' that extends below the baseline. final Paint.FontMetricsInt m = mPaint.getFontMetricsInt(); final int lowestPosForFullGlyphCoverage = getHeight() - m.descent; canvas.drawText(text, labelOffset, lowestPosForFullGlyphCoverage, mPaint); } /** * Listener for observing tick changes */ public interface OnChangeListener { void onValueChanged(int selectedTick); } }