Skip to content

Commit 6156d48

Browse files
Merge pull request #66 from componentskit/UKSlider
UKSlider
2 parents c22d663 + 58e1ad5 commit 6156d48

File tree

4 files changed

+310
-22
lines changed

4 files changed

+310
-22
lines changed

Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SliderPreview.swift

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,20 @@ import SwiftUI
22
import ComponentsKit
33

44
struct SliderPreview: View {
5-
@State private var model = Self.initialModel
6-
@State private var currentValue: CGFloat = Self.initialValue
5+
@State private var model = SliderVM {
6+
$0.style = .light
7+
$0.minValue = 0
8+
$0.maxValue = 100
9+
$0.cornerRadius = .full
10+
}
11+
@State private var currentValue: CGFloat = 30
712

813
var body: some View {
914
VStack {
15+
PreviewWrapper(title: "UIKit") {
16+
UKSlider(initialValue: self.currentValue, model: self.model)
17+
.preview
18+
}
1019
PreviewWrapper(title: "SwiftUI") {
1120
SUSlider(currentValue: self.$currentValue, model: self.model)
1221
}
@@ -30,21 +39,6 @@ struct SliderPreview: View {
3039
}
3140
}
3241
}
33-
34-
// MARK: - Helpers
35-
36-
private static var initialValue: CGFloat {
37-
50
38-
}
39-
40-
private static var initialModel: SliderVM {
41-
var model = SliderVM()
42-
model.style = .light
43-
model.minValue = 0
44-
model.maxValue = 100
45-
model.cornerRadius = .full
46-
return model
47-
}
4842
}
4943

5044
#Preview {

Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ extension SliderVM {
137137
max(self.handleSize.height, self.trackHeight)
138138
}
139139

