NavigationSplitView does not run .task for each detail view instance

NavigationSplitView does not run .task for each detail view instance. I'm trying to update my code from NavigationView to NavigationSplitView. I was having trouble, since I rely upon .task to update the @State of my detail view. It does not update this state in the detail view when using NavigationSplitView. Please see my comments in the code snippet below. Does anyone have any suggestions? FWIW I have also filed FB11932889.

Thanks for any help.

import SwiftUI



enum Category: CaseIterable, Hashable {

  case first

  case second

}



struct CategoryRow: View {

  let category: Category



  var body: some View {

    Text("Row: \(String(describing: category))")

  }

}



struct CategoryDetail: View {

  enum LoadingState {

    case none

    case loading

    case loaded

    case error

  }

  let category: Category



  @State private var loadingState: LoadingState = .none



  var body: some View {

    VStack {

      Text("Category: \(String(describing: category))")

      Text("LoadingState: \(String(describing: loadingState))")

    }

    .task {

      // If this .task does not run ever, the view will show a .none Category.

      // If this .task runs, the view will start by showing .loading, and then .loaded

      // The bug found when this is in CategoryNavigationSplitView that: this .task does not run for the .second category,

      //  but the @State is not reset, it will show .loaded for the second category chosen!

      //  I expect this .task to run each time, and not carry over the @State from

      //  previous CategoryDetail. It must be a new CategoryDetail View, since the

      //  let Category constant is changing.

      // When this is in CategoryNavigationView, the .task runs as expected.

      loadingState = .loading

      do {

        try await Task.sleep(for: .seconds(2))

        loadingState = .loaded

      } catch {

        loadingState = .error

      }

    }

  }

}



struct CategoryNavigationSplitView: View {

  @State var selectedCategory: Category?



  var body: some View {

    NavigationSplitView {

      List(Category.allCases, id: \.self, selection: $selectedCategory) { category in

        NavigationLink(value: category) { CategoryRow(category: category) }

      }

    } detail: {

      if let selectedCategory {

        CategoryDetail(category: selectedCategory)

      } else {

        Text("Select a Category")

      }

    }

  }

}



struct CategoryNavigationView: View {

  @State var selectedCategory: Category?



  var body: some View {

    NavigationView {

      List(Category.allCases, id: \.self, selection: $selectedCategory) { category in

        NavigationLink {

          CategoryDetail(category: category)

        } label: {

          CategoryRow(category: category)

        }

      }

    }



    Text("Select a Category")

  }

}
  • I should also clarify: I'm running this on macOS.

Add a Comment

Accepted Reply

I just tested; it reproduces on iPad. However it works fine on iPhone.

  • I did not mean to accept that this has been solved. it has not.

Add a Comment

Replies

I just tested; it reproduces on iPad. However it works fine on iPhone.

  • I did not mean to accept that this has been solved. it has not.

Add a Comment

I re-watched the Demystifying SwiftUI and Data Essentials in SwiftUI WWDC sessions. I basically learned that the LoadingState I have above needs to be "extracted" from CategoryDetail and maintained in the CategoryNavigation*View as a [Category:LoadingState] dictionary. This way the state is persisted and associated with the Category.

I also used the modifier .task(id:) so that it would execute when the selection changed. It's also still tricky to understand the best way to show "this hasn't ever started loading yet".

enum Category: CaseIterable, Hashable, Identifiable {
  case first
  case second
  var id: Self {
    self
  }
}

struct CategoryRow: View {
  let category: Category

  var body: some View {
    Text("Row: \(String(describing: category))")
  }
}

extension Binding {
  public func defaultValue<T>(_ value: T) -> Binding<T> where Value == T? {
    Binding<T> {
      wrappedValue ?? value
    } set: {
      wrappedValue = $0
    }
  }
}

struct CategoryDetail: View {
  enum LoadingState {
    case none
    case loading
    case loaded
    case error
  }

  @Binding var category: Category?
  @Binding var loadingState: LoadingState

  var body: some View {
    Group {
      if category == nil {
        Text("Select A Category")
      } else {
        VStack {
          Text("Category: \(String(describing: category))")
          Text("LoadingState: \(String(describing: loadingState))")
        }
      }
    }.task(id: category) {
      guard let category else {
        debugPrint("nothing selected")
        return
      }

      guard loadingState == CategoryDetail.LoadingState.none else {
        debugPrint("Early Exit \(String(describing: category)): \(String(describing: loadingState))")
        return
      }

      loadingState = .loading
      do {
        try await Task.sleep(for: .seconds(2))
        loadingState = .loaded
      } catch {
        loadingState = .error
      }
    }
  }
}

struct CategoryNavigationSplitView: View {
  @State var selectedCategory: Category?
  @State var loadingStates : [Category : CategoryDetail.LoadingState] = [:]


  var body: some View {
    NavigationSplitView {
      List(Category.allCases, selection: $selectedCategory) { category in
        NavigationLink(value: category) { CategoryRow(category: category) }
      }
    } detail: {
      CategoryDetail(category: $selectedCategory,
                     loadingState: (selectedCategory != nil) ?
                            $loadingStates[selectedCategory!].defaultValue(.none) :
                      .constant(.none))
    }
  }
}

struct CategoryNavigationView: View {
  @State var selectedCategory: Category?
  @State var loadingStates : [Category : CategoryDetail.LoadingState] = [:]

  var body: some View {
    NavigationView {
      List(Category.allCases, selection: $selectedCategory) { category in
        NavigationLink {
          CategoryDetail(category: $selectedCategory,
                         loadingState: (selectedCategory != nil) ?
                                $loadingStates[selectedCategory!].defaultValue(.none) :
                          .constant(.none))
        } label: {
          CategoryRow(category: category)
        }
      }
    }

    Text("Select a Category")
  }
}