MirrorUI uses reflection to construct a UI to edit an object's properties. It's a great way to quickly add controls to your demos, experiments and prototypes.
Simply add the @MirrorUI
property wrapper and MirrorUI will construct a UI for you. You can then edit your object directly whilst your app runs.
The easiest way to integrate MirrorUI into your project is through the Swift Package Manager.
Create a class with the fields that you want to manipulate. Use the @MirrorUI
property wrapper to expose these to MirrorUI.
You might like to make this a singleton so that you can access it from other places in your application.
class Settings {
static let shared = Settings()
@MirrorUI var blurEnabled = true
@MirrorUI var lives = 4
@MirrorUI var startingHealth = 12.5
@MirrorUI(range: 0...20) var damage = 5.3
@MirrorUI var greeting = "Welcome"
}
Then, simply construct a MirrorView
with the instance of your object. You can present this SwiftUI view however you like. As a modal, for instance.
let mirrorView = MirrorView(object: Settings.shared)
Any changes made will be reflected in the properties of your object. You can read these properties as normal throughout the rest of your application.
To be able to present UI to edit a type, MirrorUI needs to have a 'mapping' to a view for that type. Several common types are supported out of the box:
- String
- Bool
- Int and other FixedWidthInteger types
- Double
- Float
- SwiftUI.Color
- CGFloat
- CGPoint
- CGSize
- CGRect
- Enums*
*Enums must conform to CaseIterable
. Enums that contain cases with associated values will require bespoke view mappings.
If using SwiftUI
or Combine
, simply conform your object to ObservableObject
and MirrorUI
will publish changes for @MirrorUI
properties in the same way that @Published
does.
Properties using the @MirrorUI
property wrapper will not trigger callbacks through property observers such as didSet
.
Instead, there is a didSet
closure on the property wrapper that you can set to receive a callback when the value is updated:
class Settings {
@MirrorUI var blurEnabled = true
init() {
$blurEnabled.didSet = { newValue in
// perform update
}
}
}
Some types have additional configuration.
Comparable types can be given minimum and maximum values:
class Settings {
@MirrorUI var lives = 10
init() {
$lives.min = 0
$lives.max = 99
}
}
Any type can be supported in MirrorUI, provided there is a view mapping for that type. You can add mappings for additional types by registering a ViewMapping
with the ViewMapper
.
Create a custom ViewMapping
instance for your type. The ViewMapping is passed an instance of the type to display/modify. This is wrapped in a Ref<T>
container, and can be read/written through ref.value
. The following example creates a Picker
view to select between small / medium / large cases in a Size
enum:
// Our custom type
enum Size: Int {
case small
case medium
case large
}
// A mapping for the Size type
let sizeMapping = ViewMapping(for: Size.self) { (ref, context) -> AnyView in
let binding = Binding(get: { ref.value.rawValue },
set: { ref.value = Size(rawValue: $0)! })
let view = VStack {
Text(context.propertyName)
Picker(context.propertyName, selection: binding) {
Text("Small").tag(0)
Text("Medium").tag(1)
Text("Large").tag(2)
}
}
return AnyView(view)
}
Note that it will be required to write a custom Binding<T>
for the value. Writing to the value will trigger SwiftUI to update the view. Most mappings show the property name, this can be found in context.propertyName
.
Simply register the mapping with the ViewMapper, and it will map to any fields of the matching type.
ViewMapper.shared.add(mapping: sizeMapping)
In some instances, the view may also need to save additional state of its own. For instance, there may be multiple display modes the view can be in, or there may be input that isn't committed immediately. In this case, there is a state dictionary that the view can read and write from to store this information.
The following mapping provides a text field for editing a String
. The value is only updated on onCommit
. During editing the partial value is stored in the state dictionary until it is ready to commit back to the object. The state dictionary can be accessed via context.state.value
.
let stringMapping = ViewMapping(for: String.self) { ref, context in
var partial: String {
get { context.state.value["text"] as? String ?? ref.value }
set { context.state.value["text"] = newValue }
}
let binding = Binding(get: { partial },
set: { partial = $0 })
let view = HStack {
Text("\(context.propertyName):")
TextField(context.propertyName, text: binding, onCommit: {
ref.value = partial
context.state.value["text"] = nil
})
}
return AnyView(view)
}
MirrorUI uses reflection through Swift's Mirror
api to understand which fields an object has, and what types those fields are. The @MirrorUI
property wrapper wraps each field in a Ref<T>
type container, which gives each property reference semantics, even if the underlying type is a value type.
A ViewMapping
is held for each supported property type in the ViewMapper
. If MirrorView
is able to match a mapping to a property, then it will display that view. Because each property has reference semantics, it's possible to pass each view a Ref<T>
containing the value of that property that it can then modify.
Steve Barnegren
MirrorUI is available under the MIT license. See the LICENSE file for more info.