feat: range filters view

This commit is contained in:
Nikita Semenov 2022-09-21 17:50:13 +03:00
parent dde0eba7a8
commit 116d2154f8
14 changed files with 1198 additions and 0 deletions

View File

@ -4,6 +4,7 @@
- **Add**: Tag like filter collection view
- **ADD**: List like filter table view
- **ADD**: Range like filter view
### 1.26.0

View File

@ -0,0 +1,62 @@
import UIKit
public struct DefaultRangeFilterAppearance {
public var textFieldsHeight: CGFloat
public var textFieldsWidth: CGFloat
public var textFieldLabelsSpacing: CGFloat
public var textFieldContentInsets: UIEdgeInsets
public var textFieldsBorderColor: UIColor
public var textFieldsBorderWidth: CGFloat
public var sliderColor: UIColor
public var sliderOffColor: UIColor
public var thumbSize: CGFloat
public var textFieldsSpacing: CGFloat
public var spacing: CGFloat
public var leadingSpacing: CGFloat
public var trailingSpacing: CGFloat
public var fontColor: UIColor
public init(textFieldsHeight: CGFloat = 32,
textFieldsWidth: CGFloat = 100,
textFieldLabelsSpacing: CGFloat = 4,
textFieldContentInsets: UIEdgeInsets = .zero,
textFieldsBorderColor: UIColor = .black,
textFieldsBorderWidth: CGFloat = 1,
sliderColor: UIColor = .cyan,
sliderOffColor: UIColor = .darkGray,
thumbSize: CGFloat = 21,
textFieldsSpacing: CGFloat = 10,
spacing: CGFloat = 16,
leadingSpacing: CGFloat = 16,
trailingSpacing: CGFloat = 16,
fontColor: UIColor = .black) {
self.textFieldsHeight = textFieldsHeight
self.textFieldsWidth = textFieldsWidth
self.textFieldLabelsSpacing = textFieldLabelsSpacing
self.textFieldContentInsets = textFieldContentInsets
self.textFieldsBorderColor = textFieldsBorderColor
self.textFieldsBorderWidth = textFieldsBorderWidth
self.sliderColor = sliderColor
self.sliderOffColor = sliderOffColor
self.thumbSize = thumbSize
self.textFieldsSpacing = textFieldsSpacing
self.spacing = spacing
self.leadingSpacing = leadingSpacing
self.trailingSpacing = trailingSpacing
self.fontColor = fontColor
}
}
// MARK: - Default appearance
public extension DefaultRangeFilterAppearance {
static var defaultAppearance: DefaultRangeFilterAppearance {
.init(textFieldContentInsets: .init(top: 4, left: 8, bottom: 4, right: 8))
}
}

View File

@ -0,0 +1,4 @@
public enum RangeFilterLayout {
case textFieldsOnTop
case textFieldsFromBelow
}

View File

@ -0,0 +1,25 @@
import Foundation
open class BaseRangeValuesFormatter: RangeValuesFormatterProtocol {
open var formatter: NumberFormatter {
let formatter = NumberFormatter()
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
return formatter
}
public init() { }
open func string(fromDouble value: Double) -> String {
let nsNumber = NSNumber(floatLiteral: value)
return formatter.string(from: nsNumber) ?? ""
}
open func double(fromString value: String) -> Double {
formatter.number(from: value)?.doubleValue ?? 0
}
}

View File

@ -0,0 +1,4 @@
public protocol RangeValuesFormatterProtocol {
func string(fromDouble value: Double) -> String
func double(fromString value: String) -> Double
}

View File

@ -0,0 +1,4 @@
public protocol RangeFilterDelegate: AnyObject {
func valuesIsChanging(_ value: FilterRangeValue)
func valueDidEndChanging(_ value: FilterRangeValue)
}

View File

