Have you ever had a SwiftUI view with lots of toggles?
struct LotsOfStateView: View {
//1
@State var showBackground = false
//2
@State var showBorder = false
//3
@State var reduceMotionEnabled = false
//4
@State var largeFont = false
//5
@State var receiveEmailNotifications = false
var body: some View {
VStack {
Toggle(isOn: $showBackground) {
Text(LocalizedStringKey("Show Background"))
}
Toggle(isOn: $showBorder) {
Text(LocalizedStringKey("Show Border"))
}
Toggle(isOn: $reduceMotionEnabled) {
Text(LocalizedStringKey("Reduce Motion Enabled"))
}
Toggle(isOn: $largeFont) {
Text(LocalizedStringKey("Large Font"))
}
Toggle(isOn: $receiveEmailNotifications) {
Text(LocalizedStringKey("Receive Notifications"))
}
}
.padding()
.font(.system(size: 22, weight: .light, design: .rounded))
}
}
Dealing with those toggles is pretty straight forward task, but it can become cumbersome the more you add.
A technique of reducing the amount of Bool values is to compact them into one storage unit. We can essentially convert them into a series of zeros and ones. Sounds familiar?
There are only 10 types of people in the world – those who understand binary, and those who don’t.
So if we were to convert the @State above to something more manageable we could use Swift's OptionSet
. They are very similar to enums
.
I'm sure you have set a reminder before. If you set the frequency of the reminder (Monday, Wednesday and Saturday) for example. You are essentially creating a set of 3 values.
This basic idea of being able to create a combination of desired values is perfect to remove the amount of boilerplate state we have in your view.
If we were to convert the above @State into an OptionSet we could do this:
struct Options: OptionSet {
var rawValue: UInt
static let none = Self([])
static let showBackground = Self(rawValue: 1 << 0)
static let showBorder = Self(rawValue: 1 << 1)
static let reduceMotionEnabled = Self(rawValue: 1 << 2)
static let largeFont = Self(rawValue: 1 << 3)
static let receiveEmailNotifications = Self(rawValue: 1 << 4)
}
Then in our views we could remove all the @State in favour of one.
@State var viewOptions = Options.none
If you look at the rawValue of the OptionSet, it's an UInt. You are essentially storing the union of values into 1 single storage value. (Series of 101001, on/off)
This is what your view would look like.
struct LessStateVariablesView: View {
@State var viewOptions = Options.none
var body: some View {
ZStack {
if $viewOptions.bindValue(.showBackground) {
Color.red.edgesIgnoringSafeArea(.all)
}
VStack {
Toggle(isOn: $viewOptions.bind(.showBackground)) {
Text(LocalizedStringKey("Show Background"))
}
Toggle(isOn: $viewOptions.bind(.showBorder)) {
Text(LocalizedStringKey("Show Border"))
}
Toggle(isOn: $viewOptions.bind(.reduceMotionEnabled)) {
Text(LocalizedStringKey("Reduce Motion Enabled"))
}
Toggle(isOn: $viewOptions.bind(.largeFont)) {
Text(LocalizedStringKey("Large Font"))
}
Toggle(isOn: $viewOptions.bind(.receiveEmailNotifications)) {
Text(LocalizedStringKey("Receive Notifications"))
}
}
.padding()
.font(.system(size: 22, weight: .light, design: .rounded))
}
}
}
As you can see, we've shifted all the previous @State and encapsulated into an OptionSet. The behaviour is exactly the same but instead of having 5 bools, we have 1 and all the true and false is stored as 1 single UInt.
Converting an OptionSet into Binding
Now that we have our options, we need a way of interacting with them in our SwiftUI View. If you wanted to use a toggle for example we need a Binding
.
A property wrapper type that can read and write a value owned by a source of truth.
Binding is a struct with a Generic value Value
, so we could write an extension in order to interact with our new OptionSet.
extension Binding where Value: OptionSet, Value == Value.Element {
func bindedValue(_ options: Value) -> Bool {
return wrappedValue.contains(options)
}
func bind(
_ options: Value,
animate: Bool = false
) -> Binding<Bool> {
return .init { () -> Bool in
self.wrappedValue.contains(options)
} set: { newValue in
let body = {
if newValue {
self.wrappedValue.insert(options)
} else {
self.wrappedValue.remove(options)
}
}
guard animate else {
body()
return
}
withAnimation {
body()
}
}
}
}
By doing so we can now plug it in our SwiftUI view.
We have now access to bindedValue
to pull out its Bool representation and also bind
where you can pass to the Toggle to update its value. There is also a convenient animate
option in order to animate its changes.
Removing Boiler Plate (Sourcery to the rescue)
Writing an OptionSet is fairly trivial, but I often forget the syntax and the correct rawValue
needed for each item.
I have created an Stencil
template file which can be used with Sourcery in order to make creating those OptionSet very simple and easy.
Sourcery is a code generator for Swift language, built on top of Apple's own SwiftSyntax. It extends the language abstractions to allow you to generate boilerplate code automatically.
Sourcery
What we can do now is to create an enum with your cases and make sure you conform to OptionsBinding
.
protocol OptionsBinding {}
enum MyEnum: OptionsBinding {
case showBackground
case showBorder
case reduceMotionEnabled
case largeFont
case receiveEmailNotifications
}
Sourcery will then extend that enum and add the equivalent OptionSet for you.
extension MyEnum {
struct Options: OptionSet {
var rawValue: UInt
static let none = Self([])
static let showBackground = Self(rawValue: 1 << 0)
static let showBorder = Self(rawValue: 1 << 1)
static let reduceMotionEnabled = Self(rawValue: 1 << 2)
static let largeFont = Self(rawValue: 1 << 3)
static let receiveEmailNotifications = Self(rawValue: 1 << 4)
}
}
Now you are free to use MyEnum.Options wherever you need.
I hope this will help some folks to reduce some of the Bool boilerplate in your views and also make it easy to reason with your options by having a nice enum to use.
Stencil template
{% for type in types.enums where type.cases.count > 0 and type.based.OptionsBinding or type|annotated:"OptionsBinding" %}
extension {{ type.name }} {
struct Options: OptionSet {
var rawValue: UInt
static let none = Self([])
{% for i in 0...type.cases.count %}
{% if not forloop.last %}
static let {{type.cases[i].name}} = Self(rawValue: 1 << {{i}})
{% endif %}
{% endfor %}
}
}
{% endfor %}
extension Binding where Value: OptionSet, Value == Value.Element {
func bindValue(_ options: Value) -> Bool {
return wrappedValue.contains(options)
}
func bind(
_ options: Value,
animate: Bool = false
) -> Binding<Bool> {
return .init { () -> Bool in
self.wrappedValue.contains(options)
} set: { newValue in
let body = {
if newValue {
self.wrappedValue.insert(options)
} else {
self.wrappedValue.remove(options)
}
}
guard animate else {
body()
return
}
withAnimation {
body()
}
}
}
}
Top comments (0)