From 116d2154f8b06a7a8ffec6842b51bdeaf9c4aff1 Mon Sep 17 00:00:00 2001 From: Nikita Semenov Date: Wed, 21 Sep 2022 17:50:13 +0300 Subject: [PATCH] feat: range filters view --- CHANGELOG.md | 1 + .../DefaultRangeFilterAppearance.swift | 62 ++++ .../Models/Appearance/RangeFilterLayout.swift | 4 + .../Formatter/BaseRangeValuesFormatter.swift | 25 ++ .../RangeValuesFormatterProtocol.swift | 4 + .../Protocols/RangeFilterDelegate.swift | 4 + .../ViewModels/BaseRangeFilterViewModel.swift | 69 ++++ .../RangeFilterViewModelProtocol.swift | 17 + .../FilterRangeView/BaseFilterRangeView.swift | 311 ++++++++++++++++++ .../FilterRangeViewRepresenter.swift | 4 + .../BaseIntervalInputView.swift | 187 +++++++++++ .../SliderView/BaseFilterRangeSlider.swift | 210 ++++++++++++ .../Views/SliderView/StepRangeSlider.swift | 289 ++++++++++++++++ .../Sources/Wrappers/EdgeConstraints.swift | 11 + 14 files changed, 1198 insertions(+) create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/DefaultRangeFilterAppearance.swift create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/RangeFilterLayout.swift create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Formatter/BaseRangeValuesFormatter.swift create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Formatter/RangeValuesFormatterProtocol.swift create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Protocols/RangeFilterDelegate.swift create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/ViewModels/BaseRangeFilterViewModel.swift create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/ViewModels/RangeFilterViewModelProtocol.swift create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/FilterRangeView/BaseFilterRangeView.swift create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/FilterRangeView/FilterRangeViewRepresenter.swift create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/IntervalInputView/BaseIntervalInputView.swift create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/SliderView/BaseFilterRangeSlider.swift create mode 100644 TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/SliderView/StepRangeSlider.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f99da122..c99e1605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - **Add**: Tag like filter collection view - **ADD**: List like filter table view +- **ADD**: Range like filter view ### 1.26.0 diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/DefaultRangeFilterAppearance.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/DefaultRangeFilterAppearance.swift new file mode 100644 index 00000000..14ab70e6 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/DefaultRangeFilterAppearance.swift @@ -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)) + } +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/RangeFilterLayout.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/RangeFilterLayout.swift new file mode 100644 index 00000000..449cb178 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/RangeFilterLayout.swift @@ -0,0 +1,4 @@ +public enum RangeFilterLayout { + case textFieldsOnTop + case textFieldsFromBelow +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Formatter/BaseRangeValuesFormatter.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Formatter/BaseRangeValuesFormatter.swift new file mode 100644 index 00000000..afc1e68c --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Formatter/BaseRangeValuesFormatter.swift @@ -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 + } +} \ No newline at end of file diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Formatter/RangeValuesFormatterProtocol.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Formatter/RangeValuesFormatterProtocol.swift new file mode 100644 index 00000000..4f915588 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Formatter/RangeValuesFormatterProtocol.swift @@ -0,0 +1,4 @@ +public protocol RangeValuesFormatterProtocol { + func string(fromDouble value: Double) -> String + func double(fromString value: String) -> Double +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Protocols/RangeFilterDelegate.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Protocols/RangeFilterDelegate.swift new file mode 100644 index 00000000..b2d9c89d --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Protocols/RangeFilterDelegate.swift @@ -0,0 +1,4 @@ +public protocol RangeFilterDelegate: AnyObject { + func valuesIsChanging(_ value: FilterRangeValue) + func valueDidEndChanging(_ value: FilterRangeValue) +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/ViewModels/BaseRangeFilterViewModel.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/ViewModels/BaseRangeFilterViewModel.swift new file mode 100644 index 00000000..3fb95107 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/ViewModels/BaseRangeFilterViewModel.swift @@ -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) + } +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/ViewModels/RangeFilterViewModelProtocol.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/ViewModels/RangeFilterViewModelProtocol.swift new file mode 100644 index 00000000..e614f9c3 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/ViewModels/RangeFilterViewModelProtocol.swift @@ -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) +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/FilterRangeView/BaseFilterRangeView.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/FilterRangeView/BaseFilterRangeView.swift new file mode 100644 index 00000000..01b34987 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/FilterRangeView/BaseFilterRangeView.swift @@ -0,0 +1,311 @@ +import UIKit +import TIUIElements +import TIUIKitCore + +open class BaseFilterRangeView: 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) + } +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/FilterRangeView/FilterRangeViewRepresenter.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/FilterRangeView/FilterRangeViewRepresenter.swift new file mode 100644 index 00000000..849e8b8a --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/FilterRangeView/FilterRangeViewRepresenter.swift @@ -0,0 +1,4 @@ +public protocol FilterRangeViewRepresenter: AnyObject { + func configureTextFields(with value: FilterRangeValue) + func configureRangeView(with value: FilterRangeValue) +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/IntervalInputView/BaseIntervalInputView.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/IntervalInputView/BaseIntervalInputView.swift new file mode 100644 index 00000000..1bcf8c04 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/IntervalInputView/BaseIntervalInputView.swift @@ -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, 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) + } +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/SliderView/BaseFilterRangeSlider.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/SliderView/BaseFilterRangeSlider.swift new file mode 100644 index 00000000..3d27e16d --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/SliderView/BaseFilterRangeSlider.swift @@ -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 +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/SliderView/StepRangeSlider.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/SliderView/StepRangeSlider.swift new file mode 100644 index 00000000..38faa578 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/SliderView/StepRangeSlider.swift @@ -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 +} diff --git a/TIUIElements/Sources/Wrappers/EdgeConstraints.swift b/TIUIElements/Sources/Wrappers/EdgeConstraints.swift index 0274afd8..cccfdd8f 100644 --- a/TIUIElements/Sources/Wrappers/EdgeConstraints.swift +++ b/TIUIElements/Sources/Wrappers/EdgeConstraints.swift @@ -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) }