This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We discuss the basic approach to implementing tweakable values in SwiftUI.

00:06 We recently got a question from somebody who wanted to be able to interactively tweak some values in their large SwiftUI app. Ideally, they would append a modifier to, for example, a padding value — like .padding(10.tweakable) — and it would give them a slider to play with the padding.

00:37 If our app has an elaborate architecture, including a stylesheet with values that can be observed, it's possible to build this. But if we don't have that infrastructure in place and we want to quickly tweak some value while our designer is at our desk, then it's going to be difficult.

API

01:00 Although we can't make the proposed API work, we can build something similar where our tweakable value is defined locally in our view, and we don't have to go to a global stylesheet structure to make the changes.

01:17 So let's set up a view with a blue background and some padding:

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding(10)
            .background(Color.blue)
    }
}

01:40 We could create a modifier that would let us write 10.tweakable here, and it could use some shared observable object to store the tweakable value, but the problem is that we also need to rerender ContentView when that value changes. And that's the part of this API that's difficult to make work.

02:20 First of all, the value modifier would be specific to the type of value we're using — we'd need extensions for Double, CGFloat, Int, Color, etc. And even if we did all of that, we'd also need to write a custom variant of padding (and every other view modifier we want to tweak values of) that observes some global store of tweaked values.

02:47 So, what we're going to try instead is to just write a tweakable view modifier that takes an initial value and a closure. The closure receives the underlying view and the tweaked value, and it can use this value to apply any modifier to the view:

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .tweakable(initialValue: 10) {
                $0.padding($1)
            }
            .background(Color.blue)
    }
}

03:38 And since we want to present some UI to tweak the value, we'll probably also need a label for the value:

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .tweakable("padding", initialValue: 10) {
                $0.padding($1)
            }
            .background(Color.blue)
    }
}

03:48 Let's first write the outline of the tweakable helper function, and then we'll implement it with a view modifier. The function takes a label, an initial value, and a view builder closure, and it's generic over the type of view the closure returns:

extension View {
    func tweakable<Output: View>(_ label: String, initialValue: CGFloat, @ViewBuilder content: @escaping (CGFloat) -> Output) -> some View {

    }
}

04:56 We'll write a Tweakable view modifier that we apply in this function. The idea is that this modifier propagates the label and the value up the view tree as a preference. Somewhere at the top of the view tree, we pick this value up, we show some UI for it, and we send the value back down through the environment so that the Tweakable modifier can pass it to the content closure.

05:36 We give the view modifier properties to store all parameters of the tweakable function:

extension View {
    func tweakable<Output: View>(_ label: String, initialValue: CGFloat, @ViewBuilder content: @escaping (CGFloat) -> Output) -> some View {
        modifier(Tweakable(label: label, initialValue: initialValue, run: content))
    }
}

struct Tweakable<Output: View>: ViewModifier {
    var label: String
    var initialValue: CGFloat
    @ViewBuilder var run: (CGFloat) -> Output

    func body(content: Content) -> some View {
        content // todo
    }
}

07:20 But there's something missing from our API; the content closure needs to receive both a view and a tweaked value. That means we need another generic parameter for the input view's type:

extension View {
    func tweakable<Input: View, Output: View>(_ label: String, initialValue: CGFloat, @ViewBuilder content: @escaping (Input, CGFloat) -> Output) -> some View {
        modifier(Tweakable(label: label, initialValue: initialValue, run: content))
    }
}

struct Tweakable<Input: View, Output: View>: ViewModifier {
    var label: String
    var initialValue: CGFloat
    @ViewBuilder var run: (Input, CGFloat) -> Output
    @Environment(\.tweakables) var tweakables

    func body(content: Content) -> some View {
        run(content, initialValue)
    }
}

08:58 This doesn't quite work though. What we're passing to run is of type Content. So the Tweakable modifier doesn't actually need a separate type parameter for the input view:

struct Tweakable<Output: View>: ViewModifier {
    var label: String
    var initialValue: CGFloat
    @ViewBuilder var run: (Content, CGFloat) -> Output

