1

I'm trying to find a viable way to handle navigation with data that has been returned from an asynchronous callback.

Consider the following example. The button in NavigationExampleView is triggering some async method on a separate object, NavigationExampleViewModel in this case. The returned data form the method, should then be pushed on the navigation stack in a UserView. A NavigationLink seems to be the way to archive this, but I can't find a way to get hold of a non-optional value of the data that I need to present.

struct User: Identifiable {
    let id: String
    let name: String
}

protocol API {
    func getUser() -> AnyPublisher<User, Never>
}

struct NavigationExampleView: View {
    
    @ObservedObject var vm: NavigationExampleViewModel
    
    var body: some View {
        HStack {
            Button("Get User") {
                vm.getUser()
            }
            NavigationLink.init(
                destination: UserView(user: ???),
                isActive: ???,
                label: EmptyView.init
            )
        }
    }
}

class NavigationExampleViewModel: ObservableObject {
    @Published var isLoading = false
    @Published var pushUser: User?
    
    var cancellable: AnyCancellable?
    
    let api: API
    init(api: API) { self.api = api }
    
    func getUser() {
        isLoading = true
        cancellable = api.getUser().sink { user in
            self.pushUser = user
            self.isLoading = false
        }
    }
}

struct UserView: View, Identifiable {
    let id: String
    let user: User
    
    var body: some View {
        Text(user.name)
    }
}

Questions:

  1. How do I get hold of the data to present as a non-optional value in the view?
  2. What should I use as a binding to control presentation?

A way I can almost archive this is with the view modifier .sheet(item: Binding<Identifiable?>, content: Identifiable -> View), like this:

struct NavigationExampleView: View {
    
    @ObservedObject var vm: NavigationExampleViewModel
    
    var body: some View {
        HStack {
            Button("Get User") {
                vm.getUser()
            }
        }.sheet(item: $vm.pushUser, content: UserView.init)
    }
}

How can archive the same for pushing the view onto the navigation stack, instead of presenting it as a sheet?

Wiingaard
  • 4,150
  • 4
  • 35
  • 67
  • I would give the UserView multiple states it can be in such as "Not Loaded", "Loading", "Loaded". Then pass the API publisher directly to the userView and .onAppear { API.getUser }. So when the UserView appears, it will immediately begin loading and will displaye the data when the api returns it. It is explained very well here: https://www.swiftbysundell.com/articles/handling-loading-states-in-swiftui/ – KissTheCoder Mar 02 '21 at 23:48
  • This doesn't solve my problem :/ What if the specific view that should be pushed depend on the data thats returned. For instance, if I wanted to present some specific view for a user that is an admin and present some completely different view for a user that is not. – Wiingaard Mar 03 '21 at 19:46
  • Make an async User-container view, like in my comment above. Then make two other view structs (Non-Admin and Admin). The container view will determine if the user is an admin or not and will pass the data along and display to the appropriate userview (Admin or NonAdmin) – KissTheCoder Mar 03 '21 at 22:10
  • I need to push the view once loading is done. – Wiingaard Mar 04 '21 at 09:43
  • If you want to do something like that you'll have to do some extra working making a custom binding of some sort to use with a sheetCoverView. I dont think that is a good design pattern. The user will be confused if they click something expect and action to occur and nothing happens until the data is loaded. Take "Apple Music" for example. When clicking on an album, the view is presented immediately and the album is shown when it loads. – KissTheCoder Mar 04 '21 at 17:39

2 Answers2

2

You can use if-let to unwrap the optional vm.pushUser. Then, create a custom binding to map Binding<User?> to Binding<Bool>:

struct NavigationExampleView: View {
    
    @ObservedObject var vm: NavigationExampleViewModel
    
    var body: some View {
        HStack {
            Button("Get User") {
                vm.getUser()
            }
            if let user = vm.pushUser {
                NavigationLink(
                    destination: UserView(id: "<id>", user: user),
                    isActive: binding,
                    label: EmptyView.init
                )
            }
        }
    }
    
    var binding: Binding<Bool> {
        .init(
            get: { vm.pushUser != nil },
            set: { if !$0 { vm.pushUser = nil } }
        )
    }
}

