Created
April 23, 2019 21:34
-
-
Save brendanw/ff5dd47a2634eb770ccbf3c117a7757d to your computer and use it in GitHub Desktop.
RangeBar with Two Thumbs Kotlin Implementation
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
/** | |
* 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