├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── README.md
├── Package.swift
└── Sources
└── LabeledStepper
├── Style.swift
└── LabeledStepper.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # LabeledStepper
2 |
3 | A native SwiftUI Stepper that shows the value with more features! (like long press to repeat) ;)
4 |
5 |
6 |
7 |
8 |
9 | ### Features
10 | - [x] Long press to increase or decrease the value automatically
11 | - [x] Change the repeat speed of the long-press
12 | - [x] Limitable range
13 | - [x] Custom theme
14 | - [x] Custom sizing for each component
15 | - [X] Exact same API with the Native SwiftUI.Stepper
16 | - [ ] Enhanced animations
17 |
18 | ### Using it in a `List` or a `Form`
19 | In Vanilla Swift's behavior, button actions differed and overridden with the row action (which is unexpected in my POV). To make any button (including stepper's buttons) work as expected in a `List` or a `Form`, you need to set the button style to ANYTHING-BUT-THE-AUTOMATIC like:
20 |
21 | ```
22 | LabeledStepper(
23 | "Title",
24 | description: "Description",
25 | value: $value
26 | )
27 | .buttonStyle(.plain) // 👈 Any style but the `automatic` here.
28 | ```
29 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.5
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "LabeledStepper",
8 | platforms: [
9 | .iOS(.v13),
10 | // TODO: Should adapt colors for macOS
11 | // .macOS(.v10_15)
12 | ],
13 | products: [
14 | // Products define the executables and libraries a package produces, and make them visible to other packages.
15 | .library(
16 | name: "LabeledStepper",
17 | targets: ["LabeledStepper"]),
18 | ],
19 | dependencies: [
20 | // Dependencies declare other packages that this package depends on.
21 | // .package(url: /* package url */, from: "1.0.0"),
22 | ],
23 | targets: [
24 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
25 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
26 | .target(
27 | name: "LabeledStepper",
28 | dependencies: []),
29 | ]
30 | )
31 |
--------------------------------------------------------------------------------
/Sources/LabeledStepper/Style.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Style.swift
3 | //
4 | //
5 | // Created by Seyed Mojtaba Hosseini Zeidabadi on 1/13/22.
6 | //
7 | //
8 | // StackOverflow: https://stackoverflow.com/story/mojtabahosseini
9 | // Linkedin: https://linkedin.com/in/MojtabaHosseini
10 | // GitHub: https://github.com/MojtabaHs
11 | //
12 |
13 | import SwiftUI
14 |
15 | public struct Style {
16 |
17 | public init(
18 | height: Double = 34.0,
19 | labelWidth: Double = 48.0,
20 | buttonWidth: Double = 48.0,
21 | buttonPadding: Double = 12.0,
22 | backgroundColor: Color = Color(.quaternarySystemFill),
23 | activeButtonColor: Color = Color(.label),
24 | inactiveButtonColor: Color = Color(.tertiaryLabel),
25 | titleColor: Color = Color(.label),
26 | descriptionColor: Color = Color(.secondaryLabel),
27 | valueColor: Color = Color(.label)
28 | ) {
29 | self.height = height
30 | self.labelWidth = labelWidth
31 | self.buttonWidth = buttonWidth
32 | self.buttonPadding = buttonPadding
33 | self.backgroundColor = backgroundColor
34 | self.activeButtonColor = activeButtonColor
35 | self.inactiveButtonColor = inactiveButtonColor
36 | self.titleColor = titleColor
37 | self.descriptionColor = descriptionColor
38 | self.valueColor = valueColor
39 | }
40 |
41 | // TODO: Make these dynamic
42 | var height: Double
43 | var labelWidth: Double
44 |
45 | var buttonWidth: Double
46 | var buttonPadding: Double
47 |
48 | // MARK: - Colors
49 | var backgroundColor: Color
50 | var activeButtonColor: Color
51 | var inactiveButtonColor: Color
52 |
53 | var titleColor: Color
54 | var descriptionColor: Color
55 | var valueColor: Color
56 | }
57 |
--------------------------------------------------------------------------------
/Sources/LabeledStepper/LabeledStepper.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct LabeledStepper: View {
4 |
5 | public init(
6 | _ title: String,
7 | description: String = "",
8 | value: Binding,
9 | in range: ClosedRange = 0...Int.max,
10 | longPressInterval: Double = 0.3,
11 | repeatOnLongPress: Bool = true,
12 | style: Style = .init()
13 | ) {
14 | self.title = title
15 | self.description = description
16 | self._value = value
17 | self.range = range
18 | self.longPressInterval = longPressInterval
19 | self.repeatOnLongPress = repeatOnLongPress
20 | self.style = style
21 | }
22 |
23 | @Binding public var value: Int
24 |
25 | public var title: String = ""
26 | public var description: String = ""
27 | public var range = 0...Int.max
28 | public var longPressInterval = 0.3
29 | public var repeatOnLongPress = true
30 |
31 | public var style = Style()
32 |
33 | @State private var timer: Timer?
34 | private var isPlusButtonDisabled: Bool { value >= range.upperBound }
35 | private var isMinusButtonDisabled: Bool { value <= range.lowerBound }
36 |
37 | /// Perform the math operation passed into the function on the `value` and `1` each time the internal timer runs
38 | private func onPress(_ isPressing: Bool, operation: @escaping (inout Int, Int) -> ()) {
39 |
40 | guard isPressing else { timer?.invalidate(); return }
41 |
42 | func action(_ timer: Timer?) {
43 | operation(&value, 1)
44 | }
45 |
46 | /// Instant action call for short press action
47 | action(timer)
48 |
49 | guard repeatOnLongPress else { return }
50 |
51 | timer = Timer.scheduledTimer(
52 | withTimeInterval: longPressInterval,
53 | repeats: true,
54 | block: action
55 | )
56 | }
57 |
58 | public var body: some View {
59 |
60 | HStack {
61 | Text(title)
62 | .foregroundColor(style.titleColor)
63 |
64 | Text(description)
65 | .foregroundColor(style.descriptionColor)
66 |
67 | Spacer()
68 |
69 | HStack(spacing: 0) {
70 | /// - Note: The action will be performed inside the `.onLongPressGesture` modifier.
71 | Button() { } label: { Image(systemName: "minus") }
72 | .onLongPressGesture(
73 | minimumDuration: 0
74 | ) {} onPressingChanged: { onPress($0, operation: -=) }
75 | .frame(width: style.buttonWidth, height: style.height)
76 | .disabled(isMinusButtonDisabled)
77 | .foregroundColor(
78 | isMinusButtonDisabled
79 | ? style.inactiveButtonColor
80 | : style.activeButtonColor
81 | )
82 | .contentShape(Rectangle())
83 |
84 | Divider()
85 | .padding([.top, .bottom], 8)
86 |
87 | Text("\(value)")
88 | .foregroundColor(style.valueColor)
89 | .frame(width: style.labelWidth, height: style.height)
90 |
91 | Divider()
92 | .padding([.top, .bottom], 8)
93 |
94 | /// - Note: The action will be performed inside the `.onLongPressGesture` modifier.
95 | Button() { } label: { Image(systemName: "plus") }
96 | .onLongPressGesture(
97 | minimumDuration: 0
98 | ) {} onPressingChanged: { onPress($0, operation: +=) }
99 | .frame(width: style.buttonWidth, height: style.height)
100 | .disabled(isPlusButtonDisabled)
101 | .foregroundColor(
102 | isPlusButtonDisabled
103 | ? style.inactiveButtonColor
104 | : style.activeButtonColor
105 | )
106 | .contentShape(Rectangle())
107 | }
108 | .background(style.backgroundColor)
109 | .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
110 | .frame(height: style.height)
111 | }
112 | .lineLimit(1)
113 | }
114 | }
115 |
116 |
117 | // MARK: - Preview
118 |
119 | struct LabeledStepper_Previews: PreviewProvider {
120 | struct Demo: View {
121 | @State private var value: Int = 0
122 |
123 | var body: some View {
124 | LabeledStepper(
125 | "Title",
126 | description: "description",
127 | value: $value,
128 | in: 0...10
129 | )
130 | }
131 | }
132 |
133 | static var previews: some View {
134 | Demo()
135 | .padding()
136 | }
137 | }
138 |
--------------------------------------------------------------------------------