Alternatively, use it inline:

if let user = vm.pushUser {
    NavigationLink(
        destination: UserView(id: "<id>", user: user),
        isActive: .init(get: { vm.pushUser != nil }, set: { if !$0 { vm.pushUser = nil } }),
        label: EmptyView.init
    )
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209
  • This doesn't work for me :/ The detail view is never pushed. I've tried printing the return values from the binding, the getter is returning `true` but still no view is pushed. Did you try this out? – Wiingaard Mar 03 '21 at 19:37
  • 1
    @Wiingaard Yes, it works for me - Xcode 12.4, iOS 14.4. What is your environment? – pawello2222 Mar 04 '21 at 22:25
  • 1
    Sorry man, I tested this again and it worked.. I must have had some other issue in the app I was testing it in. I've wrapped your method up in a nice little ViewModifier that follows the same pattern as the `sheet` modifier. I'm posting an answer now.. Thanks! :) – Wiingaard Mar 05 '21 at 22:33
2

Based on the if let method from @pawello2222's answer, I've created this ViewModifier that let's you push a View to the Navigation Stack. It follows the same pattern as the .sheet modifier.

import SwiftUI

private struct PushPresenter<Item: Identifiable, DestinationView: View>: ViewModifier {
    
    let item: Binding<Item?>
    let destination: (Item) -> DestinationView
    let presentationBinding: Binding<Bool>
    
    init(item: Binding<Item?>, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> DestinationView) {
        self.item = item
        self.destination = content
        
        presentationBinding = Binding<Bool> {
            item.wrappedValue != nil
        } set: { isActive in
            if !isActive && item.wrappedValue != nil {
                onDismiss?()
                item.wrappedValue = nil
            }
        }
    }
    
    func body(content: Content) -> some View {
        ZStack {
            content
            NavigationLink(
                destination: item.wrappedValue == nil ? nil : destination(item.wrappedValue!),
                isActive: presentationBinding,
                label: EmptyView.init
            )
        }
    }
}

extension View {
    
    /// Pushed a View onto the navigation stack using the given item as a data source for the View's content.  **Notice**: Make sure to use `.navigationViewStyle(StackNavigationViewStyle())` on the parent `NavigationView` otherwise using this modifier will cause a memory leak.
    /// - Parameters:
    ///   - item: A binding to an optional source of truth for the view's presentation. When `item` is non-nil, the system passes the `item`’s content to the modifier’s closure. This uses a `NavigationLink` under the hood, so make sure to have a `NavigationView` as a parent in the view hierarchy.
    ///   - onDismiss: A closure to execute when poping the view of the navigation stack.
    ///   - content: A closure returning the content of the view.
    func push<Item: Identifiable, Content: View>(
        item: Binding<Item?>,
        onDismiss: (() -> Void)? = nil,
        content: @escaping (Item) -> Content) -> some View
    {
        self.modifier(PushPresenter.init(item: item, onDismiss: onDismiss, content: content))
    }
}

And then use it like so:

struct NavigationExampleView: View {
    
    @ObservedObject var vm: NavigationExampleViewModel
    
    var body: some View {
        Button("Get User") {
            vm.getUser()
        }
        .push(item: $vm.pushUser, content: UserView.init)
    }
}

Be sure to keep your implementing view in a NavigationView for the push to take effect.

Edit

I found an issue with this solution. If using a reference type as the binding item, the object will not get deinitialised on dismiss. It seems like the content is holding a reference to the item. The Object will only the deinitialised once a new object is set on the binding (next time a screen is pushed).

This is also the case for @pawello2222's answer, but not an issue when using the sheet modifier. Any suggestions on how to solve this would be much welcomed.

Edit 2

Adding .navigationViewStyle(StackNavigationViewStyle()) to the parent NavigationView removes the memory leak for some reason. See answer here for more details.

Using the if-let-method caused the push view transition to stop working. Instead pass nil to the NavigationLinks destination when the data is not pressent. I've updated ViewModifier to handle this.

Wiingaard
  • 4,150
  • 4
  • 35
  • 67