Mike Apurin

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 UIHostingControllers that have the root view type of AnyView.

View hierarchy showing hosting controllers with 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”.

Simulator and preview canvas side-by-side

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.

  1. 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.
  2. 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.
  3. We get different results because we happen to wrap views of the same exact type in AnyView. If they are different at all — one Color is replaced with Gradient, for example — the structural identity of the view inside will change, and we get transitions back.
  4. If we so choose, we can wrap the whole flag conditional in AnyView 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.