Skip to content

Instantly share code, notes, and snippets.

@alexxxdev
Last active January 24, 2020 07:05
Show Gist options
  • Save alexxxdev/5190a0ef62f92ade9bff8a71aec242e8 to your computer and use it in GitHub Desktop.
Save alexxxdev/5190a0ef62f92ade9bff8a71aec242e8 to your computer and use it in GitHub Desktop.
RangeSeekBar
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