AnyView is Pretty Great, Actually
AnyView
doesn't have a good reputation in the developer community.
I have seen people claim it is code smell and generally should be avoided at all costs. Apple themselves cast it as the “evil nemesis” in the widely-watched Demystify SwiftUI WWDC session back in 2021.
Despite having the same purpose, this is not the case for AnyPublisher
in Combine or even for its SwiftUI siblings AnyShape
, AnyLayout
, AnyTransition
.
The purpose of AnyView is to erase the type information of the view inside it. If that's what you want to achieve, it's totally okay to use it.
AnyView and SwiftUI internals
Case in point: AnyView is used extensively in SwiftUI itself.
Let's try running a simple example that shows a navigation container with NavigationStack and a modal presentation .sheet.
struct ContentView: View {
@State var isPresented = false
var body: some View {
NavigationStack {
List {
NavigationLink("Screen 1", value: "Screen 1")
}
.navigationDestination(for: String.self) { string in
Button("Screen 2") {
isPresented.toggle()
}
.sheet(isPresented: $isPresented) {
Text("Screen 2")
}
.navigationTitle(string)
}
}
}
}
If we check the view hierarchy in Xcode, we can easily see that SwiftUI translates both content views into UIHostingController
s that have the root view type of AnyView
.
Not convinced? Have you ever used Xcode Previews?
That's right, as far as Xcode Previews for SwiftUI are concerned, static typing is just an illusion. Try running this:
struct ContentView: View {
var body: some View {
Text(verbatim: "\(type(of: ChildView().body))")
}
}
struct ChildView: View {
var body: some View {
Text("I am text")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
When run in a simulator or on device, this will show “Text”. For previews, this will be “AnyView”.
It is this way because AnyView
is defined as the designated type eraser for the View
protocol (with a hidden underscored attribute @_typeEraser
). In practice, this means that any methods and computed properties returning an opaque type, when marked as dynamic
, will actually have AnyView
as their return type. And marking all your code as dynamic is exactly what previews do to enable hot reloading with dynamic replacements.
dynamic var text: some View {
Text("Hello")
}
type(of: text) // always AnyView
What does AnyView do?
Circling back to what AnyView
actually does, let's examine an example where using it instead of a @ViewBuilder
matters.
struct CounterView: View {
@State var i = 0
var body: some View {
Button("Increment: \(i)") {
i += 1
}
.foregroundColor(.white)
.padding()
}
}
Here we have a CounterView
that shows a number that is incremented each time you tap on it. Now let's add another view that will present this counter with either a purple or a mint color background, with a button to toggle between them.
struct ContentView: View {
@State var flag = true
var body: some View {
VStack {
content
Button("Toggle") {
withAnimation(.default.speed(1/5)) {
flag.toggle()
}
}
}
}
@ViewBuilder var content: some View {
if flag {
CounterView()
.background(Color.purple)
.transition(.scale.combined(with: .opacity))
} else {
CounterView()
.background(Color.mint)
.transition(.scale.combined(with: .opacity))
}
}
}
While, strictly speaking, making the whole view conditional is not necessary here, we do it this way specifically to show the difference for
AnyView
.
The main point is what is inside the content
property. Depending on the value of flag
, we return either one of two possible views. They are almost exactly identical, with the only difference being the background color. By structuring the property as a conditional, we encode our intention that those are two separate views. As such, there will be a removal and an insertion transition when you toggle between them.
Consider now what happens if we choose to use AnyView
.
var content: AnyView {
if flag {
return AnyView(
CounterView()
.background(Color.purple)
.transition(.scale.combined(with: .opacity))
)
} else {
return AnyView(
CounterView()
.background(Color.mint)
.transition(.scale.combined(with: .opacity))
)
}
}
Now, nothing about content
having a conditional structure is reflected in the return type. Moreover, since the actual type wrapped inside AnyView
is the same for both values, SwiftUI judges them to have the same structural identity, that is, to be the same view. Instead of transitioning between views, the toggle button will update the shown view while keeping the counter state and animating the background.
Actual view type ends up being
ModifiedContent<ModifiedContent<CounterView, _BackgroundStyleModifier<Color>>, _TraitWritingModifier<TransitionTraitKey>>
.
There are several important observations to make.
- We can match the
@ViewBuilder
behavior by giving our views different explicit identities, for example by tacking on.id(0)
and.id(1)
modifiers, respectively. - SwiftUI is smart enough to keep track of what's inside
AnyView
and apply updates to view state instead of recreating it, as long as we keep wrapping the same type. - We get different results because we happen to wrap views of the same exact type in
AnyView
. If they are different at all — oneColor
is replaced withGradient
, for example — the structural identity of the view inside will change, and we get transitions back. - If we so choose, we can wrap the whole
flag
conditional inAnyView
to match our intention of returning separate views. (We would need to first build the conditional with@ViewBuilder
and then wrap the result.)
Let's see how the behavior changes after we add explicit identities.
var content: AnyView {
if flag {
return AnyView(
CounterView()
.background(Color.purple)
.transition(.scale.combined(with: .opacity))
.id(0)
)
} else {
return AnyView(
CounterView()
.background(Color.mint)
.transition(.scale.combined(with: .opacity))
.id(1)
)
}
}
As expected, transitions are back. Even the case when we toggle in succession before transition animation completes is handled correctly: we restore the view with previous state.
We can see that identity preservation, identity change, animations, transitions, and state handling work without a problem with AnyView
. There isn't even really a hit in performance.
In fact, the only thing that changes is that we lose structure information of the function that directly returns the AnyView
. But instead of being a downside, this is the desired effect of using a type eraser.
AnyView
is good — use it
Comparing AnyView
with @ViewBuilder
doesn't paint the whole picture. Sometimes, it's not about matching the result type; sometimes, what you need is to actually erase the type, and there is nothing other than AnyView
that is appropriate.
Consider a Router
type that needs to store its destinations somehow:
struct Router<Destination: Hashable> {
var routes: [Destination: () -> AnyView]
}
Or, a protocol that has a function returning a view while taking some content in an argument:
protocol ListFactory {
// associatedtype ListView
// func makeList<Content: View>(@ViewBuilder content: () -> Content) -> ListView
// Swift doesn't have generic protocols, so the above is not possible.
// Type of ListView needs to be constant to implement the protocol.
func makeList<Content: View>(@ViewBuilder content: () -> Content) -> AnyView
}
All of this to say, AnyView
is pretty awesome! It is very robust and doesn't have side effects outside of its intended purpose. It is a valuable tool in your toolset; don't be afraid to use it when it fits the task.