Last active
January 24, 2020 07:05
-
-
Save alexxxdev/5190a0ef62f92ade9bff8a71aec242e8 to your computer and use it in GitHub Desktop.
RangeSeekBar
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
package com.gensport.utils.view | |
import android.content.Context | |
import android.content.res.TypedArray | |
import android.graphics.* | |
import android.os.Bundle | |
import android.os.Parcelable | |
import android.util.AttributeSet | |
import android.util.TypedValue | |
import android.view.MotionEvent | |
import android.view.View | |
import android.view.ViewConfiguration | |
import android.widget.ImageView | |
import com.gensport.R | |
import org.jetbrains.anko.dip | |
class RangeSeekBar:ImageView { | |
val DEFAULT_MINIMUM = 0 | |
val DEFAULT_MAXIMUM = 100 | |
val HEIGHT_IN_DP = 30 | |
val TEXT_LATERAL_PADDING_IN_DP = 3 | |
private val INITIAL_PADDING_IN_DP = 8 | |
private val LINE_HEIGHT_IN_DP = 1 | |
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) | |
private val thumbImage = BitmapFactory.decodeResource(resources, R.drawable.seek_thumb_normal) | |
private val thumbPressedImage = BitmapFactory.decodeResource(resources, R.drawable.seek_thumb_pressed) | |
private val thumbDisabledImage = BitmapFactory.decodeResource(resources, R.drawable.seek_thumb_disabled) | |
private val thumbWidth = thumbImage.width.toFloat() | |
private val thumbHalfWidth = 0.5f * thumbWidth | |
private val thumbHalfHeight = 0.5f * thumbImage.height | |
private var INITIAL_PADDING: Float = 0.toFloat() | |
private var padding: Float = 0.toFloat() | |
private var absoluteMinValue: Int = 0 | |
private var absoluteMaxValue: Int = 0 | |
private var numberType: NumberType? = null | |
private var absoluteMinValuePrim: Double = 0.0 | |
private var absoluteMaxValuePrim: Double = 0.0 | |
private var normalizedMinValue = 0.0 | |
private var normalizedMaxValue = 1.0 | |
private var pressedThumb: Thumb? = null | |
private var notifyWhileDragging = false | |
private var listener: OnRangeSeekBarChangeListener<Int>? = null | |
val DEFAULT_COLOR = Color.argb(0xFF, 0x33, 0xB5, 0xE5) | |
val INVALID_POINTER_ID = 255 | |
val ACTION_POINTER_UP = 0x6 | |
val ACTION_POINTER_INDEX_MASK = 0x0000ff00 | |
val ACTION_POINTER_INDEX_SHIFT = 8 | |
private var mDownMotionX: Float = 0.toFloat() | |
private var mActivePointerId = INVALID_POINTER_ID | |
private var mScaledTouchSlop: Int = 0 | |
private var mIsDragging: Boolean = false | |
private var mTextOffset: Int = 0 | |
private var mTextSize: Int = 0 | |
private var mDistanceToTop: Int = 0 | |
private var mRect: RectF = RectF() | |
private val DEFAULT_TEXT_SIZE_IN_DP = 14 | |
private val DEFAULT_TEXT_DISTANCE_TO_BUTTON_IN_DP = 8 | |
private val DEFAULT_TEXT_DISTANCE_TO_TOP_IN_DP = 8 | |
private var mSingleThumb: Boolean = false | |
constructor(context: Context): super(context) { | |
init(context, null) | |
} | |
constructor(context: Context, attrs: AttributeSet): super(context, attrs) { | |
init(context, attrs) | |
} | |
constructor(context: Context, attrs: AttributeSet, defStyle: Int): super(context, attrs, defStyle) { | |
init(context, attrs) | |
} | |
private fun init(context: Context, attrs: AttributeSet?) { | |
if (attrs == null) { | |
setRangeToDefaultValues() | |
} else { | |
val a = getContext().obtainStyledAttributes(attrs, R.styleable.RangeSeekBar, 0, 0) | |
setRangeValues( | |
extractNumericValueFromAttributes(a, R.styleable.RangeSeekBar_absoluteMinValue, DEFAULT_MINIMUM), | |
extractNumericValueFromAttributes(a, R.styleable.RangeSeekBar_absoluteMaxValue, DEFAULT_MAXIMUM)) | |
mSingleThumb = a.getBoolean(R.styleable.RangeSeekBar_singleThumb, false) | |
a.recycle() | |
} | |
setValuePrimAndNumberType() | |
INITIAL_PADDING = dip(INITIAL_PADDING_IN_DP).toFloat() | |
mTextSize = dip(DEFAULT_TEXT_SIZE_IN_DP) | |
mDistanceToTop = dip(DEFAULT_TEXT_DISTANCE_TO_TOP_IN_DP) | |
mTextOffset = this.mTextSize + dip(DEFAULT_TEXT_DISTANCE_TO_BUTTON_IN_DP) + this.mDistanceToTop | |
val lineHeight = dip(LINE_HEIGHT_IN_DP).toFloat() | |
mRect = RectF(padding, | |
mTextOffset + thumbHalfHeight - lineHeight / 2, | |
width - padding, | |
mTextOffset + thumbHalfHeight + lineHeight / 2) | |
isFocusable = true | |
isFocusableInTouchMode = true | |
mScaledTouchSlop = ViewConfiguration.get(getContext()).scaledTouchSlop | |
} | |
private fun extractNumericValueFromAttributes(a: TypedArray, attribute: Int, defaultValue: Int): Int { | |
val tv = a.peekValue(attribute) ?: return Integer.valueOf(defaultValue) | |
val type = tv.type | |
if (type == TypedValue.TYPE_FLOAT) { | |
return a.getFloat(attribute, defaultValue.toFloat()).toInt() | |
} else { | |
return a.getInteger(attribute, defaultValue) | |
} | |
} | |
fun setRangeValues(minValue: Int, maxValue: Int) { | |
this.absoluteMinValue = minValue | |
this.absoluteMaxValue = maxValue | |
setValuePrimAndNumberType() | |
} | |
private fun setRangeToDefaultValues() { | |
this.absoluteMinValue = DEFAULT_MINIMUM | |
this.absoluteMaxValue = DEFAULT_MAXIMUM | |
setValuePrimAndNumberType() | |
} | |
private fun setValuePrimAndNumberType() { | |
absoluteMinValuePrim = absoluteMinValue.toDouble() | |
absoluteMaxValuePrim = absoluteMaxValue.toDouble() | |
numberType = NumberType.fromNumber(absoluteMinValue) | |
} | |
fun resetSelectedValues() { | |
setSelectedMinValue(absoluteMinValue) | |
setSelectedMaxValue(absoluteMaxValue) | |
} | |
fun isNotifyWhileDragging(): Boolean { | |
return notifyWhileDragging | |
} | |
fun setNotifyWhileDragging(flag: Boolean) { | |
this.notifyWhileDragging = flag | |
} | |
fun getAbsoluteMinValue(): Int { | |
return absoluteMinValue | |
} | |
fun getAbsoluteMaxValue(): Int { | |
return absoluteMaxValue | |
} | |
fun getSelectedMinValue(): Int { | |
return normalizedToValue(normalizedMinValue) | |
} | |
fun setSelectedMinValue(value: Int) { | |
if (0.0 == absoluteMaxValuePrim - absoluteMinValuePrim) { | |
setNormalizedMinValue(0.0) | |
} else { | |
setNormalizedMinValue(valueToNormalized(value)) | |
} | |
} | |
fun getSelectedMaxValue(): Int { | |
return normalizedToValue(normalizedMaxValue) | |
} | |
fun setSelectedMaxValue(value: Int) { | |
if (0.0 == absoluteMaxValuePrim - absoluteMinValuePrim) { | |
setNormalizedMaxValue(1.0) | |
} else { | |
setNormalizedMaxValue(valueToNormalized(value)) | |
} | |
} | |
fun setOnRangeSeekBarChangeListener(listener: OnRangeSeekBarChangeListener<Int>) { | |
this.listener = listener | |
} | |
override fun onTouchEvent(event: MotionEvent): Boolean { | |
if (!isEnabled) { | |
return false | |
} | |
val pointerIndex: Int | |
val action = event.action | |
when (action and MotionEvent.ACTION_MASK) { | |
MotionEvent.ACTION_DOWN -> { | |
// Remember where the motion event started | |
mActivePointerId = event.getPointerId(event.pointerCount - 1) | |
pointerIndex = event.findPointerIndex(mActivePointerId) | |
mDownMotionX = event.getX(pointerIndex) | |
pressedThumb = evalPressedThumb(mDownMotionX) | |
// Only handle thumb presses. | |
if (pressedThumb == null) { | |
return super.onTouchEvent(event) | |
} | |
isPressed = true | |
invalidate() | |
onStartTrackingTouch() | |
trackTouchEvent(event) | |
attemptClaimDrag() | |
} | |
MotionEvent.ACTION_MOVE -> if (pressedThumb != null) { | |
if (mIsDragging) { | |
trackTouchEvent(event) | |
} else { | |
// Scroll to follow the motion event | |
pointerIndex = event.findPointerIndex(mActivePointerId) | |
val x = event.getX(pointerIndex) | |
if (Math.abs(x - mDownMotionX) > mScaledTouchSlop) { | |
isPressed = true | |
invalidate() | |
onStartTrackingTouch() | |
trackTouchEvent(event) | |
attemptClaimDrag() | |
} | |
} | |
if (notifyWhileDragging && listener != null) { | |
listener?.onRangeSeekBarValuesChanged(this, getSelectedMinValue(), getSelectedMaxValue()) | |
} | |
} | |
MotionEvent.ACTION_UP -> { | |
if (mIsDragging) { | |
trackTouchEvent(event) | |
onStopTrackingTouch() | |
isPressed = false | |
} else { | |
// Touch up when we never crossed the touch slop threshold | |
// should be interpreted as a tap-seek to that location. | |
onStartTrackingTouch() | |
trackTouchEvent(event) | |
onStopTrackingTouch() | |
} | |
pressedThumb = null | |
invalidate() | |
listener?.onRangeSeekBarValuesChanged(this, getSelectedMinValue(), getSelectedMaxValue()) | |
} | |
MotionEvent.ACTION_POINTER_DOWN -> { | |
val index = event.pointerCount - 1 | |
// final int index = ev.getActionIndex(); | |
mDownMotionX = event.getX(index) | |
mActivePointerId = event.getPointerId(index) | |
invalidate() | |
} | |
MotionEvent.ACTION_POINTER_UP -> { | |
onSecondaryPointerUp(event) | |
invalidate() | |
} | |
MotionEvent.ACTION_CANCEL -> { | |
if (mIsDragging) { | |
onStopTrackingTouch() | |
isPressed = false | |
} | |
invalidate() // see above explanation | |
} | |
} | |
return true | |
} | |
private fun onSecondaryPointerUp(ev: MotionEvent) { | |
val pointerIndex = ev.action and ACTION_POINTER_INDEX_MASK shr ACTION_POINTER_INDEX_SHIFT | |
val pointerId = ev.getPointerId(pointerIndex) | |
if (pointerId == mActivePointerId) { | |
// This was our active pointer going up. Choose | |
// a new active pointer and adjust accordingly. | |
// TODO: Make this decision more intelligent. | |
val newPointerIndex = if (pointerIndex == 0) 1 else 0 | |
mDownMotionX = ev.getX(newPointerIndex) | |
mActivePointerId = ev.getPointerId(newPointerIndex) | |
} | |
} | |
private fun trackTouchEvent(event: MotionEvent) { | |
val pointerIndex = event.findPointerIndex(mActivePointerId) | |
val x = event.getX(pointerIndex) | |
if (Thumb.MIN.equals(pressedThumb) && !mSingleThumb) { | |
setNormalizedMinValue(screenToNormalized(x)) | |
} else if (Thumb.MAX.equals(pressedThumb)) { | |
setNormalizedMaxValue(screenToNormalized(x)) | |
} | |
} | |
private fun attemptClaimDrag() { | |
if (parent != null) { | |
parent.requestDisallowInterceptTouchEvent(true) | |
} | |
} | |
fun onStartTrackingTouch() { | |
mIsDragging = true | |
} | |
fun onStopTrackingTouch() { | |
mIsDragging = false | |
} | |
@Synchronized override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { | |
var width = 200 | |
if (View.MeasureSpec.UNSPECIFIED != View.MeasureSpec.getMode(widthMeasureSpec)) { | |
width = View.MeasureSpec.getSize(widthMeasureSpec) | |
} | |
var height = thumbImage.height + PixelUtil.dpToPx(context, HEIGHT_IN_DP) | |
if (View.MeasureSpec.UNSPECIFIED != View.MeasureSpec.getMode(heightMeasureSpec)) { | |
height = Math.min(height, View.MeasureSpec.getSize(heightMeasureSpec)) | |
} | |
setMeasuredDimension(width, height) | |
} | |
override fun onDraw(canvas: Canvas) { | |
super.onDraw(canvas) | |
paint.textSize = mTextSize.toFloat() | |
paint.style = Paint.Style.FILL | |
paint.color = Color.GRAY | |
paint.isAntiAlias = true | |
// draw min and max labels | |
val minLabel = "Min" | |
val maxLabel = "Max" | |
val minMaxLabelSize = Math.max(paint.measureText(minLabel), paint.measureText(maxLabel)) | |
val minMaxHeight = mTextOffset + thumbHalfHeight + mTextSize / 3 | |
canvas.drawText(minLabel, 0f, minMaxHeight, paint) | |
canvas.drawText(maxLabel, width - minMaxLabelSize, minMaxHeight, paint) | |
padding = INITIAL_PADDING + minMaxLabelSize + thumbHalfWidth | |
// draw seek bar background line | |
mRect.left = padding | |
mRect.right = width - padding | |
canvas.drawRect(mRect, paint) | |
val selectedValuesAreDefault = getSelectedMinValue().equals(getAbsoluteMinValue()) && getSelectedMaxValue().equals(getAbsoluteMaxValue()) | |
val colorToUseForButtonsAndHighlightedLine = if (selectedValuesAreDefault) | |
Color.GRAY | |
else | |
// default values | |
DEFAULT_COLOR //non default, filter is active | |
// draw seek bar active range line | |
mRect.left = normalizedToScreen(normalizedMinValue) | |
mRect.right = normalizedToScreen(normalizedMaxValue) | |
paint.color = colorToUseForButtonsAndHighlightedLine | |
canvas.drawRect(mRect, paint) | |
// draw minimum thumb if not a single thumb control | |
if (!mSingleThumb) { | |
drawThumb(normalizedToScreen(normalizedMinValue), Thumb.MIN.equals(pressedThumb), canvas, | |
selectedValuesAreDefault) | |
} | |
// draw maximum thumb | |
drawThumb(normalizedToScreen(normalizedMaxValue), Thumb.MAX.equals(pressedThumb), canvas, | |
selectedValuesAreDefault) | |
// draw the text if sliders have moved from default edges | |
if (!selectedValuesAreDefault) { | |
paint.textSize = mTextSize.toFloat() | |
paint.color = Color.WHITE | |
// give text a bit more space here so it doesn't get cut off | |
val offset = PixelUtil.dpToPx(context, TEXT_LATERAL_PADDING_IN_DP) | |
val minText = getSelectedMinValue().toString() | |
val maxText = getSelectedMaxValue().toString() | |
val minTextWidth = paint.measureText(minText) + offset | |
val maxTextWidth = paint.measureText(maxText) + offset | |
if (!mSingleThumb) { | |
canvas.drawText(minText, | |
normalizedToScreen(normalizedMinValue) - minTextWidth * 0.5f, | |
(mDistanceToTop + mTextSize).toFloat(), | |
paint) | |
} | |
canvas.drawText(maxText, | |
normalizedToScreen(normalizedMaxValue) - maxTextWidth * 0.5f, | |
(mDistanceToTop + mTextSize).toFloat(), | |
paint) | |
} | |
} | |
override fun onSaveInstanceState(): Parcelable { | |
val bundle = Bundle() | |
bundle.putParcelable("SUPER", super.onSaveInstanceState()) | |
bundle.putDouble("MIN", normalizedMinValue) | |
bundle.putDouble("MAX", normalizedMaxValue) | |
return bundle | |
} | |
override fun onRestoreInstanceState(parcel: Parcelable) { | |
val bundle = parcel as Bundle | |
super.onRestoreInstanceState(bundle.getParcelable<Parcelable>("SUPER")) | |
normalizedMinValue = bundle.getDouble("MIN") | |
normalizedMaxValue = bundle.getDouble("MAX") | |
} | |
private fun drawThumb(screenCoord: Float, pressed: Boolean, canvas: Canvas, areSelectedValuesDefault: Boolean) { | |
val buttonToDraw: Bitmap | |
if (areSelectedValuesDefault) { | |
buttonToDraw = thumbDisabledImage | |
} else { | |
buttonToDraw = if (pressed) thumbPressedImage else thumbImage | |
} | |
canvas.drawBitmap(buttonToDraw, screenCoord - thumbHalfWidth, | |
mTextOffset.toFloat(), | |
paint) | |
} | |
private fun evalPressedThumb(touchX: Float): Thumb { | |
var result: Thumb = Thumb.MIN | |
val minThumbPressed = isInThumbRange(touchX, normalizedMinValue) | |
val maxThumbPressed = isInThumbRange(touchX, normalizedMaxValue) | |
if (minThumbPressed && maxThumbPressed) { | |
// if both thumbs are pressed (they lie on top of each other), choose the one with more room to drag. this avoids "stalling" the thumbs in a corner, not being able to drag them apart anymore. | |
result = if (touchX / width > 0.5f) Thumb.MIN else Thumb.MAX | |
} else if (minThumbPressed) { | |
result = Thumb.MIN | |
} else if (maxThumbPressed) { | |
result = Thumb.MAX | |
} | |
return result | |
} | |
private fun isInThumbRange(touchX: Float, normalizedThumbValue: Double): Boolean { | |
return Math.abs(touchX - normalizedToScreen(normalizedThumbValue)) <= thumbHalfWidth | |
} | |
private fun setNormalizedMinValue(value: Double) { | |
normalizedMinValue = Math.max(0.0, Math.min(1.0, Math.min(value, normalizedMaxValue))) | |
invalidate() | |
} | |
private fun setNormalizedMaxValue(value: Double) { | |
normalizedMaxValue = Math.max(0.0, Math.min(1.0, Math.max(value, normalizedMinValue))) | |
invalidate() | |
} | |
private fun normalizedToValue(normalized: Double): Int { | |
val v = absoluteMinValuePrim + normalized * (absoluteMaxValuePrim - absoluteMinValuePrim) | |
// TODO parameterize this rounding to allow variable decimal points | |
return numberType.toNumber(Math.round(v * 100) / 100.0) as Int | |
} | |
private fun valueToNormalized(value: Int): Double { | |
if (0.0 == absoluteMaxValuePrim - absoluteMinValuePrim) { | |
// prevent division by zero, simply return 0. | |
return 0.0 | |
} | |
return (value.toDouble() - absoluteMinValuePrim) / (absoluteMaxValuePrim - absoluteMinValuePrim) | |
} | |
private fun normalizedToScreen(normalizedCoord: Double): Float { | |
return (padding + normalizedCoord * (width - 2 * padding)) as Float | |
} | |
private fun screenToNormalized(screenCoord: Float): Double { | |
val width = width | |
if (width <= 2 * padding) { | |
// prevent division by zero, simply return 0. | |
return 0.0 | |
} else { | |
val result = (screenCoord - padding) / (width - 2 * padding) | |
return Math.min(1f, Math.max(0f, result)).toDouble() | |
} | |
} | |
interface OnRangeSeekBarChangeListener<T> { | |
fun onRangeSeekBarValuesChanged(bar: RangeSeekBar, minValue: T, maxValue: T) | |
} | |
private enum class Thumb { | |
MIN, MAX | |
} | |
private enum class NumberType { | |
LONG, DOUBLE, INTEGER, FLOAT, SHORT, BYTE, BIG_DECIMAL; | |
fun toNumber(value: Double): Number { | |
return Integer.valueOf(value.toInt()) | |
} | |
companion object { | |
@Throws(IllegalArgumentException::class) | |
fun <E : Number> fromNumber(value: E): NumberType { | |
return INTEGER | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment