0

Simple use case: A list of States with a Recents section that shows those States you have navigated to recently. When a link is tapped, the animation begins but is then aborted presumably as a result of the onAppear handler in the detail view changing the recents property of the model -- which is observed by the framework. Log messages indicate the framework is unhappy, but unclear how to make it happy short of a ~2 second delay before adding to recents...


import SwiftUI


class USState: Identifiable{
    typealias ID = String
    var id: ID
    var name: String
    init(_ name: String, id: String){
        self.id = id
        self.name = name
    }
}

/** The model is a small set of us states for example purposes.
    It also publishes a recents property which has a lifo stack of states that have been viewed.
 */
class StateModel: ObservableObject{
    var states: [USState]
    var stateMap: [USState.ID: USState]
    
    @Published var recents = [USState]()
    
    init(){
        states = [
            USState("California", id: "CA"),
            USState("Georgia", id: "GA"),
            USState("New York", id: "NY"),
            USState("New Jersey", id: "NJ"),
            USState("Montana", id: "MT")
        ]
        stateMap = [USState.ID: USState]()
        for state in states{
            stateMap[state.id] = state
        }
    }

    func addRecent(_ state: USState){
        recents.removeAll(where: {$0.id == state.id})
        recents.insert(state, at: 0)
    }
    
    func allExceptRecent() -> [USState]{
        states.filter{ state in
            recents.contains{
                state.id == $0.id
            } == false
        }
    }
}

/** A simple view to serve as the destination of a state link
 */
struct StateView: View{
    @EnvironmentObject var stateModel: StateModel
    var usState: USState
    var body: some View{
        Text(usState.name)
            .onAppear{
                DispatchQueue.main.async {
                    withAnimation {
                        stateModel.addRecent(usState)
                    }
                }
            }
    }
}

/** A list of states broken into two sections, those that have been recently viewed, and the remainder.
    Desired behavior is that when a state is tapped, it should navigate to its respective detail view and update the list of recents.
    The issue is that the recents updating appears to confuse SwiftUI and the navigation is aborted.
 */
struct SidebarBounce: View {

    @EnvironmentObject var model: StateModel

    @SceneStorage("selectionStore") private var selectionStore: USState.ID?
    
    struct Header: View{
        var text: String
        var body: some View{
            Text(text)
                .font(.headline)
                .padding()
        }
    }

    struct Row: View{
        var text: String
        var body: some View{
            VStack{
                HStack{
                    Text(text)
                        .padding([.leading, .trailing])
                        .padding([.top, .bottom], 8)
                    Spacer()
                }
                Divider()
            }
        }
    }
    
    var body: some View{
        ScrollView{
            LazyVStack(alignment: .leading, spacing: 0){

                Section(
                    header: Header(text: "Recent")
                ){
                    ForEach(model.recents){place in
                        NavigationLink(
                            destination: StateView(usState: place),
                            tag: place.id,
                            selection: $selectionStore
                        ){
                            Row(text: place.name)
                        }
                            .id("Recent \(place.id)")
                    }
                }
                Section(
                    header: Header(text: "All")
                ){
                    ForEach(model.allExceptRecent()){place in
                        NavigationLink(
                            destination: StateView(usState: place),
                            tag: place.id,
                            selection: $selectionStore
                        ){
                            Row(text: place.name)
                        }
                            .id("All \(place.id)")
                    }
                }
            }
        }
    }
}

struct BounceWrap: View{
    let model = StateModel()
    var body: some View{
        NavigationView{
            SidebarBounce()
                .navigationTitle("Aborted Navigation")
            Text("Nothing Selected")
        }
            .environmentObject(model)
    }
}

@main
struct DemoApp: App {
    var body: some Scene {
        WindowGroup {
            BounceWrap()
        }
    }
}

Note: This must be run as an app (iPhone or simulator) rather than in preview.

Rumbles
  • 806
  • 1
  • 8
  • 15

1 Answers1

0

I have tried to set the clicked state as a recent state before executing the animation, and the issue seems to be resolved as it can be seen from the GIF below:

Animation fix

To set the state as recent before executing the NavigationView, we need to implement a programmatic NavigationView as seen below:

struct SidebarBounce: View {
    @EnvironmentObject var model: StateModel
    @SceneStorage("selectionStore") private var selectionStore: USState.ID?
    
    @State private var clickedState: USState? // <-- see here
    @State private var expandState = false // <-- and here
    .
    .
    .
                Section(
                    header: Header(text: "All")
                ){
                    ForEach(model.allExceptRecent()){place in
                        Button(action: {
                            withAnimation {
                                model.addRecent(place) // <-- Making state recent
                            }
                            clickedState = place // <-- programmatically executing NavigationLink below
                            expandState = true // <-- programmatically executing NavigationLink below
                        }, label: {
                            Row(text: place.name)
                        })
                    }
                }
            }

            // Programmatic NavigationLink that is triggered by a Bool value vvv
            NavigationLink(
                destination: clickedState == nil ? nil : StateView(usState: clickedState!),
                isActive: $expandState,
                label: EmptyView.init)
        }
    }
}

I think the reason this is happening is because the StateModel object is identical in both views, and updating it from a view causes all views to update even if it was not the active view.

If any other issues occur, try having a unique ViewModel for each view, and each ViewModel listens to changes happening in StateModel (Singleton), and the view listens for changes from the ViewModel and reflects them into the UI.

  • Thank you for this. Consolidating into a single NavigationLink is an interesting strategy. Because my actual use case is more complicated, it becomes rather messy. – Rumbles Mar 20 '21 at 12:24