Conditional SwiftUI Configuration
(Without half-assing it.)
The Problem
It would be nice to be able to tweak SwiftUI configuration chains inline:
struct ConciseView: View {
@State var isBad: Bool
@State var isLoud: Bool
var body: some View {
MyLabel()
.conditionally(if: isLoud) { $0.fontWeight(.bold) }
.conditionally(if: isBad) { $0.background(.red) }
}
}
…Instead of being forced to use fat if statements…
struct VerboseView: View {
@State var isBad: Bool
@State var isLoud: Bool
var body: some View {
if isBad && isLoud {
MyLabel()
.fontWeight(.bold)
.background(.red)
} else if !isBad && isLoud {
MyLabel()
.fontWeight(.bold)
} else if isBad && !isLoud {
MyLabel()
.background(.red)
} else {
MyLabel()
}
}
}
...and to do so while maintaining the compiler's knowledge of the
view's structural information (i.e. it'a type) — instead of erasing it with an AnyView
wrapper.
AnyView
would otherwise open the door to lots of ways of
write this code. Some more obviously silly than others...
struct ConciseView: View {
@State var isBad: Bool
@State var isLoud: Bool
var body: some View {
var v = AnyView(MyLabel())
if isLoud {
v = AnyView(v.fontWeight(.bold))
}
if isBad {
v = AnyView(v.background(.red))
}
return v // return disables ViewBuilder behavior
}
}
The Solution
import SwiftUI
extension View {
/// Conditionally make a change to a view in a modifier chain
///
/// - Parameter if: the condition required to make the change
/// - Parameter modify: the change to make
public func conditionally(if condition: Bool, @ViewBuilder _ modify: @escaping (_ content: Self) -> some View) -> some View {
EitherView(condition: condition, input: self, modify: modify)
}
/// Make a change to a view in a modifier chain iff an optional value is available
///
/// - Parameter ifLet: the value required for the change to be made
/// - Parameter modify: the change to make
public func conditionally<V: View, P>(ifLet payload: P?, @ViewBuilder _ modify: @escaping (_ content: Self, _ value: P) -> V) -> some View {
EitherView(input: self, payload: payload, build: modify)
}
}
enum EitherView<V1: View, V2: View>: View {
init(condition: Bool, input: V1, @ViewBuilder modify: (V1)->V2) {
if condition {
self = .v1(input)
} else {
self = .v2(modify(input))
}
}
init<P>(input: V1, payload: P?, @ViewBuilder build: (V1, P)->V2) {
if let payload {
self = .v2(build(input, payload))
} else {
self = .v1(input)
}
}
case v1(V1)
case v2(V2)
var body: some View {
switch self {
case .v1(let v1): v1
case .v2(let v2): v2
}
}
}
The details
EitherView
avoids type-erasing the view by embedding its potential
cases from each side of the branch in its generic parameters.
It maintains that type/structure even across branch changes.
This means SwiftUI can work with (this part of) our view hierarchy without having to resort to runtime dynamic lookups to determine that views have changed.
This in turn can sometimes yeild performance improvements.
An AnyView allows changing the type of view used in a given view hierarchy. Whenever the type of view used with an AnyView changes, the old hierarchy is destroyed and a new hierarchy is created for the new type. Apple Docs
How often does this actually matter?
Tough to say + hard to measure + it depends.
AnyView
has a purpose. This description of AnyView's value
is solidly worth a read.
Nevertheless, given some sufficiently large amount of forced-dynamism casually sprinkled through
a view hierarchy we'd eventually encounter substantive performance issues.
Avoiding AnyView
when possible — and especially in utility methods that could end up being used liberally throughout
your app — seems prudent. And it's possible here.