Skip to content

Commit 68b5ed2

Browse files
SahilSainiYMLMark Pospesel
authored andcommitted
[CM-1205] Add Stepper (SwiftUI) and Stepper Control (UIKit)
1 parent 5874a66 commit 68b5ed2

15 files changed

+903
-21
lines changed

Package.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ let package = Package(
1717
)
1818
],
1919
dependencies: [
20-
// Dependencies declare other packages that this package depends on.
21-
// .package(url: /* package url */, from: "1.0.0"),
20+
// For UI layout support, contrast ratio calculations.
21+
.package(url: "https://github.com/yml-org/YCoreUI.git", from: "1.4.0"),
22+
// For Typography support
23+
.package(url: "https://github.com/yml-org/YMatterType.git", from: "1.4.0")
2224
],
2325
targets: [
2426
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
2527
// Targets can depend on other targets in this package, and on products in packages this package depends on.
2628
.target(
2729
name: "YStepper",
28-
dependencies: []
30+
dependencies: ["YCoreUI", "YMatterType"]
2931
),
3032
.testTarget(
3133
name: "YStepperTests",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//
2+
// YStepper+Images.swift
3+
// YStepper
4+
//
5+
// Created by Sahil Saini on 07/03/23.
6+
// Copyright © 2023 Y Media Labs. All rights reserved.
7+
//
8+
9+
import UIKit
10+
import YCoreUI
11+
12+
/// Collection of Images
13+
enum Images: String, CaseIterable {
14+
case increment = "plus.circle"
15+
case decrement = "minus.circle"
16+
case delete = "trash.circle"
17+
}
18+
19+
extension Images: ImageAsset {
20+
func loadImage() -> UIImage? {
21+
UIImage(systemName: rawValue)
22+
}
23+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//
2+
// StepperDelegate.swift
3+
// YStepper
4+
//
5+
// Created by Sahil Saini on 07/03/23.
6+
// Copyright © 2023 Y Media Labs. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
/// Observes `Stepper` actions
12+
public protocol StepperDelegate: AnyObject {
13+
/// This method is used to inform when there is a change in value.
14+
/// - Parameter newValue: new value
15+
func valueDidChange(newValue: Double)
16+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//
2+
// Stepper+AppearanceObserver.swift
3+
// YStepper
4+
//
5+
// Created by Sahil Saini on 06/03/23.
6+
// Copyright © 2023 Y Media Labs. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
// extension for appearance
12+
extension Stepper {
13+
class AppearanceObserver: ObservableObject {
14+
@Published var appearance: StepperControl.Appearance
15+
16+
/// initializer for theme observer
17+
/// - Parameter appearance: appearance object
18+
init(appearance: StepperControl.Appearance = .default) {
19+
self.appearance = appearance
20+
}
21+
}
22+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// Stepper+ValueObserver.swift
3+
// YStepper
4+
//
5+
// Created by Sahil Saini on 06/03/23.
6+
// Copyright © 2023 Y Media Labs. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
// extension for appearance
12+
extension Stepper {
13+
class ValueObserver: ObservableObject {
14+
@Published var minimumValue: Double
15+
@Published var maximumValue: Double
16+
@Published var stepValue: Double
17+
@Published var value: Double
18+
@Published var decimalValue: Int
19+
20+
init(
21+
minimumValue: Double = 0,
22+
maximumValue: Double = 100,
23+
stepValue: Double = 1,
24+
value: Double = 0,
25+
decimalValue: Int = 0
26+
) {
27+
self.minimumValue = minimumValue
28+
self.maximumValue = maximumValue
29+
self.stepValue = stepValue
30+
self.value = value
31+
self.decimalValue = decimalValue
32+
}
33+
}
34+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
//
2+
// Stepper.swift
3+
// YStepper
4+
//
5+
// Created by Sahil Saini on 06/03/23.
6+
// Copyright © 2023 Y Media Labs. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
import YMatterType
11+
12+
/// A SwiftUI stepper control.
13+
public struct Stepper {
14+
enum ButtonType {
15+
case increment
16+
case decrement
17+
}
18+
19+
let minimumSize: CGSize = CGSize(width: 44, height: 44)
20+
21+
@ObservedObject private var appearanceObserver = Stepper.AppearanceObserver()
22+
@ObservedObject private var valueObserver = Stepper.ValueObserver()
23+
24+
/// Receive value change notification
25+
public weak var delegate: StepperDelegate?
26+
/// Stepper appearance
27+
public var appearance: StepperControl.Appearance {
28+
get {
29+
self.appearanceObserver.appearance
30+
}
31+
set {
32+
self.appearanceObserver.appearance = newValue
33+
}
34+
}
35+
/// Optional minimum vale. Minimum possible value for the stepper.
36+
public var minimumValue: Double {
37+
get { valueObserver.minimumValue }
38+
set {
39+
onMinimumValueChange(newValue: newValue)
40+
}
41+
}
42+
/// Optional maximum value. Maximum possible value for the stepper.
43+
public var maximumValue: Double {
44+
get { valueObserver.maximumValue }
45+
set {
46+
onMaximumValueChange(newValue: newValue)
47+
}
48+
}
49+
/// Optional step value. The step, or increment, value for the stepper.
50+
public var stepValue: Double {
51+
get { valueObserver.stepValue }
52+
set { valueObserver.stepValue = newValue }
53+
}
54+
55+
/// Stepper's current value
56+
public var value: Double {
57+
get { valueObserver.value }
58+
set { onValueChange(newValue: newValue) }
59+
}
60+
/// Decimal digits in current value
61+
public var decimalPlaces: Int {
62+
get { valueObserver.decimalValue }
63+
set { valueObserver.decimalValue = newValue }
64+
}
65+
/// Initializes Stepper
66+
/// - Parameters:
67+
/// - appearance: appearance for the stepper. Default is `.default`
68+
/// - minimumValue: minimum value. Default is `0`
69+
/// - maximumValue: maximum value. Default is `100`
70+
/// - stepValue: Step value. Default is `1`
71+
/// - value: Current value. Default is `0` or minimumValue (if provided)
72+
public init(
73+
appearance: StepperControl.Appearance = .default,
74+
minimumValue: Double = 0,
75+
maximumValue: Double = 100,
76+
stepValue: Double = 1,
77+
value: Double = 0
78+
) {
79+
self.appearance = appearance
80+
self.minimumValue = minimumValue
81+
self.maximumValue = maximumValue
82+
self.stepValue = stepValue
83+
self.value = (minimumValue...maximumValue).contains(value) ?
84+
minimumValue : value
85+
}
86+
}
87+
88+
extension Stepper: View {
89+
/// :nodoc:
90+
public var body: some View {
91+
HStack(spacing: 0) {
92+
generateButton(buttonType: .decrement) {
93+
valueObserver.value -= stepValue
94+
updateCurrentValue(newValue: valueObserver.value)
95+
}
96+
97+
TextStyleLabel(getValueText(), typography: appearance.textStyle.typography) { label in
98+
label.textAlignment = .center
99+
}.frame(minWidth: minimumSize.width, idealWidth: minimumSize.height)
100+
101+
generateButton(buttonType: .increment) {
102+
valueObserver.value += stepValue
103+
updateCurrentValue(newValue: valueObserver.value)
104+
}
105+
}
106+
.background(
107+
Capsule()
108+
.strokeBorder(Color(appearance.borderColor), lineWidth: appearance.borderWidth)
109+
.background(Capsule().foregroundColor(Color(appearance.backgroundColor)))
110+
)
111+
}
112+
113+
func generateButton(
114+
buttonType: ButtonType,
115+
action: @escaping () -> Void
116+
) -> some View {
117+
let button = Button(action: action) {
118+
switch buttonType {
119+
case .increment:
120+
getIncrementImage()
121+
case .decrement:
122+
getImageForDecrementButton()
123+
}
124+
}
125+
return button.frame(minWidth: minimumSize.width, minHeight: minimumSize.height)
126+
}
127+
}
128+
129+
extension Stepper {
130+
func getValueText() -> String {
131+
String(format: "%.\(decimalPlaces)f", value)
132+
}
133+
134+
func updateCurrentValue(newValue: Double) {
135+
if newValue < valueObserver.minimumValue {
136+
valueObserver.value = valueObserver.minimumValue
137+
}
138+
139+
if newValue > valueObserver.maximumValue {
140+
valueObserver.value = valueObserver.maximumValue
141+
}
142+
delegate?.valueDidChange(newValue: valueObserver.value)
143+
}
144+
}
145+
146+
extension Stepper {
147+
func getDeleteImage() -> Image {
148+
Image(uiImage: appearance.deleteImage ?? StepperControl.Appearance.defaultDeleteImage)
149+
}
150+
151+
func getIncrementImage() -> Image {
152+
Image(uiImage: appearance.incrementImage ?? StepperControl.Appearance.defaultIncrementImage)
153+
}
154+
155+
func getDecrementImage() -> Image {
156+
Image(uiImage: appearance.decrementImage ?? StepperControl.Appearance.defaultDecrementImage)
157+
}
158+
159+
func getImageForDecrementButton() -> Image {
160+
if appearance.hasDeleteButton
161+
&& value <= stepValue
162+
&& minimumValue == 0 {
163+
return getDeleteImage()
164+
}
165+
return getDecrementImage()
166+
}
167+
}
168+
169+
private extension Stepper {
170+
func onMinimumValueChange(newValue: Double) {
171+
if minimumValue < maximumValue {
172+
valueObserver.minimumValue = newValue
173+
if value < minimumValue {
174+
valueObserver.value = minimumValue
175+
}
176+
}
177+
}
178+
179+
func onMaximumValueChange(newValue: Double) {
180+
if minimumValue < maximumValue {
181+
valueObserver.maximumValue = newValue
182+
}
183+
}
184+
185+
func onValueChange(newValue: Double) {
186+
if (minimumValue...maximumValue).contains(newValue) {
187+
valueObserver.value = newValue
188+
}
189+
}
190+
}
191+
192+
struct Stepper_Previews: PreviewProvider {
193+
static var previews: some View {
194+
HStack {
195+
Spacer().frame(maxWidth: .infinity)
196+
Stepper()
197+
Spacer().frame(maxWidth: .infinity)
198+
}
199+
}
200+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//
2+
// StepperControl+Appearance.swift
3+
// YStepper
4+
//
5+
// Created by Sahil Saini on 06/03/23.
6+
// Copyright © 2023 Y Media Labs. All rights reserved.
7+
//
8+
9+
import UIKit
10+
import YMatterType
11+
12+
extension StepperControl {
13+
/// Appearance for stepper that contains typography and color properties
14+
public struct Appearance {
15+
/// Typography of stepper value label
16+
public var textStyle: (textColor: UIColor, typography: Typography)
17+
/// Background color for stepper view
18+
public var backgroundColor: UIColor
19+
/// Border color for stepper view
20+
public var borderColor: UIColor
21+
/// Border width for stepper view
22+
public var borderWidth: CGFloat
23+
/// Delete button image
24+
public var deleteImage: UIImage?
25+
/// Increment button image
26+
public var incrementImage: UIImage?
27+
/// Decrement button image
28+
public var decrementImage: UIImage?
29+
/// Whether to show delete button or not.
30+
var hasDeleteButton: Bool { deleteImage != nil }
31+
32+
/// Initializer for appearance
33+
/// - Parameters:
34+
/// - textStyle: Typography and text color for valueText label.
35+
/// Default is `(UIColor.label, Typography.systemLabel)`
36+
/// - foregroundColor: Foreground color for valueText. Default is `.label`
37+
/// - backgroundColor: Background color for stepper view. Default is `.systemBackground`
38+
/// - borderColor: Border color for stepper view. Default is `UIColor.label`
39+
/// - borderWidth: Border width for day view. Default is `1.0`
40+
/// - deleteImage: Delete button image. Default is `Appearance.defaultDeleteImage`
41+
/// - incrementImage: Increment button image. Default is `Appearance.defaultIncrementImage`
42+
/// - decrementImage: Decrement button image. Default is `Appearance.defaultDecrementImage`
43+
public init(
44+
textStyle: (textColor: UIColor, typography: Typography) = (.label, .systemLabel),
45+
foregroundColor: UIColor = .label,
46+
backgroundColor: UIColor = .systemBackground,
47+
borderColor: UIColor = .label,
48+
borderWidth: CGFloat = 1.0,
49+
deleteImage: UIImage? = Appearance.defaultDeleteImage,
50+
incrementImage: UIImage? = Appearance.defaultIncrementImage,
51+
decrementImage: UIImage? = Appearance.defaultDecrementImage
52+
) {
53+
self.textStyle = textStyle
54+
self.backgroundColor = backgroundColor
55+
self.borderColor = borderColor
56+
self.borderWidth = borderWidth
57+
self.deleteImage = deleteImage
58+
self.incrementImage = incrementImage
59+
self.decrementImage = decrementImage
60+
}
61+
}
62+
}
63+
64+
extension StepperControl.Appearance {
65+
/// Default stepper appearance
66+
public static let `default` = StepperControl.Appearance()
67+
/// Default image for delete button. Is a `trash.circle` from SF Symbols in template rendering mode
68+
public static let defaultDeleteImage = Images.delete.image
69+
/// Default image for increment button. Is a `plus.circle` from SF Symbols in template rendering mode
70+
public static let defaultIncrementImage = Images.increment.image
71+
/// Default image for decrement button. Is a `minus.circle` from SF Symbols in template rendering mode
72+
public static let defaultDecrementImage = Images.decrement.image
73+
}

0 commit comments

Comments
 (0)