Skip to content

Instantly share code, notes, and snippets.

@brendanw
Created April 23, 2019 21:34
Show Gist options
  • Save brendanw/ff5dd47a2634eb770ccbf3c117a7757d to your computer and use it in GitHub Desktop.
Save brendanw/ff5dd47a2634eb770ccbf3c117a7757d to your computer and use it in GitHub Desktop.
RangeBar with Two Thumbs Kotlin Implementation
/**
* RangeBar is a bar that allows a user to define a range of values by moving two thumbs.
*
* Normalized value refers to a number as it exists in a range from [min, max]
* Px value refers to a number as it exists in a range from [startX, endX]
*/
class RangeBar : View {
companion object {
// The diameter of the circle
private const val THUMB_SIZE_DP = 12
private const val TEXT_SIZE_SP = 15
private const val TEXT_SEPARATION_DP = 5
private const val LINE_HEIGHT_DP = 4
private const val PADDING_TOP_DP = 20
private const val PADDING_BOTTOM_DP = 20
fun createThumb(context: Context, fillColor: Int): Drawable {
val d = GradientDrawable()
d.shape = GradientDrawable.OVAL
d.setColor(ContextCompat.getColor(context, fillColor))
return d
}
}
// Need to define startX left and right to ensure thumb text is not cut off
private var startX = 0f
private var endX = 0f
private var max = 0f
set(value) {
field = value
updateBounds(textPaint.measureText(getTextValue(min)), textPaint.measureText(getTextValue(max)))
}
private var min = 0f
set(value) {
field = value
updateBounds(textPaint.measureText(getTextValue(min)), textPaint.measureText(getTextValue(max)))
}
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
val a = context.obtainStyledAttributes(attrs, R.styleable.RangeBar)
min = a.getFloat(R.styleable.RangeBar_min_value, 0f)
max = a.getFloat(R.styleable.RangeBar_max_value, 0f)
leftThumbPosX = startX
a.recycle()
}
var getTextValue: (Float) -> String = { Math.round(it).toString() }
set(value) {
field = value
leftThumbText = value(pxToNormalizedValue(leftThumbPosX))
rightThumbText = value(pxToNormalizedValue(rightThumbPosX))
updateBounds(textPaint.measureText(leftThumbText), textPaint.measureText(rightThumbText))
invalidate()
}
// Background line on which the thumbs sit
private val line: Drawable by lazy {
val d = ShapeDrawable()
d.paint.color = Color.parseColor(("#AAAAAA"))
d
}
private var isDragging: Boolean = false
private val textPaint = Paint().apply {
color = Color.BLACK
textSize = spToPx(TEXT_SIZE_SP)
}
// Drawable for left thumb
private val leftThumb: Drawable by lazy { createThumb(context, R.color.black) }
private var leftThumbPosX = 0f
set(value) {
field = if (value <= startX) startX else value
d { "${this@RangeBar} leftThumbPosX=$field"}
leftThumbText = getTextValue(pxToNormalizedValue(field))
}
// Text above the left thumb
private var leftThumbText = ""
// Drawable for right thumb
private val rightThumb: Drawable by lazy { createThumb(context, R.color.black) }
private var rightThumbPosX = -1f
set(value) {
field = if (value >= endX) endX else value
d { "${this@RangeBar} rightThumbPosX=$field"}
rightThumbText = getTextValue(pxToNormalizedValue(field))
}
val selectedMinValue: Float
get() {
return pxToNormalizedValue(leftThumbPosX)
}
val selectedMaxValue: Float
get() {
return pxToNormalizedValue(rightThumbPosX)
}
// Text above the right thumb
private var rightThumbText = ""
private var pressedThumb: Thumb? = null
private var activePointerId = INVALID_POINTER_ID
override fun onTouchEvent(event: MotionEvent): Boolean {
val action = event.action
when (action and MotionEvent.ACTION_MASK) {
MotionEvent.ACTION_DOWN -> {
activePointerId = event.getPointerId(event.pointerCount - 1)
val pointerIndex = event.findPointerIndex(activePointerId)
pressedThumb = evalPressedThumb(event.getX(pointerIndex))
if (pressedThumb != null) {
isPressed = true
parent?.requestDisallowInterceptTouchEvent(true)
invalidate()
isDragging = true
}
}
MotionEvent.ACTION_MOVE -> {
if (pressedThumb != null && isDragging) {
val x = event.getX(event.findPointerIndex(activePointerId))
if (pressedThumb == Thumb.MIN) {
leftThumbPosX = x
} else {
rightThumbPosX = x
}
invalidate()
}
}
else -> {
isPressed = false
isDragging = false
}
}
return true
}
private fun pxToNormalizedValue(x: Float): Float {
return (((max - min) * (x - startX)) / (endX - startX)) + min
}
private fun evalPressedThumb(touchX: Float): Thumb? {
var result: Thumb? = null
val minThumbPressed = isInThumbRange(touchX, leftThumbPosX)
val maxThumbPressed = isInThumbRange(touchX, rightThumbPosX)
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, thumbPos: Float): Boolean {
return Math.abs(touchX - thumbPos) <= (dpToPx(THUMB_SIZE_DP) * 2)
}
private fun updateBounds(minValTextWidth: Float, maxValTextWidth: Float, vWidth: Float = width.toFloat()) {
startX = (minValTextWidth / 2)
endX = vWidth - (maxValTextWidth / 2) - (dpToPx(THUMB_SIZE_DP) / 2)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// Draw the bar
val lineHeight = dpToPx(LINE_HEIGHT_DP).toInt()
val lineTop = height / 2 - (lineHeight / 2)
line.setBounds(startX.toInt(), lineTop, endX.toInt(), lineTop + lineHeight)
line.draw(canvas)
// Draw left thumb at its position
val thumbHeight = dpToPx(THUMB_SIZE_DP, context).toInt()
val thumbTop = height / 2 - (thumbHeight / 2)
val thumbSize = dpToPx(THUMB_SIZE_DP).toInt()
leftThumb.setBounds(leftThumbPosX.toInt(), thumbTop, leftThumbPosX.toInt() + thumbSize, thumbTop + thumbSize)
leftThumb.draw(canvas)
// Draw left text above left thumb
val textSeparation = dpToPx(TEXT_SEPARATION_DP)
val leftValTextWidth = textPaint.measureText(leftThumbText)
canvas.drawText(leftThumbText, leftThumbPosX + (thumbSize / 2) - (leftValTextWidth / 2), thumbTop - textSeparation, textPaint)
// Draw right thumb at its position
rightThumb.setBounds(rightThumbPosX.toInt(), thumbTop, rightThumbPosX.toInt() + dpToPx(THUMB_SIZE_DP).toInt(), thumbTop + dpToPx(THUMB_SIZE_DP).toInt())
rightThumb.draw(canvas)
// Draw right text above right thumb
val rightValTextWidth = textPaint.measureText(rightThumbText)
canvas.drawText(rightThumbText, rightThumbPosX + (thumbSize / 2) - (rightValTextWidth / 2), thumbTop - textSeparation, textPaint)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec)
if (rightThumbPosX == -1f) {
updateBounds(textPaint.measureText(getTextValue(min)), textPaint.measureText(getTextValue(max)), width.toFloat())
rightThumbPosX = endX
}
val height1 = dpToPx(THUMB_SIZE_DP + PADDING_TOP_DP + PADDING_BOTTOM_DP)
val height2 = dpToPx(THUMB_SIZE_DP + TEXT_SEPARATION_DP + TEXT_SIZE_SP)
setMeasuredDimension(width, Math.max(height1, height2).toInt())
}
}
enum class Thumb { MIN, MAX }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment