93

⚠️ 23 June 2020 Edit: From Xcode 12, both switch and if let statements will be supported in the ViewBuilder!

I’ve been trying to replicate an app of mine using SwiftUI. It has a RootViewController which, depending on an enum value, shows a different child view controller. As in SwiftUI we use views instead of view controllers, my code looks like this:

struct RootView : View {
   @State var containedView: ContainedView = .home

   var body: some View {
      // custom header goes here
      switch containedView {
         case .home: HomeView()
         case .categories: CategoriesView()
         ...
      }
   }
}

Unfortunately, I get a warning:

Closure containing control flow statement cannot be used with function builder ViewBuilder.

So, are there any alternatives to switch so I can replicate this behaviour?

Nikolay Marinov
  • 2,381
  • 2
  • 7
  • 12
  • 1
    Do not use switch in your view, this is not a place for logic, make a function outside of body – Lu_ Jun 24 '19 at 12:22
  • 4
    I tried making a function returning someView and moving the switch statement there, but this time the error is “Function declares an opaque return type, but the return statements in its body do not have matching underlying types” :( – Nikolay Marinov Jun 24 '19 at 12:36

8 Answers8

133

⚠️ 23 June 2020 Edit: From Xcode 12, both switch and if let statements will be supported in the ViewBuilder!

Thanks for the answers, guys. I’ve found a solution on Apple’s Dev Forums. It’s answered by Kiel Gillard. The solution is to extract the switch in a function as Lu_, Linus and Mo suggested, but we have to wrap the views in AnyView for it to work – like this:

struct RootView: View {
  @State var containedViewType: ContainedViewType = .home

  var body: some View {
     VStack {
       // custom header goes here
       containedView()
     }
  }

  func containedView() -> AnyView {
     switch containedViewType {
     case .home: return AnyView(HomeView())
     case .categories: return AnyView(CategoriesView())
     ... 
  }
}
Nikolay Marinov
  • 2,381
  • 2
  • 7
  • 12
  • 1
    Wow! The AnyView trick is perfect! I was wondering what was it for. Thanks! – kontiki Jun 24 '19 at 16:22
  • 18
    ***NOTE*** Transition animations that kick in when a view is added/removed from the hierarchy, do not seem to work with switch. Even when specified explicitly. They do work with an IF statement though. – kontiki Jun 24 '19 at 16:38
  • 1
    You can even have an optional `AnyView` if you want to conditionally show a view. – gnarlybracket Oct 04 '19 at 14:50
  • 3
    Thanks for this! However, I want to add that you don't even need the helper function. Just wrapping with AnyView does the trick! – hasen Nov 20 '19 at 04:19
  • 8
    Note that AnyView() erases the type, and therefore prevents some of SwiftUI's performance optimizations from working. This article goes into greater detail why: https://www.objc.io/blog/2019/11/05/static-types-in-swiftui/ – orospakr Nov 26 '19 at 03:52
  • Does the AnyView type erasure affect animations? – Zorayr May 08 '20 at 19:12
  • 1
    @zorayn It might do, judging by this question: https://stackoverflow.com/questions/58160011/swiftui-add-subview-dynamically-but-the-animation-doesnt-work . One thing it affects for sure is performance. SwiftUI has to do extra work to update AnyView. – Nikolay Marinov May 11 '20 at 15:45
18

Update: SwiftUI 2 now includes support for switch statements in function builders, https://github.com/apple/swift/pull/30174


Adding to Nikolai's answer, which got the switch compiling but not working with transitions, here's a version of his example that does support transitions.

struct RootView: View {
  @State var containedViewType: ContainedViewType = .home

  var body: some View {
     VStack {
       // custom header goes here
       containedView()
     }
  }

  func containedView() -> some View {
     switch containedViewType {
     case .home: return AnyView(HomeView()).id("HomeView")
     case .categories: return AnyView(CategoriesView()).id("CategoriesView")
     ... 
  }
}

Note the id(...) that has been added to each AnyView. This allows SwiftUI to identify the view within it's view hierarchy allowing it to apply the transition animations correctly.

opsb
  • 29,325
  • 19
  • 89
  • 99
14

It looks like you don't need to extract the switch statement into a separate function if you specify the return type of a ViewBuilder. For example:

Group { () -> Text in
    switch status {
    case .on:
        return Text("On")
    case .off:
        return Text("Off")
    }
}

Note: You can also return arbitrary view types if you wrap them in AnyView and specify that as the return type.

Avario
  • 4,655
  • 3
  • 26
  • 19
10

You can switch on an enum with ViewBuilder.

Firstly, declare your enum:

enum Destination: CaseIterable, Identifiable {
  case restaurants
  case profile
  
  var id: String { return title }
  
  var title: String {
    switch self {
    case .restaurants: return "Restaurants"
    case .profile: return "Profile"
    }
  }
  
}

Then, in the View file:

struct ContentView: View {

   @State private var selectedDestination: Destination? = .restaurants

    var body: some View {
        NavigationView {
          view(for: selectedDestination)
        }
     }

  @ViewBuilder
  func view(for destination: Destination?) -> some View {
    switch destination {
    case .some(.restaurants):
      CategoriesView()
    case .some(.profile):
      ProfileView()
    default:
      EmptyView()
    }
  }
}

If you want to use the same case with the NavigationLink, you can use it as follows:

struct ContentView: View {
  
  @State private var selectedDestination: Destination? = .restaurants
  
  var body: some View {
    NavigationView {

      List(Destination.allCases,
           selection: $selectedDestination) { item in
        NavigationLink(destination: view(for: selectedDestination),
                       tag: item,
                       selection: $selectedDestination) {
          Text(item.title).tag(item)
        }
      }
        
    }
  }
  
  @ViewBuilder
  func view(for destination: Destination?) -> some View {
    switch destination {
    case .some(.restaurants):
      CategoriesView()
    case .some(.profile):
      ProfileView()
    default:
      EmptyView()
    }
  }
}
Adil Hussain
  • 30,049
  • 21
  • 112
  • 147
Wahab Khan Jadon
  • 875
  • 13
  • 21
7

You must wrap your code in a View, such as VStack, or Group:

var body: some View {
   Group {
       switch containedView {
          case .home: HomeView()
          case .categories: CategoriesView()
          ...
       }
   }
}

or, adding return values should work:

var body: some View {
    switch containedView {
        case .home: return HomeView()
        case .categories: return CategoriesView()
        ...
    }
}

The best-practice way to solve this issue, however, would be to create a method that returns a view:

func nextView(for containedView: YourViewEnum) -> some AnyView {
    switch containedView {
        case .home: return HomeView()
        case .categories: return CategoriesView()
        ...
    }
}

var body: some View {
    nextView(for: containedView)
}
LinusGeffarth
  • 27,197
  • 29
  • 120
  • 174
4

Providing default statement in the switch solved it for me:

struct RootView : View {
   @State var containedView: ContainedView = .home

   var body: some View {
      // custom header goes here
      switch containedView {
         case .home: HomeView()
         case .categories: CategoriesView()
         ...
         default: EmptyView()
      }
   }
}
cedricbahirwe
  • 1,274
  • 4
  • 15
2

You can do with a wrapper View

struct MakeView: View {
    let make: () -> AnyView

    var body: some View {
        make()
    }
}

struct UseMakeView: View {
    let animal: Animal = .cat

    var body: some View {
        MakeView {
            switch self.animal {
            case .cat:
                return Text("cat").erase()
            case .dog:
                return Text("dog").erase()
            case .mouse:
                return Text("mouse").erase()
            }
        }
    }
}
onmyway133
  • 45,645
  • 31
  • 257
  • 263
0

For not using AnyView(). I will use a bunch of if statements and implement the protocols Equatable and CustomStringConvertible in my Enum for retrieving my associated values:

var body: some View {
    ZStack {
        Color("background1")
            .edgesIgnoringSafeArea(.all)
            .onAppear { self.viewModel.send(event: .onAppear) }
        
        // You can use viewModel.state == .loading as well if your don't have 
        // associated values
        if viewModel.state.description == "loading" {
            LoadingContentView()
        } else if viewModel.state.description == "idle" {
            IdleContentView()
        } else if viewModel.state.description == "loaded" {
            LoadedContentView(list: viewModel.state.value as! [AnimeItem])
        } else if viewModel.state.description == "error" {
            ErrorContentView(error: viewModel.state.value as! Error)
        }
    }
}

And I will separate my views using a struct:

struct ErrorContentView: View {
    var error: Error

    var body: some View {
        VStack {
            Image("error")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 100)
            Text(error.localizedDescription)
        }
    }
}
  • 1
    just a remark to your comment in the code: you may use `if case .loading = viewModel.state` when with associated value – AlexanderZ Nov 01 '20 at 10:05