Skip to content

Instantly share code, notes, and snippets.

@rygrob
Created February 6, 2025 23:38
Show Gist options
  • Save rygrob/f79093bc6dcae6af57ff64e280cd83c8 to your computer and use it in GitHub Desktop.
Save rygrob/f79093bc6dcae6af57ff64e280cd83c8 to your computer and use it in GitHub Desktop.
Type-erased Parameter System
//
// Parameters.swift
// ---
//
// Created by Ryan Robinson on 2/5/25.
//
import CoreGraphics
import Foundation
protocol ParameterValue {
associatedtype Value
var value: Value { get }
var defaultValue: Value { get }
mutating func setValue(_ value: Value)
mutating func restoreDefault()
}
class AnyParameter: ObservableObject, Identifiable {
//
let name: String
@Published private(set) var payload: Any
private var setValueAction: ((Any) -> Void)? = nil
private var restoreDefaultAction: (() -> Void)? = nil
// Dynamic member lookup
var key: String {
name.camelCase()
}
init<T: ParameterValue>(name: String, _ value: T) {
self.name = name
self.payload = value
self.setValueAction = { [weak self] value in
guard let self else { return }
if var typed = payload as? T, let x = value as? T.Value {
typed.setValue(x)
payload = typed // Force refresh.
}
}
self.restoreDefaultAction = { [weak self] in
guard let self else { return }
if var typed = payload as? T {
typed.restoreDefault()
payload = typed // Force refresh.
}
}
}
func setValue(_ value: Any) {
setValueAction?(value)
}
func restoreDefault() {
restoreDefaultAction?()
}
}
// MARK: - Helpers
extension AnyParameter {
static func float(name: String, value: CGFloat, range: ClosedRange<CGFloat>, defaultValue: CGFloat) -> AnyParameter {
return .init(name: name, FloatValue(value: value, range: range, defaultValue: defaultValue))
}
func asFloat() -> CGFloat? {
return (payload as? FloatValue)?.value
}
static func string(name: String, value: String, defaultValue: String) -> AnyParameter {
return .init(name: name, StringValue(value: value, defaultValue: defaultValue))
}
func asString() -> String? {
return (payload as? StringValue)?.value
}
static func color(name: String, value: CGColor, defaultValue: CGColor) -> AnyParameter {
return .init(name: name, ColorValue(value: value, defaultValue: defaultValue))
}
func asColor() -> CGColor? {
return (payload as? ColorValue)?.value
}
static func indexed(name: String, value: Int, valueStrings: [String], defaultValue: Int) -> AnyParameter {
return .init(name: name, IndexedValue(value: value, valueStrings: valueStrings, defaultValue: defaultValue))
}
func asIndex() -> Int? {
return (payload as? IndexedValue)?.value
}
}
// MARK: - Values
struct FloatValue: ParameterValue {
var value: CGFloat
let range: ClosedRange<CGFloat>
let defaultValue: CGFloat
init(value: CGFloat, range: ClosedRange<CGFloat>, defaultValue: CGFloat) {
self.value = value
self.range = range
self.defaultValue = defaultValue
}
mutating func setValue(_ value: CGFloat) {
self.value = value
}
mutating func restoreDefault() {
value = defaultValue
}
}
struct StringValue: ParameterValue {
var value: String
let defaultValue: String
init(value: String, defaultValue: String) {
self.value = value
self.defaultValue = defaultValue
}
mutating func setValue(_ value: String) {
self.value = value
}
mutating func restoreDefault() {
value = defaultValue
}
}
struct ColorValue: ParameterValue {
var value: CGColor
let defaultValue: CGColor
init(value: CGColor, defaultValue: CGColor) {
self.value = value
self.defaultValue = defaultValue
}
mutating func setValue(_ value: CGColor) {
self.value = value
}
mutating func restoreDefault() {
value = defaultValue
}
}
struct IndexedValue: ParameterValue {
var value: Int
let valueStrings: [String]
let defaultValue: Int
init(value: Int, valueStrings: [String], defaultValue: Int) {
self.value = value
self.valueStrings = valueStrings
self.defaultValue = defaultValue
}
mutating func setValue(_ value: Int) {
self.value = value
}
mutating func restoreDefault() {
value = defaultValue
}
}
// MARK: - Groups
@dynamicMemberLookup
class ParameterGroup: Identifiable {
//
let name: String
let parameters: [AnyParameter]
var key: String {
name.camelCase()
}
init(name: String, parameters: [AnyParameter]) {
self.name = name
self.parameters = parameters
}
// Dynamic member lookup
subscript(dynamicMember member: String) -> AnyParameter? {
return parameters.first { $0.key == member }
}
}
@dynamicMemberLookup
class AllParameters {
//
let groups: [ParameterGroup]
init(groups: [ParameterGroup]) {
self.groups = groups
}
// Dynamic member lookup
subscript(dynamicMember member: String) -> ParameterGroup? {
return groups.first { $0.key == member }
}
}
//
// ParametersView.swift
// ---
//
// Created by Ryan Robinson on 1/10/25.
//
import SwiftUI
struct ParametersView<E: Editor>: View {
//
let editor: E
var body: some View {
VStack {
ForEach(editor.params.groups) { group in
VStack {
ForEach(group.parameters) { parameter in
ParameterView(parameter)
.padding([.leading, .trailing])
.padding([.top, .bottom], 4)
}
}
}
}
}
}
// MARK: - ParameterView
struct ParameterView: View {
@ObservedObject var parameter: AnyParameter
@Environment(\.self) var environment
init(_ parameter: AnyParameter) {
self.parameter = parameter
}
var body: some View {
ZStack {
switch parameter.payload {
case let payload as FloatValue:
let binding: Binding<CGFloat> = .init(
get: { parameter.asFloat() ?? 0 },
set: { parameter.setValue($0) }
)
HStack(spacing: 0) {
let w: CGFloat = 42
HStack {
Text(parameter.name)
Spacer()
}
.frame(width: 1.5 * w)
Slider(value: binding, in: payload.range)
}
case _ as StringValue:
let binding: Binding<String> = .init(
get: { parameter.asString() ?? "" },
set: { parameter.setValue($0) }
)
HStack {
Text(parameter.name)
TextField(parameter.name, text: binding)
}
case _ as ColorValue:
let binding: Binding<Color> = .init(
get: { Color(cgColor: parameter.asColor() ?? .red) },
set: { color in
let resolved = color.resolve(in: environment)
parameter.setValue(resolved.cgColor)
}
)
HStack {
ColorPicker(selection: binding) {
HStack {
Text(parameter.name)
Spacer()
}
}
}
case let payload as IndexedValue:
let binding: Binding<Int> = .init(
get: { parameter.asIndex() ?? 0 },
set: { parameter.setValue($0) }
)
Picker(parameter.name, selection: binding) {
ForEach(Array(payload.valueStrings.indices), id: \.self) { index in
Text(payload.valueStrings[index])
.tag(index)
}
}
default:
EmptyView()
}
}
.contentShape(.rect)
.contextMenu {
Button {
parameter.restoreDefault()
} label: {
Text("Reset to Default")
}
}
}
}
#Preview {
VStack {
ParameterView(.string(name: "Text", value: "Hello", defaultValue: "Hello"))
ParameterView(.color(name: "Color", value: .red, defaultValue: .red))
ParameterView(.float(name: "Amount", value: 0, range: 0...1, defaultValue: 0))
ParameterView(.indexed(name: "Quality", value: 0, valueStrings: ["Low", "Med.", "High"], defaultValue: 0))
}
.padding()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment