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/DefaultIntervalInputAppearance.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/DefaultIntervalInputAppearance.swift new file mode 100644 index 00000000..e96f390f --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/DefaultIntervalInputAppearance.swift @@ -0,0 +1,48 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import UIKit + +public struct DefaultIntervalInputAppearance { + + 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 init(textFieldsHeight: CGFloat = 32, + textFieldsWidth: CGFloat = 100, + textFieldLabelsSpacing: CGFloat = 4, + textFieldContentInsets: UIEdgeInsets = .init(top: 4, left: 8, bottom: 4, right: 8), + textFieldsBorderColor: UIColor = .black, + textFieldsBorderWidth: CGFloat = 1) { + + self.textFieldsHeight = textFieldsHeight + self.textFieldsWidth = textFieldsWidth + self.textFieldLabelsSpacing = textFieldLabelsSpacing + self.textFieldContentInsets = textFieldContentInsets + self.textFieldsBorderColor = textFieldsBorderColor + self.textFieldsBorderWidth = textFieldsBorderWidth + } +} 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..84cde2d0 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/DefaultRangeFilterAppearance.swift @@ -0,0 +1,49 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import UIKit + +public struct DefaultRangeFilterAppearance { + + public var intervalInputAppearance: DefaultIntervalInputAppearance + public var stepSliderAppearance: DefaultStepSliderAppearance + + public var spacing: CGFloat + public var leadingSpacing: CGFloat + public var trailingSpacing: CGFloat + public var fontColor: UIColor + + public init(intervalInputAppearance: DefaultIntervalInputAppearance = .init(), + stepSliderAppearance: DefaultStepSliderAppearance = .init(), + spacing: CGFloat = 16, + leadingSpacing: CGFloat = 16, + trailingSpacing: CGFloat = 16, + fontColor: UIColor = .black) { + + self.intervalInputAppearance = intervalInputAppearance + self.stepSliderAppearance = stepSliderAppearance + self.spacing = spacing + self.leadingSpacing = leadingSpacing + self.trailingSpacing = trailingSpacing + self.fontColor = fontColor + } +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/DefaultStepSliderAppearance.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/DefaultStepSliderAppearance.swift new file mode 100644 index 00000000..5d3d25ea --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/DefaultStepSliderAppearance.swift @@ -0,0 +1,42 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import UIKit + +public struct DefaultStepSliderAppearance { + + public var sliderColor: UIColor + public var sliderOffColor: UIColor + public var thumbSize: CGFloat + public var stepLabelsOffset: CGFloat + + public init(sliderColor: UIColor = .cyan, + sliderOffColor: UIColor = .darkGray, + thumbSize: CGFloat = 21, + stepLabelsOffset: CGFloat = 11) { + + self.sliderColor = sliderColor + self.sliderOffColor = sliderOffColor + self.thumbSize = thumbSize + self.stepLabelsOffset = stepLabelsOffset + } +} 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..f07075ae --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Appearance/RangeFilterLayout.swift @@ -0,0 +1,26 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +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..647211b4 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Formatter/BaseRangeValuesFormatter.swift @@ -0,0 +1,58 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +open class BaseRangeValuesFormatter: RangeValuesFormatterProtocol { + + public let formatter: NumberFormatter + + public var floatValueDelimiter = "." + + public var formattingFailureValue = 0.0 + + public init() { + formatter = NumberFormatter() + + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 1 + } + + open func getIntervalInputLabel(state: RangeBoundSide) -> String { + switch state { + case .lower: + return "от" + case .upper: + return "до" + } + } + + open func string(fromDouble value: Double) -> String { + let nsNumber = NSNumber(floatLiteral: value) + + return formatter.string(from: nsNumber) ?? "\(formattingFailureValue)" + } + + open func double(fromString value: String) -> Double { + formatter.number(from: value)?.doubleValue ?? formattingFailureValue + } +} 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..22915c1b --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Models/Formatter/RangeValuesFormatterProtocol.swift @@ -0,0 +1,30 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +public protocol RangeValuesFormatterProtocol { + var floatValueDelimiter: String { get } + + func getIntervalInputLabel(state: RangeBoundSide) -> String + + func string(fromDouble value: Double) -> String + func double(fromString value: String) -> Double +} diff --git a/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Protocols/RangeFiltersPickerDelegate.swift b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Protocols/RangeFiltersPickerDelegate.swift new file mode 100644 index 00000000..44ac121a --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Protocols/RangeFiltersPickerDelegate.swift @@ -0,0 +1,26 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +public protocol RangeFiltersPickerDelegate: 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..ce1722c1 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/ViewModels/BaseRangeFilterViewModel.swift @@ -0,0 +1,95 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import CoreGraphics +import Foundation +import TISwiftUtils + +public typealias FilterRangeValue = (fromValue: CGFloat, toValue: CGFloat) + +open class BaseRangeFilterViewModel: RangeFilterViewModelProtocol { + + public let fromValue: CGFloat + public let toValue: CGFloat + public let stepValues: [CGFloat] + + public var initialFromValue: CGFloat? + public var initialToValue: CGFloat? + + public weak var filterRangeView: FilterRangeViewRepresenter? + public weak var pickerDelegate: RangeFiltersPickerDelegate? + + open var initialValues: FilterRangeValue { + (initialFromValue ?? fromValue, initialToValue ?? toValue) + } + + open var isChanged: Bool { + initialFromValue != fromValue || initialToValue != toValue + } + + public init(fromValue: CGFloat, + toValue: CGFloat, + stepValues: [CGFloat], + initialFromValue: CGFloat? = nil, + initialToValue: CGFloat? = 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) + pickerDelegate?.valuesIsChanging(values) + } + + open func rangeSliderValueDidEndChanging(_ values: FilterRangeValue) { + filterRangeView?.configureTextFields(with: values) + pickerDelegate?.valueDidEndChanging(values) + } + + open func intervalInputValueIsChanging(_ values: FilterRangeValue, side: RangeBoundSide) { + switch side { + case .lower: + filterRangeView?.configureRangeView(with: values) + pickerDelegate?.valueDidEndChanging(values) + + case .upper: + filterRangeView?.configureRangeView(with: values) + pickerDelegate?.valueDidEndChanging(values) + } + } + + open func intervalInputValueDidEndChanging(_ values: FilterRangeValue, side: RangeBoundSide) { + switch side { + case .lower: + filterRangeView?.configureRangeView(with: values) + pickerDelegate?.valueDidEndChanging(values) + + case .upper: + filterRangeView?.configureRangeView(with: values) + pickerDelegate?.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..a8ae07e5 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/ViewModels/RangeFilterViewModelProtocol.swift @@ -0,0 +1,38 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import CoreGraphics + +public protocol RangeFilterViewModelProtocol { + + var fromValue: CGFloat { get } + var toValue: CGFloat { get } + var stepValues: [CGFloat] { get } + var initialFromValue: CGFloat? { get } + var initialToValue: CGFloat? { get } + + func rangeSliderValueIsChanging(_ values: FilterRangeValue) + func rangeSliderValueDidEndChanging(_ values: FilterRangeValue) + + func intervalInputValueIsChanging(_ values: FilterRangeValue, side: RangeBoundSide) + func intervalInputValueDidEndChanging(_ values: FilterRangeValue, side: RangeBoundSide) +} 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..cfb123f1 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/FilterRangeView/BaseFilterRangeView.swift @@ -0,0 +1,349 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +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: .lower) + public let toValueView = BaseIntervalInputView(state: .upper) + + public var viewModel: ViewModel? + + // MARK: - Open properties + + open var rangeSliderValue: FilterRangeValue { + (fromValue: rangeSlider.leftValue, toValue: rangeSlider.rightValue) + } + + open var textFieldsValues: FilterRangeValue { + ( + fromValue: min(fromValueView.currentValue, viewModel?.toValue ?? .zero), + toValue: 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 = .init() { + 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) + + let fromTextFieldConstraints = [ + fromTextFieldWidthConstraint, + fromValueView.leadingAnchor.constraint(equalTo: textFieldsContainer.leadingAnchor), + fromValueView.topAnchor.constraint(equalTo: textFieldsContainer.topAnchor), + fromValueView.bottomAnchor.constraint(equalTo: textFieldsContainer.bottomAnchor), + ] + + let toTextFieldConstraints = [ + toTextFieldWidthConstraint, + toValueView.trailingAnchor.constraint(equalTo: textFieldsContainer.trailingAnchor), + toValueView.topAnchor.constraint(equalTo: textFieldsContainer.topAnchor), + toValueView.bottomAnchor.constraint(equalTo: textFieldsContainer.bottomAnchor), + ] + + let sliderConstraints = [ + rangeSlider.leadingAnchor.constraint(equalTo: textFieldsContainer.leadingAnchor), + rangeSlider.trailingAnchor.constraint(equalTo: textFieldsContainer.trailingAnchor), + layout == .textFieldsOnTop + ? rangeSlider.bottomAnchor.constraint(equalTo: bottomAnchor) + : rangeSlider.topAnchor.constraint(equalTo: topAnchor) + ] + + NSLayoutConstraint.activate( + contentEdgesConstraints.allConstraints + + + fromTextFieldConstraints + + + toTextFieldConstraints + + + sliderConstraints + ) + + 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) + + defaultConfigure() + } + + open override func configureAppearance() { + super.configureAppearance() + + 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 = viewModel.fromValue + rangeSlider.maximumValue = viewModel.toValue + + rangeSlider.leftValue = viewModel.initialFromValue ?? viewModel.fromValue + rangeSlider.rightValue = viewModel.initialToValue ?? viewModel.toValue + + rangeSlider.addStep(stepValues: viewModel.stepValues) + } + + // 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() { + let intervalAppearance = appearance.intervalInputAppearance + let sliderAppearance = appearance.stepSliderAppearance + + contentSpacing = appearance.spacing + leadingInset = appearance.leadingSpacing + trailingInset = appearance.trailingSpacing + fontColor = appearance.fontColor + + textFieldsHeight = intervalAppearance.textFieldsHeight + textFieldsMinWidth = intervalAppearance.textFieldsWidth + textFieldsContentInsets = intervalAppearance.textFieldContentInsets + textFieldsLabelsSpacing = intervalAppearance.textFieldLabelsSpacing + textFieldsBorderColor = intervalAppearance.textFieldsBorderColor + textFieldsBorderWidth = intervalAppearance.textFieldsBorderWidth + + rangeSlider.sliderColor = sliderAppearance.sliderColor + rangeSlider.sliderOffColor = sliderAppearance.sliderOffColor + rangeSlider.thumbSize = sliderAppearance.thumbSize + rangeSlider.stepLabelsOffset = sliderAppearance.stepLabelsOffset + } + + 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?.intervalInputValueIsChanging(textFieldsValues, side: .lower) + } + + @objc private func toValueIsChanging() { + viewModel?.intervalInputValueIsChanging(textFieldsValues, side: .upper) + } + + @objc private func fromValueDidEndChanging() { + viewModel?.intervalInputValueDidEndChanging(textFieldsValues, side: .lower) + } + + @objc private func toValueDidEndChanging() { + viewModel?.intervalInputValueDidEndChanging(textFieldsValues, side: .upper) + } +} 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..cd9e41ed --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/FilterRangeView/FilterRangeViewRepresenter.swift @@ -0,0 +1,26 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +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..4002abd7 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/IntervalInputView/BaseIntervalInputView.swift @@ -0,0 +1,209 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import TIUIElements +import TIUIKitCore +import UIKit + +public enum RangeBoundSide { + case lower + case upper +} + +open class BaseIntervalInputView: BaseInitializableView, UITextFieldDelegate { + + private var contentEdgesConstraints: EdgeConstraints? + private var labelsSpacingConstraint: NSLayoutConstraint? + + public let state: RangeBoundSide + + public let intervalLabel = UILabel() + public let inputTextField = UITextField() + + public var formatter: RangeValuesFormatterProtocol? { + didSet { + updateState() + } + } + + open lazy var validCharacterSet: CharacterSet = { + CharacterSet + .decimalDigits + .union(CharacterSet(charactersIn: formatter?.floatValueDelimiter ?? ".")) + }() + + open var fontColor: UIColor = .black { + didSet { + inputTextField.textColor = fontColor + intervalLabel.textColor = fontColor + } + } + + open var intrinsicContentHeight: CGFloat? { + didSet { + invalidateIntrinsicContentSize() + } + } + + open var currentValue: Double { + guard let formatter = formatter, + let inputText = inputTextField.text else { + return 0 + } + + return formatter.double(fromString: inputText) + } + + 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: RangeBoundSide) { + 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 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 + } + + open func updateState() { + intervalLabel.text = formatter?.getIntervalInputLabel(state: state) + } + + // 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..33806717 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/SliderView/BaseFilterRangeSlider.swift @@ -0,0 +1,234 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +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 var stepLabelsOffset: CGFloat = 0 { + didSet { + 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() { + guard let formatter = formatter else { return } + + 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 + stepLabelsOffset + : circleView.frame.minY - label.bounds.height - stepLabelsOffset + + 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 + } +} 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..508c3fe7 --- /dev/null +++ b/TIEcommerce/Sources/Filters/FiltersViews/RangeFilters/Views/SliderView/StepRangeSlider.swift @@ -0,0 +1,311 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +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) }