Merge pull request #324 from TouchInstinct/feature/range_filters
Range filters
This commit is contained in:
commit
3157cac5d5
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
- **Add**: Tag like filter collection view
|
||||
- **ADD**: List like filter table view
|
||||
- **ADD**: Range like filter view
|
||||
|
||||
### 1.26.0
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue