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
@@ -0,0 +1,52 @@
import SwiftUI
import ComponentsKit

struct SliderPreview: View {
@State private var model = Self.initialModel
@State private var currentValue: CGFloat = Self.initialValue

var body: some View {
VStack {
PreviewWrapper(title: "SwiftUI") {
SUSlider(currentValue: self.$currentValue, model: self.model)
}
Form {
ComponentColorPicker(selection: self.$model.color)
ComponentRadiusPicker(selection: self.$model.cornerRadius) {
Text("Custom: 2px").tag(ComponentRadius.custom(2))
}
SizePicker(selection: self.$model.size)
Picker("Step", selection: self.$model.step) {
Text("1").tag(CGFloat(1))
Text("5").tag(CGFloat(5))
Text("10").tag(CGFloat(10))
Text("25").tag(CGFloat(25))
Text("50").tag(CGFloat(50))
}
Picker("Style", selection: self.$model.style) {
Text("Light").tag(SliderVM.Style.light)
Text("Striped").tag(SliderVM.Style.striped)
}
}
}
}

// 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 {
SliderPreview()
}
3 changes: 3 additions & 0 deletions Examples/DemosApp/DemosApp/Core/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ struct App: View {
NavigationLinkWithTitle("Segmented Control") {
SegmentedControlPreview()
}
NavigationLinkWithTitle("Slider") {
SliderPreview()
}
NavigationLinkWithTitle("Text Input") {
TextInputPreviewPreview()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

extension SliderVM {
/// Defines the visual styles for the slider component.
public enum Style {
case light
case striped
}
}
186 changes: 186 additions & 0 deletions Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import SwiftUI

/// A model that defines the appearance properties for a slider component.
public struct SliderVM: ComponentVM {
/// The color of the slider.
///
/// Defaults to `.accent`.
public var color: ComponentColor = .accent

/// The visual style of the slider component.
///
/// Defaults to `.light`.
public var style: Style = .light

/// The size of the slider.
///
/// Defaults to `.medium`.
public var size: ComponentSize = .medium

/// The minimum value of the slider.
public var minValue: CGFloat = 0

/// The maximum value of the slider.
public var maxValue: CGFloat = 100

/// The corner radius of the slider track and handle.
///
/// Defaults to `.full`.
public var cornerRadius: ComponentRadius = .full

/// The step value for the slider.
///
/// Defaults to `1`.
public var step: CGFloat = 1

/// Initializes a new instance of `SliderVM` with default values.
public init() {}
}

// MARK: - Shared Helpers

extension SliderVM {
var trackHeight: CGFloat {
switch self.size {
case .small:
return 6
case .medium:
return 12
case .large:
return 32
}
}
var handleSize: CGSize {
switch self.size {
case .small, .medium:
return CGSize(width: 20, height: 32)
case .large:
return CGSize(width: 40, height: 40)
}
}
func cornerRadius(for height: CGFloat) -> CGFloat {
switch self.cornerRadius {
case .none:
return 0
case .small:
return height / 3.5
case .medium:
return height / 3.0
case .large:
return height / 2.5
case .full:
return height / 2.0
case .custom(let value):
return min(value, height / 2)
}
}
var trackSpacing: CGFloat {
return 4
}
var handleOverlaySide: CGFloat {
return 12
}
private func stripesCGPath(in rect: CGRect) -> CGMutablePath {
let stripeWidth: CGFloat = 2
let stripeSpacing: CGFloat = 4
let stripeAngle: Angle = .degrees(135)

let path = CGMutablePath()
let step = stripeWidth + stripeSpacing
let radians = stripeAngle.radians
let dx = rect.height * tan(radians)

for x in stride(from: rect.width + rect.height, through: dx, by: -step) {
let topLeft = CGPoint(x: x, y: 0)
let topRight = CGPoint(x: x + stripeWidth, y: 0)
let bottomLeft = CGPoint(x: x + dx, y: rect.height)
let bottomRight = CGPoint(x: x + stripeWidth + dx, y: rect.height)
path.move(to: topLeft)
path.addLine(to: topRight)
path.addLine(to: bottomRight)
path.addLine(to: bottomLeft)
path.closeSubpath()
}

return path
}
}

extension SliderVM {
func steppedValue(for offset: CGFloat, trackWidth: CGFloat) -> CGFloat {
guard trackWidth > 0 else { return self.minValue }

let newProgress = offset / trackWidth

let newValue = self.minValue + newProgress * (self.maxValue - self.minValue)

if self.step > 0 {
let stepsCount = (newValue / self.step).rounded()
return stepsCount * self.step
} else {
return newValue
}
}
}

extension SliderVM {
func progress(for currentValue: CGFloat) -> CGFloat {
let range = self.maxValue - self.minValue
guard range > 0 else { return 0 }
let normalized = (currentValue - self.minValue) / range
return max(0, min(1, normalized))
}
}

extension SliderVM {
var containerHeight: CGFloat {
max(self.handleSize.height, self.trackHeight)
}

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

func barWidth(for totalWidth: CGFloat, progress: CGFloat) -> CGFloat {
let width = self.sliderWidth(for: totalWidth)
return width * progress
}

func backgroundWidth(for totalWidth: CGFloat, progress: CGFloat) -> CGFloat {
let width = self.sliderWidth(for: totalWidth)
let filled = width * progress
return width - filled
}
}

// MARK: - UIKit Helpers

extension SliderVM {
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
}
}

// MARK: - SwiftUI Helpers

extension SliderVM {
func stripesPath(in rect: CGRect) -> Path {
Path(self.stripesCGPath(in: rect))
}
}

// MARK: - Validation

extension SliderVM {
func validateMinMaxValues() {
if self.minValue > self.maxValue {
assertionFailure("Min value must be less than max value")
}
}
}
107 changes: 107 additions & 0 deletions Sources/ComponentsKit/Components/Slider/SUSlider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import SwiftUI

/// A SwiftUI component that displays a slider.
public struct SUSlider: View {
// MARK: - Properties

/// A model that defines the appearance properties.
public var model: SliderVM

/// A binding to control the current value.
@Binding public var currentValue: CGFloat

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

// MARK: - Initializer

/// Initializer.
/// - Parameters:
/// - currentValue: A binding to the current value.
/// - model: A model that defines the appearance properties.
public init(
currentValue: Binding<CGFloat>,
model: SliderVM = .init()
) {
self._currentValue = currentValue
self.model = model
}

// MARK: - Body

public var body: some View {
GeometryReader { geometry in
let barWidth = self.model.barWidth(for: geometry.size.width, progress: self.progress)
let backgroundWidth = self.model.backgroundWidth(for: geometry.size.width, progress: self.progress)

HStack(spacing: self.model.trackSpacing) {
// Progress segment
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight))
.foregroundStyle(self.model.color.main.color)
.frame(width: barWidth, height: self.model.trackHeight)

// Handle
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleSize.height))
.foregroundStyle(self.model.color.main.color)
.frame(width: self.model.handleSize.width, height: self.model.handleSize.height)
.overlay(
Group {
if self.model.size == .large {
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleOverlaySide))
.foregroundStyle(self.model.color.contrast.color)
.frame(width: self.model.handleOverlaySide, height: self.model.handleOverlaySide)
}
}
)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let totalWidth = geometry.size.width
let sliderWidth = max(0, totalWidth - self.model.handleSize.width - 2 * self.model.trackSpacing)

let currentLeft = barWidth
let newOffset = currentLeft + value.translation.width

let clampedOffset = min(max(newOffset, 0), sliderWidth)
self.currentValue = self.model.steppedValue(for: clampedOffset, trackWidth: sliderWidth)
}
)

// Remaining segment
Group {
switch self.model.style {
case .light:
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight))
.foregroundStyle(self.model.color.background.color)
.frame(width: backgroundWidth)
case .striped:
ZStack {
RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight))
.foregroundStyle(.clear)

StripesShapeSlider(model: self.model)
.foregroundStyle(self.model.color.main.color)
.cornerRadius(self.model.cornerRadius(for: self.model.trackHeight))
}
.frame(width: backgroundWidth)
}
}
.frame(height: self.model.trackHeight)
}
}
.frame(height: self.model.containerHeight)
.onAppear {
self.model.validateMinMaxValues()
}
}
}
// MARK: - Helpers

struct StripesShapeSlider: Shape, @unchecked Sendable {
var model: SliderVM

func path(in rect: CGRect) -> Path {
self.model.stripesPath(in: rect)
}
}
Loading