140-
private func sliderWidth(for totalWidth: CGFloat) -> CGFloat {
140+
func sliderWidth(for totalWidth: CGFloat) -> CGFloat {
141141
max(0, totalWidth - self.handleSize.width - 2 * self.trackSpacing)
142142
}
143143

@@ -156,14 +156,21 @@ extension SliderVM {
156156
// MARK: - UIKit Helpers
157157

158158
extension SliderVM {
159+
var isHandleOverlayVisible: Bool {
160+
switch self.size {
161+
case .small, .medium:
162+
return false
163+
case .large:
164+
return true
165+
}
166+
}
167+
159168
func stripesBezierPath(in rect: CGRect) -> UIBezierPath {
160169
return UIBezierPath(cgPath: self.stripesCGPath(in: rect))
161170
}
162171

163172
func shouldUpdateLayout(_ oldModel: Self) -> Bool {
164-
return self.style != oldModel.style ||
165-
self.size != oldModel.size ||
166-
self.step != oldModel.step
173+
return self.size != oldModel.size
167174
}
168175
}
169176

Sources/ComponentsKit/Components/Slider/SUSlider.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public struct SUSlider: View {
4242
.frame(width: barWidth, height: self.model.trackHeight)
4343

4444
// Handle
45-
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleSize.height))
45+
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleSize.width))
4646
.foregroundStyle(self.model.color.main.color)
4747
.frame(width: self.model.handleSize.width, height: self.model.handleSize.height)
4848
.overlay(
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import AutoLayout
2+
import UIKit
3+
4+
/// A UIKit component that displays a slider.
5+
open class UKSlider: UIView, UKComponent {
6+
// MARK: - Properties
7+
8+
/// A closure that is triggered when the `currentValue` changes.
9+
public var onValueChange: (CGFloat) -> Void
10+
11+
/// A model that defines the appearance properties.
12+
public var model: SliderVM {
13+
didSet {
14+
self.update(oldValue)
15+
}
16+
}
17+
18+
/// The current value of the slider.
19+
public var currentValue: CGFloat {
20+
didSet {
21+
guard self.currentValue != oldValue else { return }
22+
self.updateSliderAppearance()
23+
self.onValueChange(self.currentValue)
24+
}
25+
}
26+
27+
// MARK: - Subviews
28+
29+
/// The background view of the slider track.
30+
public let backgroundView = UIView()
31+
32+
/// The filled portion of the slider track.
33+
public let barView = UIView()
34+
35+
/// A shape layer used to render striped styling.
36+
public let stripedLayer = CAShapeLayer()
37+
38+
/// The draggable handle representing the current value.
39+
public let handleView = UIView()
40+
41+
/// An overlay view for handle for the `large` style.
42+
private let handleOverlayView = UIView()
43+
44+
// MARK: - Layout Constraints
45+
46+
private var barViewConstraints = LayoutConstraints()
47+
private var backgroundViewConstraints = LayoutConstraints()
48+
private var handleViewConstraints = LayoutConstraints()
49+
50+
// MARK: - Private Properties
51+
52+
private var isDragging = false
53+
54+
private var progress: CGFloat {
55+
self.model.progress(for: self.currentValue)
56+
}
57+
58+
// MARK: - UIView Properties
59+
60+
open override var intrinsicContentSize: CGSize {
61+
return self.sizeThatFits(UIView.layoutFittingExpandedSize)
62+
}
63+
64+
// MARK: - Initialization
65+
66+
/// Initializer.
67+
/// - Parameters:
68+
/// - initialValue: The initial slider value. Defaults to `0`.
69+
/// - model: A model that defines the appearance properties.
70+
/// - onValueChange: A closure triggered whenever `currentValue` changes.
71+
public init(
72+
initialValue: CGFloat = 0,
73+
model: SliderVM = .init(),
74+
onValueChange: @escaping (CGFloat) -> Void = { _ in }
75+
) {
76+
self.currentValue = initialValue
77+
self.model = model
78+
self.onValueChange = onValueChange
79+
super.init(frame: .zero)
80+
81+
self.setup()
82+
self.style()
83+
self.layout()
84+
}
85+
86+
public required init?(coder: NSCoder) {
87+
fatalError("init(coder:) has not been implemented")
88+
}
89+
90+
// MARK: - Setup
91+
92+
private func setup() {
93+
self.addSubview(self.backgroundView)
94+
self.addSubview(self.barView)
95+
self.addSubview(self.handleView)
96+
self.backgroundView.layer.addSublayer(self.stripedLayer)
97+
self.handleView.addSubview(self.handleOverlayView)
98+
}
99+
100+
// MARK: - Style
101+
102+
private func style() {
103+
Self.Style.backgroundView(self.backgroundView, model: self.model)
104+
Self.Style.barView(self.barView, model: self.model)
105+
Self.Style.stripedLayer(self.stripedLayer, model: self.model)
106+
Self.Style.handleView(self.handleView, model: self.model)
107+
Self.Style.handleOverlayView(self.handleOverlayView, model: self.model)
108+
}
109+
110+
// MARK: - Update
111+
112+
public func update(_ oldModel: SliderVM) {
113+
guard self.model != oldModel else { return }
114+
115+
self.style()
116+
117+
if self.model.shouldUpdateLayout(oldModel) {
118+
self.barViewConstraints.height?.constant = self.model.trackHeight
119+
self.backgroundViewConstraints.height?.constant = self.model.trackHeight
120+
self.handleViewConstraints.height?.constant = self.model.handleSize.height
121+
self.handleViewConstraints.width?.constant = self.model.handleSize.width
122+
123+
UIView.performWithoutAnimation {
124+
self.layoutIfNeeded()
125+
}
126+
}
127+
128+
self.updateSliderAppearance()
129+
}
130+
131+
private func updateSliderAppearance() {
132+
if self.model.style == .striped {
133+
self.stripedLayer.frame = self.backgroundView.bounds
134+
self.stripedLayer.path = self.model.stripesBezierPath(in: self.stripedLayer.bounds).cgPath
135+
}
136+
137+
let barWidth = self.model.barWidth(for: self.bounds.width, progress: self.progress)
138+
self.barViewConstraints.width?.constant = barWidth
139+
}
140+
141+
// MARK: - Layout
142+
143+
private func layout() {
144+
self.barViewConstraints = .merged {
145+
self.barView.leading()
146+
self.barView.centerVertically()
147+
self.barView.height(self.model.trackHeight)
148+
self.barView.width(0)
149+
}
150+
151+
self.backgroundViewConstraints = .merged {
152+
self.backgroundView.trailing()
153+
self.backgroundView.centerVertically()
154+
self.backgroundView.height(self.model.trackHeight)
155+
}
156+
157+
self.handleViewConstraints = .merged {
158+
self.handleView.after(self.barView, padding: self.model.trackSpacing)
159+
self.handleView.before(self.backgroundView, padding: self.model.trackSpacing)
160+
self.handleView.size(
161+
width: self.model.handleSize.width,
162+
height: self.model.handleSize.height
163+
)
164+
self.handleView.centerVertically()
165+
}
166+
167+
self.handleOverlayView.center()
168+
self.handleOverlayView.size(
169+
width: self.model.handleOverlaySide,
170+
height: self.model.handleOverlaySide
171+
)
172+
}
173+
174+
open override func layoutSubviews() {
175+
super.layoutSubviews()
176+
177+
self.backgroundView.layer.cornerRadius =
178+
self.model.cornerRadius(for: self.backgroundView.bounds.height)
179+
180+
self.barView.layer.cornerRadius =
181+
self.model.cornerRadius(for: self.barView.bounds.height)
182+
183+
self.handleView.layer.cornerRadius =
184+
self.model.cornerRadius(for: self.handleView.bounds.width)
185+
186+
self.handleOverlayView.layer.cornerRadius =
187+
self.model.cornerRadius(for: self.handleOverlayView.bounds.width)
188+
189+
self.updateSliderAppearance()
190+
self.model.validateMinMaxValues()
191+
}
192+
193+
// MARK: - UIView Methods
194+
195+
open override func sizeThatFits(_ size: CGSize) -> CGSize {
196+
let width = self.superview?.bounds.width ?? size.width
197+
return CGSize(
198+
width: min(size.width, width),
199+
height: min(size.height, self.model.handleSize.height)
200+
)
201+
}
202+
203+
open override func touchesBegan(
204+
_ touches: Set<UITouch>,
205+
with event: UIEvent?
206+
) {
207+
guard let point = touches.first?.location(in: self),
208+
self.hitTest(point, with: nil) == self.handleView
209+
else { return }
210+
211+
self.isDragging = true
212+
}
213+
214+
open override func touchesMoved(
215+
_ touches: Set<UITouch>,
216+
with event: UIEvent?
217+
) {
218+
guard self.isDragging,
219+
let translation = touches.first?.location(in: self)
220+
else { return }
221+
222+
let totalWidth = self.bounds.width
223+
let sliderWidth = max(0, totalWidth - self.model.handleSize.width - 2 * self.model.trackSpacing)
224+
225+
let newOffset = translation.x - self.model.trackSpacing - self.model.handleSize.width / 2
226+
let clampedOffset = min(max(newOffset, 0), sliderWidth)
227+
228+
self.currentValue = self.model.steppedValue(for: clampedOffset, trackWidth: sliderWidth)
229+
}
230+
231+
open override func touchesEnded(
232+
_ touches: Set<UITouch>,
233+
with event: UIEvent?
234+
) {
235+
self.isDragging = false
236+
}
237+
238+
open override func touchesCancelled(
239+
_ touches: Set<UITouch>,
240+
with event: UIEvent?
241+
) {
242+
self.isDragging = false
243+
}
244+
}
245+
246+
// MARK: - Style Helpers
247+
248+
extension UKSlider {
249+
fileprivate enum Style {
250+
static func backgroundView(_ view: UIView, model: SliderVM) {
251+
view.backgroundColor = model.color.background.uiColor
252+
if model.style == .striped {
253+
view.backgroundColor = .clear
254+
}
255+
view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height)
256+
view.layer.masksToBounds = true
257+
}
258+
259+
static func barView(_ view: UIView, model: SliderVM) {
260+
view.backgroundColor = model.color.main.uiColor
261+
view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height)
262+
view.layer.masksToBounds = true
263+
}
264+
265+
static func stripedLayer(_ layer: CAShapeLayer, model: SliderVM) {
266+
layer.fillColor = model.color.main.uiColor.cgColor
267+
switch model.style {
268+
case .light:
269+
layer.isHidden = true
270+
case .striped:
271+
layer.isHidden = false
272+
}
273+
}
274+
275+
static func handleView(_ view: UIView, model: SliderVM) {
276+
view.backgroundColor = model.color.main.uiColor
277+
view.layer.cornerRadius = model.cornerRadius(for: model.handleSize.width)
278+
view.layer.masksToBounds = true
279+
}
280+
281+
static func handleOverlayView(_ view: UIView, model: SliderVM) {
282+
view.isVisible = model.isHandleOverlayVisible
283+
view.backgroundColor = model.color.contrast.uiColor
284+
view.layer.cornerRadius = model.cornerRadius(for: model.handleOverlaySide)
285+
}
286+
}
287+
}

0 commit comments

Comments
 (0)