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 + + + + + + + + + + + +