Using SwiftUI Property Wrappers Outside of Views
SwiftUI property wrappers are designed to be used within Views. When used outside of a View implementation, they may cause a runtime error such as:
Accessing Environment's value outside of being installed on a View. This will always read the default value and will not update.
However, there is a technique that allows us to use these property wrappers to build views in ways other than directly implementing the View
protocol. In this post, we will build a simple dashboard system that renders widgets in either a large or small configuration.
Designing a Flexible Dashboard API
Imagine setting up a dashboard like so:
DashboardView {
EmailWidget()
BalanceWidget()
}
We will not delve into the details of how this initialization works, but the key takeaway is that it is a dashboard displaying two widgets. At first glance, the widgets appear to be simple views—and they could be—but a problem arises: how do they determine whether to render in a small or large configuration? One option is to read this information from the environment, but that would make it a global property across the entire app, which doesn’t seem appropriate. Furthermore, what happens when the configuration expands beyond just size? We need a way for widgets to retrieve their dependencies directly from the dashboard.
To solve this, instead of using standard views, widgets will conform to our own protocol:
protocol DashboardWidget: DynamicProperty {
associatedtype Body: View
@MainActor @ViewBuilder
func body(size: Size) -> Body
}
This protocol is similar to ButtonStyle and related, as it defines a method for rendering the body rather than using a property. Additionally, our protocol extends DynamicProperty
, a key component of SwiftUI’s update mechanism. Apple’s documentation defines it as "An interface for a stored variable that updates an external property of a view." While this may seem abstract at first, its core purpose is ensuring that properties within Views update dynamically inside the View hierarchy. Essentially, this allows us to use SwiftUI property wrappers outside of Views. To learn more, others have explored DynamicProperty
in greater detail.
Defining a Widget
The advantage of using a protocol with a method instead of a View
’s body
property is that we can specify parameters for the method, making our API more flexible and composable.
Here’s how we define a widget:
struct EmailWidget: DashboardWidget {
@Environment(\.colorScheme) private var scheme
func body(size: Size) -> some View {
// A widget that depends on the color scheme and the size
}
}
Our dashboard can then render this widget in its body
:
var body: some View {
ForEach(widgets) { widget in
widget.body(size: .large)
}
}
Although this will render our widget, SwiftUI will still throw the same error regarding environment access—even though we have conformed DashboardWidget
to DynamicProperty
.
Installing on a View
Examining the error message again: "outside of being installed on a View" — we need to install it on a View. However, what it does not say is that we also need to resolve it to a concrete type.
We can achieve this by introducing an intermediate View:
struct AnyDashboardWidgetView: View {
let body: AnyView
init<W: DashboardWidget>(widget: W, size: Size) {
self.body = AnyView(
Resolver(widget: widget, size: size)
)
}
private struct Resolver<W: DashboardWidget>: View {
let widget: W
let size: Size
var body: some View {
widget.body(size: size)
}
}
}
By wrapping our widgets in AnyDashboardWidgetView
, we ensure they are properly installed within the View hierarchy, allowing SwiftUI’s property wrappers to function correctly. Now, our dashboard must use this intermediate View:
var body: some View {
ForEach(widgets) { widget in
AnyDashboardWidgetView(
widget: widget,
size: .large
)
}
}
And we are good to go.
Summary
In this post, we have explored how we can model views with dependencies in better ways than using the View
protocol. In doing so, we have learned how to use DynamicProperty
to enable property wrappers to work outside of views and ensure that our widgets fit nicely with how we usually write SwiftUI.