    func body(content: Content) -> some View {
        run(content, initialValue)
    }
}

09:20 This moves the problem to the tweakable function, because now we can't pass the closure into the view modifier. That's because Content isn't actually a generic type parameter, but rather an associated type of the ViewModifier protocol — basically, it's an opaque wrapper around AnyView. So we can wrap the content view in an AnyView and pass it in that way:

extension View {
    func tweakable<Output: View>(_ label: String, initialValue: CGFloat, @ViewBuilder content: @escaping (AnyView, CGFloat) -> Output) -> some View {
        modifier(Tweakable(label: label, initialValue: initialValue, run: content))
    }
}

struct Tweakable<Output: View>: ViewModifier {
    var label: String
    var initialValue: CGFloat
    @ViewBuilder var run: (AnyView, CGFloat) -> Output

    func body(content: Content) -> some View {
        run(AnyView(content), initialValue)
    }
}

10:02 We might be able to write this without AnyView, but it makes things a lot simpler for now.

Propagating Tweakable Values Up

10:36 The next step is to propagate the tweakable value up so that we get a chance to tweak it elsewhere. Since we have both a label and a value, we can package them both in a dictionary. So, we write a preference key that uses an empty dictionary as its default value. The key's reduce function needs to combine two dictionaries from sibling views. We implement this by merging the dictionaries, and if they contain the same label, we just take the value from the second dictionary:

struct TweakablePreference: PreferenceKey {
    static var defaultValue: [String: CGFloat] = [:]
    static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) {
        value.merge(nextValue(), uniquingKeysWith: { $1 })
    }
}

11:33 Now we can set the preference in the view modifier:

struct Tweakable<Output: View>: ViewModifier {
    var label: String
    var initialValue: CGFloat
    @ViewBuilder var run: (AnyView, CGFloat) -> Output

    func body(content: Content) -> some View {
        run(AnyView(content), tweakables[label] ?? initialValue)
            .preference(key: TweakablePreference.self, [label: initialValue])
    }
}

12:03 We're propagating the dictionary up the view tree, so we should be able to pick this up at the top and show a view with labels and sliders, for example. We can do this with another view modifier we call in ContentView:

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .tweakable("padding", initialValue: 10) {
                $0.padding($1)
            }
            .background(Color.blue)
            .modifier(TweakableGUI())
    }
}

12:33 This modifier adds a frame around its content view, and it places the extra UI in the safe area inset. Using onPreferenceChange, we can pick up the tweakable values dictionary:

struct TweakableGUI: ViewModifier {
    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .safeAreaInset(edge: .bottom) {
                Text("Display GUI")
            }
            .onPreferenceChange(TweakablePreference.self, perform: { value in
                print(value)
            })
    }
}

14:02 We assign the dictionary to a state variable so that we can do something with it:

struct TweakableGUI: ViewModifier {
    @State private var values: [String: CGFloat] = [:]

    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .safeAreaInset(edge: .bottom) {
                Text("Display GUI")
            }
            .onPreferenceChange(TweakablePreference.self, perform: { value in
                values = value
            })
    }
}

14:48 We replace the text view placeholder with a Form with a slider for each of the values in the dictionary:

struct TweakableGUI: ViewModifier {
    @State private var values: [String: CGFloat] = [:]

    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .safeAreaInset(edge: .bottom) {
                Form {
                    ForEach(values.keys.sorted(), id: \.self) { key in
                        Slider(value: $values[key], in: 1...100, label: { Text(key) })
                    }
                }
                .frame(maxHeight: 200)
            }
            .onPreferenceChange(TweakablePreference.self, perform: { value in
                values = value
            })
    }
}

17:02 Because we use a subscript to get the value out of the dictionary, we end up with a binding to an optional CGFloat?, but the slider expects a binding to a CGFloat. There's an initializer on Binding that takes another binding, and we can use this to get an optional binding, i.e. Binding<CGFloat>?. We know that the value is present in the dictionary because we're looping over the dictionary's keys, so we can force-unwrap this optional binding:

