adamz.dev A weblog about code.

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.