Created
January 7, 2024 03:20
-
-
Save wildthink/8539ef4ade4c017b4a279430f91589c7 to your computer and use it in GitHub Desktop.
Swift Talk Episode 388 - Tweakable Values
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// Swift Talk Episode 388: | |
// [Tweakable Values: Finishing Up](https://talk.objc.io/episodes/S01E388-tweakable-values-finishing-up) | |
import SwiftUI | |
struct PreferenceValue: Equatable { | |
var initialValue: Any | |
var label: String | |
var edit: (String, Binding<Any>) -> AnyView | |
init<T>(initialValue: T, label: String, edit: @escaping (String, Binding<T>) -> AnyView) { | |
self.initialValue = initialValue | |
self.label = label | |
self.edit = { label, binding in | |
let b: Binding<T> = Binding(get: { binding.wrappedValue as! T }, set: { binding.wrappedValue = $0 }) | |
return edit(label, b) | |
} | |
} | |
static func ==(lhs: Self, rhs: Self) -> Bool { | |
return true // todo we can't compare closures | |
} | |
} | |
struct TweakablePreference: PreferenceKey { | |
static var defaultValue: [TweakableKey:PreferenceValue] = [:] | |
static func reduce(value: inout Value, nextValue: () -> Value) { | |
value.merge(nextValue(), uniquingKeysWith: { $1 }) | |
} | |
} | |
struct TweakableValuesKey: EnvironmentKey { | |
static var defaultValue: [TweakableKey: Any] = [:] | |
} | |
extension EnvironmentValues { | |
var tweakables: TweakableValuesKey.Value { | |
get { self[TweakableValuesKey.self] } | |
set { self[TweakableValuesKey.self] = newValue } | |
} | |
} | |
public protocol TweakableType { | |
associatedtype V: View | |
static func edit(label: String, binding: Binding<Self>) -> V | |
} | |
extension Double: TweakableType { | |
public static func edit(label: String, binding: Binding<Self>) -> some View { | |
Slider(value: binding, in: 0...300) { | |
let val = String(format: "%.1f", binding.wrappedValue) | |
let tx = "\(label) \(val)" | |
Text(tx) | |
} | |
} | |
} | |
extension Bool: TweakableType { | |
public static func edit(label: String, binding: Binding<Self>) -> some View { | |
Toggle(label, isOn: binding) | |
} | |
} | |
extension Color: TweakableType { | |
public static func edit(label: String, binding: Binding<Self>) -> some View { | |
ColorPicker(label, selection: binding) | |
} | |
} | |
struct TweakableKey: Hashable, Comparable { | |
var line: UInt8 | |
var column: UInt8 | |
var file: String | |
static func <(lhs: Self, rhs: Self) -> Bool { | |
if lhs.file < rhs.file { return true } | |
if lhs.file > rhs.file { return false } | |
if lhs.line < rhs.line { return true } | |
if lhs.line > rhs.line { return false } | |
if lhs.column < rhs.column { return true } | |
if lhs.column > rhs.column { return false } | |
return false | |
} | |
} | |
// MARK: Public Tweaker API | |
public extension View { | |
func tweaker() -> some View { | |
self.modifier(TweakableGUI()) | |
} | |
func tweak<Value: TweakableType, Output: View>(_ label: String, initialValue: Value, line: UInt8 = #line, column: UInt8 = #column, file: String = #file, @ViewBuilder content: @escaping (AnyView, Value) -> Output) -> some View { | |
let key = TweakableKey(line: line, column: column, file: file) | |
return modifier(Tweakable(label: label, initialValue: initialValue, edit: Value.edit, key: key, run: content)) | |
} | |
func tweakable<Value, Editor: View, Output: View>(_ label: String, initialValue: Value, line: UInt8 = #line, column: UInt8 = #column, file: String = #file, edit: @escaping (String, Binding<Value>) -> Editor, @ViewBuilder content: @escaping (AnyView, Value) -> Output) -> some View { | |
let key = TweakableKey(line: line, column: column, file: file) | |
return modifier(Tweakable(label: label, initialValue: initialValue, edit: edit, key: key, run: content)) | |
} | |
} | |
struct Tweakable<Value, Editor: View, Output: View>: ViewModifier { | |
var label: String | |
var initialValue: Value | |
var edit: (String, Binding<Value>) -> Editor | |
var key: TweakableKey | |
@ViewBuilder var run: (AnyView, Value) -> Output | |
@Environment(\.tweakables) var tweakables | |
func body(content: Content) -> some View { | |
run(AnyView(content), (tweakables[key] as? Value) ?? initialValue) | |
.transformPreference(TweakablePreference.self) { value in | |
value[key] = .init(initialValue: initialValue, label: label, edit: { AnyView(edit($0, $1)) }) | |
} | |
} | |
} | |
struct TweakableGUI: ViewModifier { | |
@State private var definitions: [TweakableKey: PreferenceValue] = [:] | |
@State private var values: [TweakableKey: Any] = [:] | |
func body(content: Content) -> some View { | |
content | |
.environment(\.tweakables, values) | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
.safeAreaInset(edge: .bottom) { | |
ScrollView { | |
VStack(alignment: .leading) { | |
ForEach(values.keys.sorted(), id: \.self) { key in | |
let b = Binding($values[key])! | |
let def = definitions[key]! | |
VStack(alignment: .leading) { | |
def.edit(def.label, b) | |
let filename = (key.file as NSString).lastPathComponent | |
Text("\(filename):\(key.line)") | |
.font(.caption) | |
.foregroundStyle(.secondary) | |
} | |
} | |
} | |
} | |
.frame(maxHeight: 200) | |
} | |
.onPreferenceChange(TweakablePreference.self, perform: { value in | |
values = value.mapValues { $0.initialValue } | |
definitions = value | |
}) | |
} | |
} | |
struct TweakableView<Value, Content: View>: View { | |
var label: String | |
var initialValue: Value | |
var edit: (String, Binding<Value>) -> AnyView | |
@ViewBuilder var content: (Value) -> Content | |
private var key: TweakableKey | |
@Environment(\.tweakables) private var tweakables | |
init<Editor: View>(_ label: String, initialValue: Value, file: String = #file, line: UInt8 = #line, column: UInt8 = #column, edit: @escaping (String, Binding<Value>) -> Editor, @ViewBuilder content: @escaping (Value) -> Content) { | |
self.label = label | |
self.initialValue = initialValue | |
self.edit = { AnyView(edit($0, $1)) } | |
self.content = content | |
self.key = .init(line: line, column: column, file: file) | |
} | |
var body: some View { | |
content((tweakables[key] as? Value) ?? initialValue) | |
.transformPreference(TweakablePreference.self) { value in | |
value[key] = .init(initialValue: initialValue, label: label, edit: { AnyView(edit($0, $1)) }) | |
} | |
} | |
} | |
extension TweakableView where Value: TweakableType { | |
init(_ label: String, initialValue: Value, file: String = #file, line: UInt8 = #line, column: UInt8 = #column, @ViewBuilder content: @escaping (Value) -> Content) { | |
self.init(label, initialValue: initialValue, edit: { Value.edit(label: $0, binding: $1) }, content: content) | |
} | |
} | |
// MARK: Preview | |
struct TweakerPreView: View { | |
var body: some View { | |
TweakableView("Content", initialValue: true) { value in | |
if value { | |
Text("Hello, world!") | |
} else { | |
Image(systemName: "globe") | |
} | |
} | |
.tweakable("alignment", initialValue: Alignment.center, edit: { title, binding in | |
HStack { | |
Button("Leading") { binding.wrappedValue = .leading } | |
Button("Center") { binding.wrappedValue = .center } | |
Button("Trailing") { binding.wrappedValue = .trailing } | |
} | |
}) { | |
$0.frame(maxWidth: .infinity, alignment: $1) | |
} | |
.tweak("padding", initialValue: 10) { | |
$0.padding($1) | |
} | |
.tweak("offset", initialValue: 10) { | |
$0.offset(x: $1) | |
} | |
.tweak("foreground color", initialValue: Color.white) { | |
$0.foregroundStyle($1) | |
} | |
.tweak("padding", initialValue: Color.blue) { | |
$0.background($1) | |
} | |
// .background(Color.blue) | |
.tweaker() | |
} | |
} | |
#Preview { | |
TweakerPreView() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment