Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@ import SwiftUI
import ComponentsKit

struct SliderPreview: View {
@State private var model = Self.initialModel
@State private var currentValue: CGFloat = Self.initialValue
@State private var model = SliderVM {
$0.style = .light
$0.minValue = 0
$0.maxValue = 100
$0.cornerRadius = .full
}
@State private var currentValue: CGFloat = 30

var body: some View {
VStack {
PreviewWrapper(title: "UIKit") {
UKSlider(initialValue: self.currentValue, model: self.model)
.preview
}
PreviewWrapper(title: "SwiftUI") {
SUSlider(currentValue: self.$currentValue, model: self.model)
}
Expand All @@ -30,21 +39,6 @@ struct SliderPreview: View {
}
}
}

// MARK: - Helpers

private static var initialValue: CGFloat {
50
}

private static var initialModel: SliderVM {
var model = SliderVM()
model.style = .light
model.minValue = 0
model.maxValue = 100
model.cornerRadius = .full
return model
}
}

#Preview {
Expand Down
15 changes: 11 additions & 4 deletions Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ extension SliderVM {
max(self.handleSize.height, self.trackHeight)
}

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

Expand All @@ -156,14 +156,21 @@ extension SliderVM {
// MARK: - UIKit Helpers

extension SliderVM {
var isHandleOverlayVisible: Bool {
switch self.size {
case .small, .medium:
return false
case .large:
return true
}
}

func stripesBezierPath(in rect: CGRect) -> UIBezierPath {
return UIBezierPath(cgPath: self.stripesCGPath(in: rect))
}

func shouldUpdateLayout(_ oldModel: Self) -> Bool {
return self.style != oldModel.style ||
self.size != oldModel.size ||
self.step != oldModel.step
return self.size != oldModel.size
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/ComponentsKit/Components/Slider/SUSlider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public struct SUSlider: View {
.frame(width: barWidth, height: self.model.trackHeight)

// Handle
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleSize.height))
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleSize.width))
.foregroundStyle(self.model.color.main.color)
.frame(width: self.model.handleSize.width, height: self.model.handleSize.height)
.overlay(
Expand Down
287 changes: 287 additions & 0 deletions Sources/ComponentsKit/Components/Slider/UKSlider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import AutoLayout
import UIKit

/// A UIKit component that displays a slider.
open class UKSlider: UIView, UKComponent {
// MARK: - Properties

/// A closure that is triggered when the `currentValue` changes.
public var onValueChange: (CGFloat) -> Void

/// A model that defines the appearance properties.
public var model: SliderVM {
didSet {
self.update(oldValue)
}
}

/// The current value of the slider.
public var currentValue: CGFloat {
didSet {
guard self.currentValue != oldValue else { return }
self.updateSliderAppearance()
self.onValueChange(self.currentValue)
}
}

// MARK: - Subviews

/// The background view of the slider track.
public let backgroundView = UIView()

/// The filled portion of the slider track.
public let barView = UIView()

/// A shape layer used to render striped styling.
public let stripedLayer = CAShapeLayer()

/// The draggable handle representing the current value.
public let handleView = UIView()

/// An overlay view for handle for the `large` style.
private let handleOverlayView = UIView()

// MARK: - Layout Constraints

private var barViewConstraints = LayoutConstraints()
private var backgroundViewConstraints = LayoutConstraints()
private var handleViewConstraints = LayoutConstraints()

// MARK: - Private Properties

private var isDragging = false

private var progress: CGFloat {
self.model.progress(for: self.currentValue)
}

// MARK: - UIView Properties

open override var intrinsicContentSize: CGSize {
return self.sizeThatFits(UIView.layoutFittingExpandedSize)
}

// MARK: - Initialization

/// Initializer.
/// - Parameters:
/// - initialValue: The initial slider value. Defaults to `0`.
/// - model: A model that defines the appearance properties.
/// - onValueChange: A closure triggered whenever `currentValue` changes.
public init(
initialValue: CGFloat = 0,
model: SliderVM = .init(),
onValueChange: @escaping (CGFloat) -> Void = { _ in }
) {
self.currentValue = initialValue
self.model = model
self.onValueChange = onValueChange
super.init(frame: .zero)

self.setup()
self.style()
self.layout()
}

public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - Setup

private func setup() {
self.addSubview(self.backgroundView)
self.addSubview(self.barView)
self.addSubview(self.handleView)
self.backgroundView.layer.addSublayer(self.stripedLayer)
self.handleView.addSubview(self.handleOverlayView)
}

// MARK: - Style

private func style() {
Self.Style.backgroundView(self.backgroundView, model: self.model)
Self.Style.barView(self.barView, model: self.model)
Self.Style.stripedLayer(self.stripedLayer, model: self.model)
Self.Style.handleView(self.handleView, model: self.model)
Self.Style.handleOverlayView(self.handleOverlayView, model: self.model)
}

// MARK: - Update

public func update(_ oldModel: SliderVM) {
guard self.model != oldModel else { return }

self.style()

if self.model.shouldUpdateLayout(oldModel) {
self.barViewConstraints.height?.constant = self.model.trackHeight
self.backgroundViewConstraints.height?.constant = self.model.trackHeight
self.handleViewConstraints.height?.constant = self.model.handleSize.height
self.handleViewConstraints.width?.constant = self.model.handleSize.width

UIView.performWithoutAnimation {
self.layoutIfNeeded()
}
}

self.updateSliderAppearance()
}

private func updateSliderAppearance() {
if self.model.style == .striped {
self.stripedLayer.frame = self.backgroundView.bounds
self.stripedLayer.path = self.model.stripesBezierPath(in: self.stripedLayer.bounds).cgPath
}

let barWidth = self.model.barWidth(for: self.bounds.width, progress: self.progress)
self.barViewConstraints.width?.constant = barWidth
}

// MARK: - Layout

private func layout() {
self.barViewConstraints = .merged {
self.barView.leading()
self.barView.centerVertically()
self.barView.height(self.model.trackHeight)
self.barView.width(0)
}

self.backgroundViewConstraints = .merged {
self.backgroundView.trailing()
self.backgroundView.centerVertically()
self.backgroundView.height(self.model.trackHeight)
}

self.handleViewConstraints = .merged {
self.handleView.after(self.barView, padding: self.model.trackSpacing)
self.handleView.before(self.backgroundView, padding: self.model.trackSpacing)
self.handleView.size(
width: self.model.handleSize.width,
height: self.model.handleSize.height
)
self.handleView.centerVertically()
}

self.handleOverlayView.center()
self.handleOverlayView.size(
width: self.model.handleOverlaySide,
height: self.model.handleOverlaySide
)
}

open override func layoutSubviews() {
super.layoutSubviews()

self.backgroundView.layer.cornerRadius =
self.model.cornerRadius(for: self.backgroundView.bounds.height)

self.barView.layer.cornerRadius =
self.model.cornerRadius(for: self.barView.bounds.height)

self.handleView.layer.cornerRadius =
self.model.cornerRadius(for: self.handleView.bounds.width)

self.handleOverlayView.layer.cornerRadius =
self.model.cornerRadius(for: self.handleOverlayView.bounds.width)

self.updateSliderAppearance()
self.model.validateMinMaxValues()
}

// MARK: - UIView Methods

open override func sizeThatFits(_ size: CGSize) -> CGSize {
let width = self.superview?.bounds.width ?? size.width
return CGSize(
width: min(size.width, width),
height: min(size.height, self.model.handleSize.height)
)
}

open override func touchesBegan(
_ touches: Set<UITouch>,
with event: UIEvent?
) {
guard let point = touches.first?.location(in: self),
self.hitTest(point, with: nil) == self.handleView
else { return }

self.isDragging = true
}

open override func touchesMoved(
_ touches: Set<UITouch>,
with event: UIEvent?
) {
guard self.isDragging,
let translation = touches.first?.location(in: self)
else { return }

let totalWidth = self.bounds.width
let sliderWidth = max(0, totalWidth - self.model.handleSize.width - 2 * self.model.trackSpacing)

let newOffset = translation.x - self.model.trackSpacing - self.model.handleSize.width / 2
let clampedOffset = min(max(newOffset, 0), sliderWidth)

self.currentValue = self.model.steppedValue(for: clampedOffset, trackWidth: sliderWidth)
}

open override func touchesEnded(
_ touches: Set<UITouch>,
with event: UIEvent?
) {
self.isDragging = false
}

open override func touchesCancelled(
_ touches: Set<UITouch>,
with event: UIEvent?
) {
self.isDragging = false
}
}

// MARK: - Style Helpers

extension UKSlider {
fileprivate enum Style {
static func backgroundView(_ view: UIView, model: SliderVM) {
view.backgroundColor = model.color.background.uiColor
if model.style == .striped {
view.backgroundColor = .clear
}
view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height)
view.layer.masksToBounds = true
}

static func barView(_ view: UIView, model: SliderVM) {
view.backgroundColor = model.color.main.uiColor
view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height)
view.layer.masksToBounds = true
}

static func stripedLayer(_ layer: CAShapeLayer, model: SliderVM) {
layer.fillColor = model.color.main.uiColor.cgColor
switch model.style {
case .light:
layer.isHidden = true
case .striped:
layer.isHidden = false
}
}

static func handleView(_ view: UIView, model: SliderVM) {
view.backgroundColor = model.color.main.uiColor
view.layer.cornerRadius = model.cornerRadius(for: model.handleSize.width)
view.layer.masksToBounds = true
}

static func handleOverlayView(_ view: UIView, model: SliderVM) {
view.isVisible = model.isHandleOverlayVisible
view.backgroundColor = model.color.contrast.uiColor
view.layer.cornerRadius = model.cornerRadius(for: model.handleOverlaySide)
}
}
}
Loading