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 }
}
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.