@ -0,0 +1,69 @@
import CoreGraphics
import Foundation
import TISwiftUtils
public typealias FilterRangeValue = (fromValue: Double, toValue: Double)
open class BaseRangeFilterViewModel: RangeFilterViewModelProtocol {
public let fromValue: Double
public let toValue: Double
public let stepValues: [Double]
public var initialFromValue: Double?
public var initialToValue: Double?
public weak var filterRangeView: FilterRangeViewRepresenter?
public weak var delegate: RangeFilterDelegate?
open var initialValues: FilterRangeValue {
(initialFromValue ?? fromValue, initialToValue ?? toValue)
}
open var isChanged: Bool {
initialFromValue != fromValue || initialToValue != toValue
}
public init(fromValue: Double,
toValue: Double,
stepValues: [Double],
initialFromValue: Double? = nil,
initialToValue: Double? = nil) {
self.fromValue = fromValue
self.toValue = toValue
self.stepValues = stepValues
self.initialFromValue = initialFromValue
self.initialToValue = initialToValue
}
open func rangeSliderValueIsChanging(_ values: FilterRangeValue) {
filterRangeView?.configureTextFields(with: values)
delegate?.valuesIsChanging(values)
}
open func rangeSliderValueDidEndChanging(_ values: FilterRangeValue) {
filterRangeView?.configureTextFields(with: values)
delegate?.valueDidEndChanging(values)
}
open func fromValueIsChanging(_ values: FilterRangeValue) {
filterRangeView?.configureRangeView(with: values)
delegate?.valueDidEndChanging(values)
}
open func toValueIsChanging(_ values: FilterRangeValue) {
filterRangeView?.configureRangeView(with: values)
delegate?.valueDidEndChanging(values)
}
open func fromValueDidEndChanging(_ values: FilterRangeValue) {
filterRangeView?.configureRangeView(with: values)
delegate?.valueDidEndChanging(values)
}
open func toValueDidEndChanging(_ values: FilterRangeValue) {
filterRangeView?.configureRangeView(with: values)
delegate?.valueDidEndChanging(values)
}
}

View File

@ -0,0 +1,17 @@
public protocol RangeFilterViewModelProtocol {
var fromValue: Double { get }
var toValue: Double { get }
var stepValues: [Double] { get }
var initialFromValue: Double? { get }
var initialToValue: Double? { get }
func rangeSliderValueIsChanging(_ values: FilterRangeValue)
func rangeSliderValueDidEndChanging(_ values: FilterRangeValue)
func toValueIsChanging(_ values: FilterRangeValue)
func toValueDidEndChanging(_ values: FilterRangeValue)
func fromValueDidEndChanging(_ values: FilterRangeValue)
func fromValueIsChanging(_ values: FilterRangeValue)
}

View File

