diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SliderPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SliderPreview.swift new file mode 100644 index 00000000..99c4df99 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SliderPreview.swift @@ -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() +} diff --git a/Examples/DemosApp/DemosApp/Core/App.swift b/Examples/DemosApp/DemosApp/Core/App.swift index ab7aaf9e..01d9e8c9 100644 --- a/Examples/DemosApp/DemosApp/Core/App.swift +++ b/Examples/DemosApp/DemosApp/Core/App.swift @@ -47,6 +47,9 @@ struct App: View { NavigationLinkWithTitle("Segmented Control") { SegmentedControlPreview() } + NavigationLinkWithTitle("Slider") { + SliderPreview() + } NavigationLinkWithTitle("Text Input") { TextInputPreviewPreview() } diff --git a/Sources/ComponentsKit/Components/Slider/Models/SliderStyle.swift b/Sources/ComponentsKit/Components/Slider/Models/SliderStyle.swift new file mode 100644 index 00000000..c4a70868 --- /dev/null +++ b/Sources/ComponentsKit/Components/Slider/Models/SliderStyle.swift @@ -0,0 +1,9 @@ +import Foundation + +extension SliderVM { + /// Defines the visual styles for the slider component. + public enum Style { + case light + case striped + } +} diff --git a/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift b/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift new file mode 100644 index 00000000..18bd5900 --- /dev/null +++ b/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift @@ -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") + } + } +} diff --git a/Sources/ComponentsKit/Components/Slider/SUSlider.swift b/Sources/ComponentsKit/Components/Slider/SUSlider.swift new file mode 100644 index 00000000..76f3b262 --- /dev/null +++ b/Sources/ComponentsKit/Components/Slider/SUSlider.swift @@ -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, + 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) + } +}