diff --git a/base-filters/README.md b/base-filters/README.md
index c68f7e0..95bf577 100644
--- a/base-filters/README.md
+++ b/base-filters/README.md
@@ -4,6 +4,7 @@
1. Выбор одного/нескольких из доступных значений списка
2. Выбор одного/нескольких значений из перечня тегов
+3. Выбор минимального и максимального значения из диапозона
# Использование
@@ -89,8 +90,8 @@ val selectorView = ListSelectionView(newContext)
## 2. Выбор одного/нескольких значений из перечня тегов
-`TagLayoutView` - view-контейнер для тегов
-`TagView` - view для тега. Кастомная разметка для тега должна содержать в корне `TagView`
+* `TagLayoutView` - view-контейнер для тегов
+* `TagView` - view для тега. Кастомная разметка для тега должна содержать в корне `TagView`
### Как использовать
``` kotlin
@@ -116,3 +117,62 @@ binding.tagItemLayout
* `onMoreValuesAction(FilterMoreAction)` и `onPropertySelectedAction(PropertySelectedAction)` используются для передачи колбэков на клик по тегу типа "Еще" и обычного тега соответственно
* после вызова конфигурационных методов обязательно необходимо вызать метод `build()`
* в Builder необходимо передать объект `filterItem: FilterItem`
+
+
+## 3. Выбор минимального и максимального значения из диапозона
+
+* `RangeChoiceView` - контейнер для слайдера и редактируемых полей
+* `FilterRangeSlider` - слайдер - Можно использовать как отдельный элемент
+* `HintInputView` - view для редактируемого поля начала и окончания диапозона
+
+### Как использовать
+
+В разметке
+``` xml
+
+```
+
+Настройка в коде
+``` kotlin
+fun setupValues(item: FilterRangeItem) {
+ binding.rangeValuesTest.setupRangeValues(
+ rangeFilterItem = item,
+ onChangeCallback = callback
+ )
+ }
+
+ fun resetValues() {
+ binding.rangeValuesTest.resetRangeValue()
+ }
+```
+### Конфигурации
+Вся конфигурация вьюх осуществляется через стили:
+* Для `RangeChoiceView`:
+ * `filterRange_sliderMargin` - расстояние от слайдера до редактируемых полей
+ * `filterRange_startHint` - ссылка на строку с текстом подсказки в редактируемом поле для начального значения
+ * `filterRange_endHint` - ссылка на строку с текстом подсказки в редактируемом поле для конечного значения
+ * `filterRange_theme` - ссылка на тему
+* В теме:
+ * атрибут `filterRange_sliderStyle` - ссылка на стиль слайдера
+ * атрибут `filterRange_hintViewStyle` - ссылка на стиль `HintInputView`
+ * атрибут `filterRange_hintTextStyle` - ссылка на стиль `TextView` внутри `HintInputView`
+ * атрибут `filterRange_valueEditTextStyle` - ссылка на стиль `EditText` внутри `HintInputView`
+* Для `FilterRangeSlider`:
+ * `trackColorActive`
+ * `trackColorInactive`
+ * `trackHeight`
+ * `thumbElevation`
+ * `thumbColor`
+ * `labelBehavior`
+ * `haloRadius`
+ * `filterRange_stepTextAppearance`
+ * `filterRange_activeTickColor`
+ * `filterRange_inactiveTickColor`
+ * `filterRange_stepValueMarginTop`
+ * `filterRange_sliderPointSize`
+ * `filterRange_pointShape`
diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/FilterRangeSlider.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/FilterRangeSlider.kt
new file mode 100644
index 0000000..0fdfc1d
--- /dev/null
+++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/FilterRangeSlider.kt
@@ -0,0 +1,173 @@
+package ru.touchin.roboswag.base_filters.range
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Paint.Cap
+import android.util.AttributeSet
+import androidx.annotation.StyleRes
+import androidx.appcompat.widget.AppCompatTextView
+import androidx.core.content.withStyledAttributes
+import androidx.core.widget.TextViewCompat
+import com.google.android.material.shape.CornerFamily
+import com.google.android.material.shape.MaterialShapeDrawable
+import com.google.android.material.shape.ShapeAppearanceModel
+import com.google.android.material.slider.RangeSlider
+import ru.touchin.roboswag.base_filters.R
+import ru.touchin.roboswag.components.utils.getColorSimple
+
+class FilterRangeSlider @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ defStyleRes: Int = 0
+) : RangeSlider(context, attrs, defStyleAttr) {
+
+ var points: List? = null
+ set(value) {
+ field = value?.sorted()?.filter { it > valueFrom && it < valueTo }
+ }
+
+ private val innerThumbRadius: Int = thumbRadius
+
+ private var stepValueMarginTop = 0f
+ private var inactiveTickColor: Int = context.getColorSimple(R.color.slider_point_inactive)
+ private var activeTickColor: Int = context.getColorSimple(R.color.slider_point_active)
+ private var sliderPointSize: Float = 5f
+
+ @StyleRes
+ private var stepTextAppearance: Int = -1
+ private var shape: Shape = Shape.CIRCLE
+
+ private var trackCenterY: Float = -1F
+
+ init {
+ // Set original thumb radius to zero to draw custom one on top
+ thumbRadius = 0
+
+ context.withStyledAttributes(attrs, R.styleable.FilterRangeSlider, defStyleAttr, defStyleRes) {
+ stepValueMarginTop = getDimension(R.styleable.FilterRangeSlider_filterRange_stepValueMarginTop, stepValueMarginTop)
+ inactiveTickColor = getColor(R.styleable.FilterRangeSlider_filterRange_inactiveTickColor, inactiveTickColor)
+ activeTickColor = getColor(R.styleable.FilterRangeSlider_filterRange_activeTickColor, activeTickColor)
+ sliderPointSize = getDimension(R.styleable.FilterRangeSlider_filterRange_sliderPointSize, sliderPointSize)
+ stepTextAppearance = getResourceId(R.styleable.FilterRangeSlider_filterRange_stepTextAppearance, -1)
+ shape = Shape.values()[getInt(R.styleable.FilterRangeSlider_filterRange_pointShape, Shape.CIRCLE.ordinal)]
+ }
+ }
+
+ private val thumbDrawable = MaterialShapeDrawable().apply {
+ shadowCompatibilityMode = MaterialShapeDrawable.SHADOW_COMPAT_MODE_ALWAYS
+ setBounds(0, 0, innerThumbRadius * 2, innerThumbRadius * 2)
+ elevation = thumbElevation
+ state = drawableState
+ fillColor = thumbTintList
+ shapeAppearanceModel = ShapeAppearanceModel
+ .builder()
+ .setAllCorners(shape.value, innerThumbRadius.toFloat())
+ .build()
+ }
+
+ private val inactiveTicksPaint = getDefaultTickPaint().apply { color = inactiveTickColor }
+
+ private val activeTicksPaint = getDefaultTickPaint().apply { color = activeTickColor }
+
+ private fun getDefaultTickPaint() = Paint().apply {
+ isAntiAlias = true
+ style = Paint.Style.STROKE
+ strokeCap = Cap.ROUND
+ strokeWidth = sliderPointSize
+ }
+
+ // Using TextView as a bridge to get text params
+ private val stepValuePaint: Paint = AppCompatTextView(context).apply {
+ stepTextAppearance.takeIf { it != -1 }?.let { TextViewCompat.setTextAppearance(this, it) }
+ }.let { textView ->
+ Paint().apply {
+ isAntiAlias = true
+ color = textView.currentTextColor
+ textSize = textView.textSize
+ typeface = textView.typeface
+ textAlign = Paint.Align.CENTER
+ }
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+
+ trackCenterY = measuredHeight / 2F
+
+ val height = trackCenterY + trackHeight / 2F + stepValueMarginTop + stepValuePaint.textSize
+ setMeasuredDimension(measuredWidth, height.toInt())
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+
+ drawTicks(canvas)
+ drawThumb(canvas)
+ drawStepValues(canvas)
+ }
+
+ private fun drawTicks(canvas: Canvas) {
+ if (points.isNullOrEmpty()) return
+
+ val ticksCoordinates = mutableListOf()
+ points?.forEach { point ->
+ ticksCoordinates.add(normalizeValue(point.toFloat()) * trackWidth + trackSidePadding)
+ ticksCoordinates.add(trackCenterY)
+ }
+
+ val leftPointsSize = points?.count { it < values[0] } ?: 0
+ val rightPointSize = points?.count { it > values[1] } ?: 0
+ val activePointSize = (points?.size ?: 0) - leftPointsSize - rightPointSize
+
+ // Draw inactive ticks to the left of the smallest thumb.
+ canvas.drawPoints(ticksCoordinates.toFloatArray(), 0, leftPointsSize * 2, inactiveTicksPaint)
+
+ // Draw active ticks between the thumbs.
+ canvas.drawPoints(
+ ticksCoordinates.toFloatArray(),
+ leftPointsSize * 2,
+ activePointSize * 2,
+ activeTicksPaint
+ )
+
+ // Draw inactive ticks to the right of the largest thumb.
+ canvas.drawPoints(
+ ticksCoordinates.toFloatArray(),
+ leftPointsSize * 2 + activePointSize * 2,
+ rightPointSize * 2,
+ inactiveTicksPaint
+ )
+ }
+
+ private fun drawThumb(canvas: Canvas) {
+ for (value in values) {
+ canvas.save()
+ canvas.translate(
+ (trackSidePadding + (normalizeValue(value) * trackWidth).toInt() - innerThumbRadius).toFloat(),
+ trackCenterY - innerThumbRadius
+ )
+ thumbDrawable.draw(canvas)
+ canvas.restore()
+ }
+ }
+
+ private fun drawStepValues(canvas: Canvas) {
+ points?.forEach { point ->
+ canvas.drawText(
+ point.toString(),
+ normalizeValue(point.toFloat()) * trackWidth + trackSidePadding,
+ trackCenterY + trackHeight / 2F + stepValueMarginTop + stepValuePaint.textSize - 3F,
+ stepValuePaint
+ )
+ }
+ }
+
+ private fun normalizeValue(value: Float) = (value - valueFrom) / (valueTo - valueFrom)
+
+ private enum class Shape(val value: Int) {
+ CIRCLE(CornerFamily.ROUNDED),
+ CUT(CornerFamily.CUT)
+ }
+}
diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/HintInputView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/HintInputView.kt
new file mode 100644
index 0000000..9f860a6
--- /dev/null
+++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/HintInputView.kt
@@ -0,0 +1,39 @@
+package ru.touchin.roboswag.base_filters.range
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import ru.touchin.roboswag.base_filters.databinding.ViewHintInputBinding
+
+class HintInputView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ defStyleRes: Int = 0
+) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) {
+
+ private val binding = ViewHintInputBinding.inflate(LayoutInflater.from(context), this)
+
+ var inputText: String = ""
+ set(value) {
+ setText(value)
+ field = value
+ }
+
+ fun setHint(value: String?) {
+ binding.startHint.text = value.orEmpty()
+ }
+
+ fun setOnEditorActionListener(listener: TextView.OnEditorActionListener) =
+ binding.editText.setOnEditorActionListener(listener)
+
+ private fun setText(value: String) {
+ binding.editText.run {
+ setText(value)
+ setSelection(text?.length ?: 0)
+ }
+ }
+
+}
diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/RangeChoiceView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/RangeChoiceView.kt
new file mode 100644
index 0000000..c03143a
--- /dev/null
+++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/RangeChoiceView.kt
@@ -0,0 +1,168 @@
+package ru.touchin.roboswag.base_filters.range
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.util.AttributeSet
+import android.view.ContextThemeWrapper
+import android.view.LayoutInflater
+import android.view.inputmethod.EditorInfo
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.withStyledAttributes
+import androidx.core.view.updateLayoutParams
+import com.google.android.material.slider.RangeSlider
+import ru.touchin.roboswag.base_filters.R
+import ru.touchin.roboswag.base_filters.databinding.RangeChoiceViewBinding
+import ru.touchin.roboswag.base_filters.range.model.FilterRangeItem
+import ru.touchin.roboswag.base_filters.range.model.SelectedValues
+import kotlin.properties.Delegates
+
+typealias FilterRangeChanged = (FilterRangeItem) -> Unit
+
+class RangeChoiceView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ defStyleRes: Int = 0
+) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) {
+
+ private val defaultTheme = R.style.Theme_FilterRangeSlider
+ private var binding: RangeChoiceViewBinding by Delegates.notNull()
+
+ private var valueChangedAction: FilterRangeChanged? = null
+
+ private var rangeItem: FilterRangeItem? = null
+ set(value) {
+ field = value
+
+ binding.fromInput.inputText = value?.selectedValues?.min?.toString()
+ ?: value?.start?.toString().orEmpty()
+ binding.toInput.inputText = value?.selectedValues?.max?.toString()
+ ?: value?.end?.toString().orEmpty()
+
+ binding.rangeSlider.run {
+ values = listOf(
+ value?.selectedValues?.min?.toFloat() ?: value?.start?.toFloat(),
+ value?.selectedValues?.max?.toFloat() ?: value?.end?.toFloat()
+ )
+ }
+ }
+
+ init {
+ context.withStyledAttributes(attrs, R.styleable.FilterRangeChoice, defStyleAttr, defStyleRes) {
+ val theme = getResourceId(R.styleable.FilterRangeChoice_filterRange_theme, defaultTheme)
+ val themeContext = ContextThemeWrapper(context, theme)
+ binding = RangeChoiceViewBinding.inflate(LayoutInflater.from(themeContext), this@RangeChoiceView)
+
+ binding.fromInput.setHint(getString(R.styleable.FilterRangeChoice_filterRange_startHint))
+ binding.toInput.setHint(getString(R.styleable.FilterRangeChoice_filterRange_endHint))
+ binding.rangeSliderGuideline.updateLayoutParams {
+ topMargin = getDimension(R.styleable.FilterRangeChoice_filterRange_sliderMargin, 0f).toInt()
+ }
+ }
+ }
+
+ fun setupRangeValues(
+ rangeFilterItem: FilterRangeItem,
+ onChangeCallback: FilterRangeChanged
+ ) {
+ rangeItem = rangeFilterItem
+ valueChangedAction = onChangeCallback
+
+ with(binding) {
+ addChangeValuesListener()
+ setupRangeSlider(rangeFilterItem)
+ }
+ }
+
+ fun resetRangeValue() {
+ rangeItem = rangeItem?.resetSelectedValues()
+ }
+
+ private fun addChangeValuesListener() {
+ binding.fromInput.addChangeValueListener { rangeItem?.setValue(selectedMinValue = it.toIntOrNull()) }
+ binding.toInput.addChangeValueListener { rangeItem?.setValue(selectedMaxValue = it.toIntOrNull()) }
+ }
+
+ private fun setupRangeSlider(rangeFilterItem: FilterRangeItem) {
+ with(binding) {
+ rangeSlider.apply {
+ valueFrom = rangeFilterItem.start.toFloat()
+ valueTo = rangeFilterItem.end.toFloat()
+ points = rangeFilterItem.intermediates
+ }
+
+ rangeSlider.addOnChangeListener { _, _, _ ->
+ fromInput.inputText = rangeSlider.values[0].toInt().toString()
+ toInput.inputText = rangeSlider.values[1].toInt().toString()
+ }
+
+ rangeSlider.addOnSliderTouchListener(object : RangeSlider.OnSliderTouchListener {
+ @SuppressLint("RestrictedApi")
+ override fun onStartTrackingTouch(slider: RangeSlider) = Unit
+
+ @SuppressLint("RestrictedApi")
+ override fun onStopTrackingTouch(slider: RangeSlider) {
+ binding.rangeSlider.apply {
+ when (focusedThumbIndex) {
+ 0 -> {
+ rangeItem = rangeItem?.setValue(selectedMinValue = from().toInt())
+ rangeItem?.let { valueChangedAction?.invoke(it) }
+ }
+ 1 -> {
+ rangeItem = rangeItem?.setValue(selectedMaxValue = to().toInt())
+ rangeItem?.let { valueChangedAction?.invoke(it) }
+ }
+ }
+ }
+ }
+ })
+ }
+ }
+
+ private fun HintInputView.addChangeValueListener(updateValue: (String) -> FilterRangeItem?) {
+ setOnEditorActionListener { view, actionId, _ ->
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ rangeItem = updateValue(view.text.toString().filterNot { it.isWhitespace() })
+ rangeItem?.let { valueChangedAction?.invoke(it) }
+ }
+ false
+ }
+ }
+
+ private fun RangeSlider.from() = values[0].toInt().toString()
+
+ private fun RangeSlider.to() = values[1].toInt().toString()
+
+ @SuppressWarnings("detekt.ComplexMethod")
+ private fun FilterRangeItem.setValue(
+ selectedMaxValue: Int? = selectedValues?.max,
+ selectedMinValue: Int? = selectedValues?.min
+ ): FilterRangeItem {
+ val isMaxValueUpdated = selectedMaxValue != selectedValues?.max
+ val isMinValueUpdated = selectedMinValue != selectedValues?.min
+
+ val isMinValueOutOfRange = selectedMinValue != null && isMinValueUpdated && selectedMinValue > (selectedMaxValue ?: end)
+ val isMaxValueOutOfRange = selectedMaxValue != null && isMaxValueUpdated && selectedMaxValue < (selectedMinValue ?: start)
+
+ val updatedValues = when {
+ selectedMaxValue == end && selectedMinValue == start -> null
+ isMinValueOutOfRange -> SelectedValues(
+ max = selectedMaxValue ?: end,
+ min = selectedMaxValue ?: end
+ )
+ isMaxValueOutOfRange -> SelectedValues(
+ max = selectedMinValue ?: start,
+ min = selectedMinValue ?: start
+ )
+ else -> SelectedValues(
+ max = selectedMaxValue?.takeIf { it < end } ?: end,
+ min = selectedMinValue?.takeIf { it > start } ?: start
+ )
+ }
+
+ return copyWithSelectedValue(selectedValues = updatedValues)
+ }
+
+ private fun FilterRangeItem.resetSelectedValues() = copyWithSelectedValue(selectedValues = null)
+
+}
diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/model/FilterRangeItem.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/model/FilterRangeItem.kt
new file mode 100644
index 0000000..1c452de
--- /dev/null
+++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/model/FilterRangeItem.kt
@@ -0,0 +1,34 @@
+package ru.touchin.roboswag.base_filters.range.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+open class FilterRangeItem(
+ val id: String,
+ val start: Int,
+ val end: Int,
+ val title: String,
+ val intermediates: List? = null,
+ val step: Int? = null,
+ val selectedValues: SelectedValues? = null
+) : Parcelable {
+
+ fun isCorrectValues() = end > start
+
+ fun copyWithSelectedValue(selectedValues: SelectedValues?) = FilterRangeItem(
+ id = id,
+ start = start,
+ end = end,
+ title = title,
+ intermediates = intermediates,
+ step = step,
+ selectedValues = selectedValues
+ )
+}
+
+@Parcelize
+data class SelectedValues(
+ val max: Int,
+ val min: Int
+) : Parcelable
diff --git a/base-filters/src/main/res/drawable/background_hint_input.xml b/base-filters/src/main/res/drawable/background_hint_input.xml
new file mode 100644
index 0000000..354e22c
--- /dev/null
+++ b/base-filters/src/main/res/drawable/background_hint_input.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/base-filters/src/main/res/drawable/cursor_background_text_input_view.xml b/base-filters/src/main/res/drawable/cursor_background_text_input_view.xml
new file mode 100644
index 0000000..4633444
--- /dev/null
+++ b/base-filters/src/main/res/drawable/cursor_background_text_input_view.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/base-filters/src/main/res/layout/range_choice_view.xml b/base-filters/src/main/res/layout/range_choice_view.xml
new file mode 100644
index 0000000..6d749ef
--- /dev/null
+++ b/base-filters/src/main/res/layout/range_choice_view.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/base-filters/src/main/res/layout/view_hint_input.xml b/base-filters/src/main/res/layout/view_hint_input.xml
new file mode 100644
index 0000000..1f66eb4
--- /dev/null
+++ b/base-filters/src/main/res/layout/view_hint_input.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
diff --git a/base-filters/src/main/res/values/attrs.xml b/base-filters/src/main/res/values/attrs.xml
index bd6618a..32e1c69 100644
--- a/base-filters/src/main/res/values/attrs.xml
+++ b/base-filters/src/main/res/values/attrs.xml
@@ -6,4 +6,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/base-filters/src/main/res/values/colors.xml b/base-filters/src/main/res/values/colors.xml
new file mode 100644
index 0000000..aed3bb2
--- /dev/null
+++ b/base-filters/src/main/res/values/colors.xml
@@ -0,0 +1,8 @@
+
+
+ #B9B9B9
+ #E35100
+ #EE9766
+ #E7E7E7
+ #E35100
+
diff --git a/base-filters/src/main/res/values/strings.xml b/base-filters/src/main/res/values/strings.xml
new file mode 100644
index 0000000..6f75b53
--- /dev/null
+++ b/base-filters/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+
+ от
+ до
+
diff --git a/base-filters/src/main/res/values/styles.xml b/base-filters/src/main/res/values/styles.xml
index 380b4da..8565d5b 100644
--- a/base-filters/src/main/res/values/styles.xml
+++ b/base-filters/src/main/res/values/styles.xml
@@ -15,4 +15,55 @@
- 16dp
+
+
+
+
+
+
+
+
+
+
+
+