@ -0,0 +1,311 @@
import UIKit
import TIUIElements
import TIUIKitCore
open class BaseFilterRangeView<ViewModel: RangeFilterViewModelProtocol>: BaseInitializableControl,
FilterRangeViewRepresenter {
// MARK: - Private properties
private let layout: RangeFilterLayout
private var contentEdgesConstraints: EdgeConstraints?
private var toTextFieldWidthConstraint: NSLayoutConstraint?
private var fromTextFieldWidthConstraint: NSLayoutConstraint?
private var textFieldsHeightConstraint: NSLayoutConstraint?
// MARK: - Public properties
public let formatter: RangeValuesFormatterProtocol
public let rangeSlider: BaseFilterRangeSlider
public let textFieldsContainer = UIView()
public let fromValueView = BaseIntervalInputView(state: .fromValue)
public let toValueView = BaseIntervalInputView(state: .toValue)
public var viewModel: ViewModel?
// MARK: - Open properties
open var rangeSliderValue: FilterRangeValue {
(rangeSlider.leftValue, rangeSlider.rightValue)
}
open var textFieldsValues: FilterRangeValue {
(min(fromValueView.currentValue, viewModel?.toValue ?? .zero),
max(toValueView.currentValue, viewModel?.fromValue ?? .zero))
}
// MARK: - Text Fields Setup
open var textFieldsBorderColor: UIColor = .black {
didSet {
toValueView.layer.borderColor = textFieldsBorderColor.cgColor
fromValueView.layer.borderColor = textFieldsBorderColor.cgColor
}
}
open var textFieldsBorderWidth: CGFloat = 1 {
didSet {
toValueView.layer.borderWidth = textFieldsBorderWidth
fromValueView.layer.borderWidth = textFieldsBorderWidth
}
}
open var textFieldsHeight: CGFloat = 32 {
didSet {
textFieldsHeightConstraint?.constant = textFieldsHeight
}
}
open var textFieldsContentInsets: UIEdgeInsets = .zero {
didSet {
fromValueView.updateContentInsets(textFieldsContentInsets)
toValueView.updateContentInsets(textFieldsContentInsets)
}
}
open var textFieldsLabelsSpacing: CGFloat = .zero {
didSet {
fromValueView.updateLabelsSpacing(textFieldsLabelsSpacing)
toValueView.updateLabelsSpacing(textFieldsLabelsSpacing)
}
}
open var textFieldsMinWidth: CGFloat = 40 {
didSet {
toTextFieldWidthConstraint?.constant = textFieldsMinWidth
fromTextFieldWidthConstraint?.constant = textFieldsMinWidth
}
}
open var fontColor: UIColor = .black {
didSet {
toValueView.fontColor = fontColor
fromValueView.fontColor = fontColor
rangeSlider.fontColor = fontColor
}
}
open var contentSpacing: CGFloat = .zero {
didSet {
if layout == .textFieldsOnTop {
contentEdgesConstraints?.bottomConstraint.constant -= contentSpacing
} else {
contentEdgesConstraints?.topConstraint.constant += contentSpacing
}
}
}
open var leadingInset: CGFloat = .zero {
didSet {
contentEdgesConstraints?.leadingConstraint.constant += leadingInset
}
}
open var trailingInset: CGFloat = .zero {
didSet {
contentEdgesConstraints?.trailingConstraint.constant -= trailingInset
}
}
open var appearance: DefaultRangeFilterAppearance = .defaultAppearance {
didSet {
updateAppearance()
}
}
// MARK: - Init
public init(layout: RangeFilterLayout = .textFieldsOnTop,
formatter: RangeValuesFormatterProtocol = BaseRangeValuesFormatter(),
viewModel: ViewModel) {
self.viewModel = viewModel
self.layout = layout
self.formatter = formatter
self.rangeSlider = BaseFilterRangeSlider(layout: layout)
super.init(frame: .zero)
}
@available(*, unavailable)
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Life cycle
open override func addViews() {
super.addViews()
textFieldsContainer.addSubviews(fromValueView, toValueView)
addSubviews(textFieldsContainer, rangeSlider)
}
open override func configureLayout() {
super.configureLayout()
[textFieldsContainer, rangeSlider, toValueView, fromValueView]
.forEach { $0.translatesAutoresizingMaskIntoConstraints = false }
switch layout {
case .textFieldsOnTop:
configureTextFieldOnTop()
case .textFieldsFromBelow:
configureTextFieldFromBelow()
}
guard let contentEdgesConstraints = contentEdgesConstraints else {
return
}
let toTextFieldWidthConstraint = toValueView.widthAnchor.constraint(greaterThanOrEqualToConstant: textFieldsMinWidth)
let fromTextFieldWidthConstraint = fromValueView.widthAnchor.constraint(greaterThanOrEqualToConstant: textFieldsMinWidth)
NSLayoutConstraint.activate(
contentEdgesConstraints.allConstraints
+
[
toTextFieldWidthConstraint,
fromTextFieldWidthConstraint,
toValueView.trailingAnchor.constraint(equalTo: textFieldsContainer.trailingAnchor),
toValueView.topAnchor.constraint(equalTo: textFieldsContainer.topAnchor),
toValueView.bottomAnchor.constraint(equalTo: textFieldsContainer.bottomAnchor),
fromValueView.leadingAnchor.constraint(equalTo: textFieldsContainer.leadingAnchor),
fromValueView.topAnchor.constraint(equalTo: textFieldsContainer.topAnchor),
fromValueView.bottomAnchor.constraint(equalTo: textFieldsContainer.bottomAnchor),
rangeSlider.leadingAnchor.constraint(equalTo: textFieldsContainer.leadingAnchor),
rangeSlider.trailingAnchor.constraint(equalTo: textFieldsContainer.trailingAnchor),
layout == .textFieldsOnTop
? rangeSlider.bottomAnchor.constraint(equalTo: bottomAnchor)
: rangeSlider.topAnchor.constraint(equalTo: topAnchor)
]
)
self.toTextFieldWidthConstraint = toTextFieldWidthConstraint
self.fromTextFieldWidthConstraint = fromTextFieldWidthConstraint
}
open override func bindViews() {
super.bindViews()
fromValueView.configure(with: formatter)
toValueView.configure(with: formatter)
rangeSlider.configure(with: formatter)
fromValueView.addTarget(self, valueIsChangedAction: #selector(fromValueIsChanging))
toValueView.addTarget(self, valueIsChangedAction: #selector(toValueIsChanging))
fromValueView.addTarget(self, valueDidEndChangingAction: #selector(fromValueDidEndChanging))
toValueView.addTarget(self, valueDidEndChangingAction: #selector(toValueDidEndChanging))
rangeSlider.addTarget(self,
action: #selector(rangeSliderValueIsChanging),
for: .valueChanged)
rangeSlider.addTarget(self,
action: #selector(rangeSliderValueDidEndChanging),
for: .editingDidEnd)
}
open override func configureAppearance() {
super.configureAppearance()
defaultConfigure()
updateAppearance()
layoutSubviews()
}
// MARK: - Configuration
open func defaultConfigure() {
guard let viewModel = viewModel else {
return
}
fromValueView.configure(with: viewModel.initialFromValue ?? viewModel.fromValue)
toValueView.configure(with: viewModel.initialToValue ?? viewModel.toValue)
rangeSlider.minimumValue = CGFloat(viewModel.fromValue)
rangeSlider.maximumValue = CGFloat(viewModel.toValue)
rangeSlider.leftValue = CGFloat(viewModel.initialFromValue ?? viewModel.fromValue)
rangeSlider.rightValue = CGFloat(viewModel.initialToValue ?? viewModel.toValue)
rangeSlider.addStep(stepValues: viewModel.stepValues.map { CGFloat($0) })
}
// MARK: - FilterRangeViewRepresenter
open func configureTextFields(with values: FilterRangeValue) {
toValueView.configure(with: values.toValue)
fromValueView.configure(with: values.fromValue)
}
open func configureRangeView(with values: FilterRangeValue) {
rangeSlider.configure(with: values)
}
// MARK: - Private methods
private func updateAppearance() {
contentSpacing = appearance.spacing
leadingInset = appearance.leadingSpacing
trailingInset = appearance.trailingSpacing
textFieldsHeight = appearance.textFieldsHeight
textFieldsMinWidth = appearance.textFieldsWidth
textFieldsContentInsets = appearance.textFieldContentInsets
textFieldsLabelsSpacing = appearance.textFieldLabelsSpacing
textFieldsBorderColor = appearance.textFieldsBorderColor
textFieldsBorderWidth = appearance.textFieldsBorderWidth
fontColor = appearance.fontColor
rangeSlider.sliderColor = appearance.sliderColor
rangeSlider.sliderOffColor = appearance.sliderOffColor
rangeSlider.thumbSize = appearance.thumbSize
}
private func configureTextFieldOnTop() {
contentEdgesConstraints = EdgeConstraints(leadingConstraint: textFieldsContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingConstraint: textFieldsContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
topConstraint: textFieldsContainer.topAnchor.constraint(equalTo: topAnchor),
bottomConstraint: textFieldsContainer.bottomAnchor.constraint(equalTo: rangeSlider.topAnchor))
}
private func configureTextFieldFromBelow() {
contentEdgesConstraints = EdgeConstraints(leadingConstraint: textFieldsContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingConstraint: textFieldsContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
topConstraint: textFieldsContainer.topAnchor.constraint(equalTo: rangeSlider.bottomAnchor),
bottomConstraint: textFieldsContainer.bottomAnchor.constraint(equalTo: bottomAnchor))
}
// MARK: - Actions
@objc private func rangeSliderValueIsChanging() {
viewModel?.rangeSliderValueIsChanging(rangeSliderValue)
}
@objc private func rangeSliderValueDidEndChanging() {
viewModel?.rangeSliderValueDidEndChanging(rangeSliderValue)
}
@objc private func fromValueIsChanging() {
viewModel?.fromValueIsChanging(textFieldsValues)
}
@objc private func toValueIsChanging() {
viewModel?.toValueIsChanging(textFieldsValues)
}
@objc private func fromValueDidEndChanging() {
viewModel?.fromValueDidEndChanging(textFieldsValues)
}
@objc private func toValueDidEndChanging() {
viewModel?.toValueDidEndChanging(textFieldsValues)
}
}

View File

@ -0,0 +1,4 @@
public protocol FilterRangeViewRepresenter: AnyObject {
func configureTextFields(with value: FilterRangeValue)
func configureRangeView(with value: FilterRangeValue)
}

View File

@ -0,0 +1,187 @@
import TIUIElements
import TIUIKitCore
import UIKit
open class BaseIntervalInputView: BaseInitializableView, UITextFieldDelegate {
public enum TextFieldState {
case fromValue
case toValue
}
private var contentEdgesConstraints: EdgeConstraints?
private var labelsSpacingConstraint: NSLayoutConstraint?
public let state: TextFieldState
public let intervalLabel = UILabel()
public let inputTextField = UITextField()
public var formatter: RangeValuesFormatterProtocol?
open var intervalTextLabel: String {
switch state {
case .fromValue:
return "от"
case .toValue:
return "до"
}
}
open var validCharacterSet: CharacterSet {
CharacterSet.decimalDigits.union(CharacterSet.init(charactersIn: "."))
}
open var fontColor: UIColor = .black {
didSet {
inputTextField.textColor = fontColor
intervalLabel.textColor = fontColor
}
}
open var intrinsicContentHeight: CGFloat? {
didSet {
invalidateIntrinsicContentSize()
}
}
open var currentValue: Double {
formatter?.double(fromString: inputTextField.text ?? "") ?? 0
}
open override var intrinsicContentSize: CGSize {
if let height = intrinsicContentHeight {
return CGSize(width: UIView.noIntrinsicMetric, height: height)
}
return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}
// MARK: - Init
public init(state: TextFieldState) {
self.state = state
super.init(frame: .zero)
}
@available(*, unavailable)
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Life Cycle
open override func addViews() {
super.addViews()
addSubviews(intervalLabel, inputTextField)
}
open override func configureLayout() {
super.configureLayout()
[intervalLabel, inputTextField].forEach { $0.translatesAutoresizingMaskIntoConstraints = false }
let contentEdgesConstraints = EdgeConstraints(leadingConstraint: intervalLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingConstraint: inputTextField.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
topConstraint: intervalLabel.topAnchor.constraint(equalTo: topAnchor),
bottomConstraint: intervalLabel.bottomAnchor.constraint(equalTo: bottomAnchor))
let labelsSpacingConstraint = inputTextField.leadingAnchor.constraint(equalTo: intervalLabel.trailingAnchor)
NSLayoutConstraint.activate(
contentEdgesConstraints.allConstraints
+
[
labelsSpacingConstraint,
inputTextField.centerYAnchor.constraint(equalTo: intervalLabel.centerYAnchor)
]
)
self.labelsSpacingConstraint = labelsSpacingConstraint
self.contentEdgesConstraints = contentEdgesConstraints
}
open override func configureAppearance() {
super.configureAppearance()
inputTextField.keyboardType = .numberPad
layer.borderColor = UIColor.darkGray.cgColor
layer.borderWidth = 1
layer.round(corners: .allCorners, radius: 8)
}
open override func bindViews() {
super.bindViews()
inputTextField.delegate = self
inputTextField.addTarget(target, action: #selector(formatValue), for: .editingChanged)
}
open override func localize() {
super.localize()
intervalLabel.text = intervalTextLabel
}
open override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
inputTextField.becomeFirstResponder()
}
open func configure(with value: Double) {
inputTextField.text = formatter?.string(fromDouble: value)
}
open func configure(with formatter: RangeValuesFormatterProtocol) {
self.formatter = formatter
}
// MARK: - UITextFieldDelegate
open func textField(_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String) -> Bool {
let characterSet = CharacterSet(charactersIn: string)
textField.undoManager?.removeAllActions()
return validCharacterSet.isSuperset(of: characterSet)
}
// MARK: - Open methods
open func updateContentInsets(_ insets: UIEdgeInsets) {
contentEdgesConstraints?.update(from: insets)
}
open func updateLabelsSpacing(_ spacing: CGFloat) {
labelsSpacingConstraint?.constant += spacing
}
// MARK: - Public methods
public func addTarget(_ target: Any?, valueIsChangedAction: Selector) {
inputTextField.addTarget(target, action: valueIsChangedAction, for: .editingChanged)
}
public func addTarget(_ target: AnyObject, valueDidEndChangingAction: Selector) {
inputTextField.addTarget(target, action: valueDidEndChangingAction, for: .editingDidEnd)
}
// MARK: - Private methods
@objc private func formatValue() {
let onlyDigits = inputTextField.text.orEmpty
.components(separatedBy: validCharacterSet.inverted)
.joined()
let newValue = formatter?.double(fromString: onlyDigits)
inputTextField.text = formatter?.string(fromDouble: newValue ?? 0)
}
}

View File

@ -0,0 +1,210 @@
import TIUIKitCore
import UIKit
open class BaseFilterRangeSlider: StepRangeSlider {
private let layout: RangeFilterLayout
private var stepCircleViews: [UIView] = []
private var stepLabels: [UILabel] = []
private(set) var stepValues: [CGFloat] = []
public var formatter: RangeValuesFormatterProtocol?
open var numberOfSteps: Int {
stepValues.count
}
open var fontColor: UIColor = .black {
didSet {
stepLabels.forEach { $0.textColor = fontColor }
}
}
open var sliderColor: UIColor = .systemOrange {
didSet {
stepCircleInColor = sliderColor
activeTrackColor = sliderColor
thumbColor = sliderColor
}
}
open var sliderOffColor: UIColor = .darkGray {
didSet {
stepCircleOutColor = sliderOffColor
trackColor = sliderOffColor
}
}
open var stepCircleInColor: UIColor = .systemOrange {
didSet {
setNeedsLayout()
}
}
open var stepCircleOutColor: UIColor = .darkGray {
didSet {
setNeedsLayout()
}
}
open var stepCircleSize: CGFloat = 5 {
didSet {
updateStepViewsRadius()
setNeedsLayout()
}
}
open override var intrinsicContentSize: CGSize {
let superSize = super.intrinsicContentSize
guard !stepLabels.isEmpty else {
return superSize
}
return .init(width: superSize.width, height: stepLabels[.zero].frame.maxY)
}
// MARK: - Init
public init(layout: RangeFilterLayout) {
self.layout = layout
super.init(frame: .zero)
}
open override func layoutSubviews() {
super.layoutSubviews()
updateStepCircleViewsPosition()
updateStepLabelsPosition()
updateStepViewsColors()
}
// MARK: - Open methods
open func configure(with values: FilterRangeValue) {
leftValue = CGFloat(values.fromValue)
rightValue = CGFloat(values.toValue)
}
open func configure(with formatter: RangeValuesFormatterProtocol) {
self.formatter = formatter
createStepLabels()
}
open func addStep(stepValues: [CGFloat]) {
self.stepValues = stepValues
createStepCircleViews()
createStepLabels()
updateStepViewsRadius()
updateStepViewsColors()
setNeedsLayout()
layoutIfNeeded()
invalidateIntrinsicContentSize()
}
// MARK: - Private methods
private func createStepCircleViews() {
stepCircleViews.forEach {
$0.removeFromSuperview()
}
stepCircleViews.removeAll()
for _ in .zero ..< numberOfSteps {
let stepCircle = UIView()
stepCircleViews.append(stepCircle)
}
addSubviews(stepCircleViews)
thumbs.forEach {
bringSubviewToFront($0)
}
}
private func updateStepCircleViewsPosition() {
for index in 0 ..< stepCircleViews.count {
guard index >= .zero && index < stepCircleViews.count else {
return
}
let circle = stepCircleViews[index]
let positionX = getPositionX(from: stepValues[index]) + (thumbSize - stepCircleSize) / 2
let positionY = trackView.frame.minY + (trackView.frame.height - stepCircleSize) / 2
circle.frame = .init(x: positionX,
y: positionY,
width: stepCircleSize,
height: stepCircleSize)
}
}
private func updateStepViewsRadius() {
stepCircleViews.forEach {
$0.layer.round(corners: .allCorners, radius: stepCircleSize / 2)
}
}
private func updateStepViewsColors() {
stepCircleViews.forEach {
let leftThumbMaxX = leftThumbView.frame.maxX
let rightThumbMinX = rightThumbView.frame.minX
let isEnterRange = $0.frame.maxX >= leftThumbMaxX && $0.frame.minX <= rightThumbMinX
$0.backgroundColor = isEnterRange ? stepCircleInColor : stepCircleOutColor
}
}
private func createStepLabels() {
stepLabels.forEach {
$0.removeFromSuperview()
}
stepValues.forEach {
let label = UILabel()
let formattedText = formatter?.string(fromDouble: $0)
label.text = formattedText ?? ""
stepLabels.append(label)
}
addSubviews(stepLabels)
}
private func updateStepLabelsPosition() {
for (index, label) in stepLabels.enumerated() {
guard index >= .zero && index < stepCircleViews.count else {
return
}
let circleView = stepCircleViews[index]
let yPosition = layout == .textFieldsOnTop
? circleView.frame.maxY + .stepLabelsTopInset
: circleView.frame.minY - label.bounds.height - .stepLabelsTopInset
label.center.x = circleView.center.x
label.sizeToFit()
label.frame = .init(x: label.frame.minX,
y: yPosition,
width: label.bounds.width,
height: label.bounds.height)
}
}
private func getStepCirclePositionX(_ value: CGFloat) -> CGFloat {
let unit = trackUsingWidth / (maximumValue - minimumValue)
return (value - minimumValue) * unit
}
}
// MARK: - Constant
private extension CGFloat {
static let stepLabelsTopInset: CGFloat = 11
}

View File

@ -0,0 +1,289 @@
import TIUIKitCore
import UIKit
open class StepRangeSlider: UIControl, InitializableViewProtocol {
// MARK: - Public properties
public let leftThumbView = UIView()
public let rightThumbView = UIView()
public let activeTrack = UIView()
public let trackView = UIView()
// MARK: - Open properties
open var disregardedWidth: CGFloat {
thumbSize
}
open var trackUsingWidth: CGFloat {
frame.width - disregardedWidth
}
open var thumbs: [UIView] {
[leftThumbView, rightThumbView]
}
open var leftValue: CGFloat = 0 {
didSet {
if leftValue < minimumValue || leftValue > rightValue {
leftValue = oldValue
}
setNeedsLayout()
}
}
open var rightValue: CGFloat = 1000 {
didSet {
if rightValue < leftValue || rightValue > maximumValue {
rightValue = oldValue
}
setNeedsLayout()
}
}
open var minimumValue: CGFloat = .zero {
didSet {
if minimumValue > maximumValue {
minimumValue = oldValue
}
resetCurrentValues()
setNeedsLayout()
}
}
open var maximumValue: CGFloat = 1000 {
didSet {
if minimumValue > maximumValue && maximumValue <= 0 {
maximumValue = oldValue
}
resetCurrentValues()
setNeedsLayout()
}
}
open var thumbSize: CGFloat = .thumbSize {
didSet {
setNeedsLayout()
}
}
open var trackColor: UIColor = .darkGray {
didSet {
trackView.backgroundColor = trackColor
}
}
open var activeTrackColor: UIColor = .systemOrange {
didSet {
activeTrack.backgroundColor = activeTrackColor
}
}
open var thumbColor: UIColor = .systemOrange {
didSet {
thumbs.forEach {
$0.backgroundColor = thumbColor
}
}
}
open var linePath: CGPath {
let linePath = UIBezierPath()
linePath.move(to: CGPoint(x: .zero, y: bounds.height / 2))
linePath.addLine(to: CGPoint(x: frame.width, y: bounds.height / 2))
return linePath.cgPath
}
open var leftThumbCenterPositionToValue: CGFloat {
let unit = CGFloat(maximumValue) / trackUsingWidth
return leftThumbView.center.y * unit
}
open var rightThumbCenterPositionToValue: CGFloat {
let unit = CGFloat(maximumValue) / trackUsingWidth
return rightThumbView.center.y * unit
}
open override var intrinsicContentSize: CGSize {
.init(width: UIView.noIntrinsicMetric, height: thumbSize)
}
// MARK: - Init
public override init(frame: CGRect) {
super.init(frame: frame)
initializeView()
}
@available(*, unavailable)
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
// MARK: - Life Cycle
open override func layoutSubviews() {
updateTrackViewFrame()
updateThumbsFrame()
updateActiveTrack()
super.layoutSubviews()
}
open func addViews() {
addSubviews(trackView, leftThumbView, rightThumbView)
trackView.addSubviews(activeTrack)
}
open func configureLayout() {
// override
}
open func bindViews() {
thumbs.forEach {
let panGestureRecognizer = UIPanGestureRecognizer(target: self,
action: #selector(draggingThumb(_:)))
$0.addGestureRecognizer(panGestureRecognizer)
}
}
open func configureAppearance() {
trackView.backgroundColor = trackColor
activeTrack.backgroundColor = activeTrackColor
thumbs.forEach {
$0.backgroundColor = thumbColor
}
trackView.layer.round(corners: .allCorners, radius: 2)
updateActiveTrack()
}
open func localize() {
// override
}
// MARK: - Internal Methods
func getPositionX(from value: CGFloat) -> CGFloat {
let unit = trackUsingWidth / (maximumValue - minimumValue)
return (value - minimumValue) * unit
}
// MARK: - Private Methods
@objc private func draggingThumb(_ gesture: UIPanGestureRecognizer) {
guard let view = gesture.view else {
return
}
let translation = gesture.translation(in: self)
switch gesture.state {
case .began:
bringSubviewToFront(view)
case .changed:
draggingChanged(view: view, translation: translation.x)
case .ended:
sendActions(for: .editingDidEnd)
default:
break
}
gesture.setTranslation(.zero, in: self)
}
private func draggingChanged(view: UIView, translation: CGFloat) {
switch view {
case leftThumbView:
handleDragginLeftThumb(translation: translation)
case rightThumbView:
handleDraggingRightThumb(translation: translation)
default:
break
}
sendActions(for: .valueChanged)
}
private func handleDragginLeftThumb(translation: CGFloat) {
let valueTranslation = maximumValue / (trackUsingWidth / translation)
var newLeftValue = max(minimumValue, leftValue + valueTranslation)
newLeftValue = min(newLeftValue, maximumValue)
newLeftValue = min(newLeftValue, rightValue)
leftValue = newLeftValue
}
private func handleDraggingRightThumb(translation: CGFloat) {
let valueTranslation = maximumValue / (trackUsingWidth / translation)
var newRightValue = min(maximumValue, rightValue + valueTranslation)
newRightValue = max(newRightValue, minimumValue)
newRightValue = max(newRightValue, leftValue)
rightValue = newRightValue
}
private func updateActiveTrack() {
activeTrack.frame = .init(x: leftThumbView.frame.maxX,
y: .zero,
width: rightThumbView.frame.minX - leftThumbView.frame.maxX,
height: trackView.frame.height)
}
private func updateThumbsFrame() {
thumbs.forEach {
$0.layer.round(corners: .allCorners, radius: thumbSize / 2)
}
leftThumbView.frame = getLeftThumbRect(from: leftValue)
rightThumbView.frame = getRightThumbRect(from: rightValue)
}
private func updateTrackViewFrame() {
let trackViewHeight: CGFloat = 3
trackView.frame = .init(x: .zero,
y: (thumbSize - trackViewHeight) / 2,
width: bounds.width,
height: trackViewHeight)
}
private func getLeftThumbRect(from value: CGFloat) -> CGRect {
.init(x: getPositionX(from: value),
y: .zero,
width: thumbSize,
height: thumbSize)
}
private func getRightThumbRect(from value: CGFloat) -> CGRect {
.init(x: getPositionX(from: value),
y: .zero,
width: thumbSize,
height: thumbSize)
}
private func resetCurrentValues() {
leftValue = minimumValue
rightValue = maximumValue
}
}
// MARK: - Constant
private extension CGFloat {
static let thumbSize: CGFloat = 21
}

View File

@ -37,6 +37,17 @@ public struct EdgeConstraints {
]
}
public init(leadingConstraint: NSLayoutConstraint,
trailingConstraint: NSLayoutConstraint,
topConstraint: NSLayoutConstraint,
bottomConstraint: NSLayoutConstraint) {
self.leadingConstraint = leadingConstraint
self.trailingConstraint = trailingConstraint
self.topConstraint = topConstraint
self.bottomConstraint = bottomConstraint
}
public func activate() {
NSLayoutConstraint.activate(allConstraints)
}