struct TweakableGUI: ViewModifier {
    @State private var values: [String: CGFloat] = [:]

    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .safeAreaInset(edge: .bottom) {
                Form {
                    ForEach(values.keys.sorted(), id: \.self) { key in
                        let b = Binding($values[key])
                        Slider(value: b!, in: 1...100, label: { Text(key) })
                    }
                }
                .frame(maxHeight: 200)
            }
            .onPreferenceChange(TweakablePreference.self, perform: { value in
                values = value
            })
    }
}

18:59 Next, we need to make sure to propagate the value we tweak using our slider back down to our padding modifier. That's a job for the environment.

Propagating Tweaked Values Down

19:16 We write an environment key to send the tweakable values down the view tree. We could also reuse TweakablePreference for this by conforming it to EnvironmentKey, but if we later choose to make the UI customizable, these keys will have different value types, so let's separate them:

struct TweakableValuesKey: EnvironmentKey {
    static var defaultValue: [String:CGFloat] = [:]
}

19:57 Then we need to extend EnvironmentValues with a computed property to access the tweakable values using the key above:

extension EnvironmentValues {
    var tweakables: TweakableValuesKey.Value {
        get { self[TweakableValuesKey.self] }
        set { self[TweakableValuesKey.self] = newValue }
    }
}

20:41 In TweakableGUI, we pass the values state property to the environment so that the content view receives the dictionary:

struct TweakableGUI: ViewModifier {
    @State private var values: [String: CGFloat] = [:]

    func body(content: Content) -> some View {
        content
            .environment(\.tweakables, values)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .safeAreaInset(edge: .bottom) {
                Form {
                    ForEach(values.keys.sorted(), id: \.self) { key in
                        let b = Binding($values[key])
                        Slider(value: b!, in: 1...100, label: { Text(key) })
                    }
                }
                .frame(maxHeight: 200)
            }
            .onPreferenceChange(TweakablePreference.self, perform: { value in
                values = value
            })
    }
}

21:10 Finally, the Tweakable modifier can get the tweakable values dictionary from the environment and pass the value for its label key to the run closure. We use the nil-coalescing operator to default to the initial value if the dictionary doesn't contain a value for the label:

struct Tweakable<Output: View>: ViewModifier {
    var label: String
    var initialValue: CGFloat
    @ViewBuilder var run: (AnyView, CGFloat) -> Output
    @Environment(\.tweakables) var tweakables

    func body(content: Content) -> some View {
        run(AnyView(content), tweakables[label] ?? initialValue)
            .preference(key: TweakablePreference.self, [label: initialValue])
    }
}

Tweaking a Second Value

21:42 Let's see what happens if we add a second tweakable value to our view:

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .tweakable("padding", initialValue: 10) {
                $0.padding($1)
            }
            .tweakable("offset", initialValue: 10) {
                $0.offset(x: $1)
            }
            .background(Color.blue)
            .modifier(TweakableGUI())
    }
}

22:19 When we run this, we still get only one slider, because of the way we're setting the preference value in Tweakable:

.preference(key: TweakablePreference.self, [label: initialValue])

22:58 Here, we might be overwriting a dictionary that can already contain values from further down the view tree. So rather than preference, we should use a modifier that transforms the preference value:

struct Tweakable<Output: View>: ViewModifier {
    var label: String
    var initialValue: CGFloat
    @ViewBuilder var run: (AnyView, CGFloat) -> Output
    @Environment(\.tweakables) var tweakables

    func body(content: Content) -> some View {
        run(AnyView(content), tweakables[label] ?? initialValue)
            .transformPreference(TweakablePreference.self) { value in
                value[label] = initialValue
            }
    }
}

23:40 Now we still don't have labels for our sliders, but we get a slider for each of the two tweakable values, enabling us to change both the offset and the padding of our view:

23:50 This is a good demonstration of how the tweakable API will work. Now the real work starts to make this work with different types of values and not just CGFloat. It'd be nice if we could also tweak colors by providing an appropriate interface for doing so. Let's look at that next time.

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

140 Episodes · 49h57min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes