├── .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 | Screen Shot 2022-01-13 at 16 16 51 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 | --------------------------------------------------------------------------------