A simple SwiftUI component for stepping through the contents of an array. Features long press gestures to increment or decrement the selected value along with a list view of all available values for selection. Very similar to TextFieldStepper sharing both similar functionality and styling.
- Platforms
- Usage
- Advanced
- Uniqueness
- Selected
- Appending
- Customizing
- Sections
- Parameters
- Styling
- SwiftUI
- License
Tested on iOS 16.0 but should work on both iOS and iPadOS 15 and up.
Creating a basic ArrayStepper
is simple. Refer to advanced and sections for other ways of creating an ArrayStepper
.
struct ContentView: View {
@StateObject private var values = ArrayStepperValues(
selected: "Joe",
values: [
"Tim",
"Joe",
"James"
]
)
var body: some View {
ArrayStepper(
values: values,
label: "Person"
)
}
}
By default, ArrayStepper
can accept anything that conforms to Equatable
. This should be fine in most cases but there may be times where you want to pass in more complex types such as custom structs. This is where the display
parameter becomes necessary in order to define what value to show. By default if this parameter is not defined and a struct is passed, ArrayStepper
will just print out the entire object.
struct Person {
var name: String
var age: Int?
}
struct ContentView: View {
@StateObject private var values = ArrayStepperValues(
selected: Person(name: "Joe", age: 23),
values: [
Person(name: "Tim", age: 20),
Person(name: "Joe", age: 23),
Person(name: "James", age: nil)
]
)
var body: some View {
ArrayStepper(
values: values,
display: { $0.name },
label: "Person"
)
}
}
In order to combat issues with multiple values that are the same, ArrayStepper
uses a custom type ASValue
to convert everything into a unique value. This is a complexity of O(n)
as each value has to manually be cast to this custom type. In most situations this shouldn't slow anything down but if you want to skip this cast, you can just pass everything in as ASValue
. Keep in mind if you create the ArrayStepper
this way, your selected value must be stored as variable in order to ensure the ID matches in the values
array.
@StateObject private var values: ArrayStepperValues<ASValue<String>>
init() {
let selected = ASValue(item: "Joe")
self.values = ArrayStepperValues(
selected: selected
values: [
ASValue(item: "Tim"),
selected,
ASValue(item: "James")
]
)
}
var body: some View {
ArrayStepper(
values: values,
label: "Person"
)
}
ArrayStepper
tries setting the selected values to the first index of ArrayStepperValues.selected
in ArrayStepperValues.values
. If this value is not found, ArrayStepper
will throw a fatalError()
by default, however there are three ways that can be set to handle this:
- .Fail - Throws a
fatalError()
if theselected
is not found invalues
and is the default setting. - .First - Sets the
selected
to the first value found invalues
. - .Append - Appends
selected
tovalues
and setsselected
to the last element invalues
.
In order to access the value of selected
you need to access selected.item
as all values are cast to ASValue
, refer to uniqueness for more.
You can do any number of operations on ArrayStepperValues.values
as you would expect for an array. The only restriction is that values must be passed as ASValue
since after you create the ArrayStepper
all values are converted to this type. Also keep in mind that ArrayStepper
still follows the setting for selected.
@StateObject private var values = ArrayStepperValues(
selected: "Joe",
values: [
"Tim",
"Joe",
"James"
]
)
var body: some View {
ArrayStepper(
values: values,
label: "Person"
)
Button("Add Person", action: {
values.values.append(ASValue(item: "Joe"))
})
}
Although the defaults should be fine for most situations, there are cases where they won't be. ArrayStepper
has two ways of changing the default parameters.
-
If you just want to modify a single component, you can directly pass in the parameter name and value.
struct ContentView: View { @StateObject private var values = ArrayStepperValues( selected: "Joe", values: [ "Tim", "Joe", "James" ] ) var body: some View { ArrayStepper( values: values, label: "Person", disabledColor: .black ) } }
-
If you want to modify multiple at once, use
ArrayStepperConfig
. You can either pass a different one into each instance of the component or create a single one and make it available globally as a sort of default.struct ContentView: View { @StateObject private var values = ArrayStepperValues( selected: "Joe", values: [ "Tim", "Joe", "James" ] ) let config = ArrayStepperConfig( label: "Person", disabledColor: .black ) var body: some View { ArrayStepper( values: values, config: config ) } }
If you have multiple groups of data, you can use an array of ASSection
, yes, ASS-ection, to create difference sections in the list.
struct ContentView: View {
@StateObject private var values = ArrayStepperValues(
selected: Person(name: "Joe", age: 23),
sections: [
ASSection(
header: "Siblings",
items: [
Person(name: "Joe", age: 23),
Person(name: "Tim", age: 20)
]
),
ASSection(
header: "Parents",
items: [
Person(name: "Phil", age: 55)
]
)
]
)
var body: some View {
ArrayStepper(
sections: $sections,
display: { "\($0.name): \($0.age)" },
label: "Person"
)
}
}
Below are the parameters available on both ArrayStepper
and ArrayStepperConfig
.
Parameter | Type | Default | Note |
---|---|---|---|
label | String | “” | Label to show under value. |
incrementSpeed | Double | 0.25 | How many seconds before the button action is ran while holding. |
decrementImage | ArrayStepperImage | ArrayStepperImage(systemName: "minus.circle.fill") | Image for decrement button. |
incrementImage | ArrayStepperImage | ArrayStepperImage(systemName: "plus.circle.fill") | Image for increment button. |
disabledColor | Color | Color(UIColor.lightGray) | Color of disabled button. |
labelOpacity | Double | 1.0 | Opacity of label under value. |
labelColor | Color | .primary | Color of label under value. |
valueColor | Color | .primary | Color of value. |
Below are the default colors and images that ArrayStepper
uses. In addition to this, when a button is disabled it will use Color(UIColor.lightGray)
which can be overridden with the disabledColor
parameter. You can also specify the label opacity and color with labelOpacity
and labelColor
. If you want to change the color of the main value, use valueColor
.
Button | Color | Image |
---|---|---|
decrementButton | .accentColor | minus.circle.fill |
incrementButton | .accentColor | plus.circle.fill |
You can override the default images by creating an instance of ArrayStepperImage
and passing that to the corresponding parameter on either ArrayStepper
or ArrayStepperConfig
. There are two methods of instantiating ArrayStepperImage
. Currently there is no method to just change the color, you must provide an image as well.
- If you’re just using a system named image, you can use the
systemName
parameter.let image = ArrayStepperImage(systemName: "circle.fill")
- If you're using a custom image, directly pass an
Image
.let image = ArrayStepperImage(image: Image(systemName: "circle.fill")
In Xcode 14 you may get a SwiftUI warning that reads the following:
[SwiftUI] Publishing changes from within view updates is not allowed, this will cause undefined behavior.
There is some speculation whether or not this is an actual error or a bug but from all my testing, nothing seems to be out of the ordinary. Just keep this in mind if you get unexpected results.
MIT License
Copyright (c) 2022 Joe Scotto
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.