Merge pull request #324 from TouchInstinct/feature/range_filters

Range filters
This commit is contained in:
Nikita Semenov 2022-10-11 08:29:11 +03:00 committed by GitHub
commit 3157cac5d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1553 additions and 0 deletions

View File

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

View File

@ -0,0 +1,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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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<ViewModel: RangeFilterViewModelProtocol>: BaseInitializableControl,
FilterRangeViewRepresenter {
// MARK: - Private properties
private let layout: RangeFilterLayout
private var contentEdgesConstraints: EdgeConstraints?
private var toTextFieldWidthConstraint: NSLayoutConstraint?
private var fromTextFieldWidthConstraint: NSLayoutConstraint?
private var textFieldsHeightConstraint: NSLayoutConstraint?
// MARK: - Public properties
public let formatter: RangeValuesFormatterProtocol
public let rangeSlider: BaseFilterRangeSlider
public let textFieldsContainer = UIView()
public let fromValueView = BaseIntervalInputView(state: .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)
}
}

View File

@ -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)
}

View File

@ -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<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
inputTextField.becomeFirstResponder()
}
open func configure(with value: Double) {
inputTextField.text = formatter?.string(fromDouble: value)
}
open func configure(with formatter: RangeValuesFormatterProtocol) {
self.formatter = formatter
}
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)
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

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