1

In a simple test project at Github I display a List of NavigationLink items:

screenshot

The file GamesViewModel.swift simulates a list of numerical game ids, coming via Websockets in my real app:

class GamesViewModel: ObservableObject /*, WebSocketDelegate */ {
    @Published var currentGames: [Int] = [2, 3]
    @Published var displayedGame: Int = 0
    
    func updateCurrentGames() {
        currentGames = currentGames.count == 3 ?
            [1, 2, 3, 4] : [2, 5, 7]
    }

    func updateDisplayedGame() {
        displayedGame = currentGames.randomElement() ?? 0
    }
}

My problem is that I am trying to activate a random NavigationLink in the ContentView.swift programmatically, when the Button "Join a random game" is clicked:

struct ContentView: View {
    @StateObject private var vm:GamesViewModel = GamesViewModel()

    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(vm.currentGames, id: \.self) { gameNumber in
                        NavigationLink(destination: GameView(gameNumber: gameNumber)
                                       /* , isActive: $vm.displayedGame == gameNumber */ ) {
                            Text("Game #\(gameNumber)")
                        }
                    }
                }
                Button(
                    action: { vm.updateCurrentGames() },
                    label: { Text("Update games") }
                )

                Button(
                    action: { vm.updateDisplayedGame() },
                    label: { Text("Join a random game") }
                )
          }
       }
    }
}

However, the code

ForEach(vm.currentGames, id: \.self) { gameNumber in
    NavigationLink(destination: GameView(gameNumber: gameNumber),
                   isActive: $vm.displayedGame == gameNumber ) {
        Text("Game #\(gameNumber)")
    }
}

does not compile:

Cannot convert value of type 'Bool' to expected argument type 'Binding'

Is it even possible to use a $ inside of a ForEach?

My context is that in my real app the Jetty backend creates a new game in the PostgreSQL database and then sends the numerical id of that new game to the app. And the app should display that game, i.e. navigate to the GameView.swift programmatically.

UPDATE:

The users jnpdx and Dhaval have both suggested interesting solutions (thanks!) - however they only work for short Lists, when the NavigationLinks are visible at the screen.

For longer Lists when a NavigationLink should be activated for the game number which is scrolled offscreen - they do not work!

I have tried implementing my own solution, by using a NavigationLink/EmptyView at the top of the screen, to ensure that it is always visible and can be triggered to transition to the vm.displayedGame number.

However my code does not work, i.e. only works once (maybe I need to set displayedGame = 0 somehow after navigating back to the main screen?) -

Here ContentView.swift:

struct ContentView: View {
    @StateObject private var vm:GamesViewModel = GamesViewModel()

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(
                        destination: GameView(gameNumber: vm.displayedGame),
                        isActive: Binding(get: { vm.displayedGame > 0 }, set: { _,_ in })
                ) {
                    EmptyView()
                }
                List {
                    ForEach(vm.currentGames, id: \.self) { gameNumber in
                        NavigationLink(
                                destination: GameView(gameNumber: gameNumber)
                            ) {
                            Text("Game #\(gameNumber)")
                        }
                    }
                }
                Button(
                    action: { vm.updateCurrentGames() },
                    label: { Text("Update games") }
                )
                Button(
                    action: { vm.updateDisplayedGame() },
                    label: { Text("Join a random game") }
                )
          }
       }
    }
}
Alexander Farber
  • 21,519
  • 75
  • 241
  • 416

2 Answers2

1

The issue is you need a Binding<Bool> -- not just a simple boolean condition. Adding $ gives you a binding to the displayedGame (ie an Binding<Int?>), but not a Binding<Bool>, which is what the NavigationLink expects.

One solution is to create a custom Binding for the condition you're looking for:

class GamesViewModel: ObservableObject /*, WebSocketDelegate */ {
    @Published var currentGames: [Int] = [2, 3]
    @Published var displayedGame: Int?
    
    func navigationBindingForGame(gameNumber: Int) -> Binding<Bool> {
        .init {
            self.displayedGame == gameNumber
        } set: { newValue in
            self.displayedGame = newValue ? gameNumber : nil
        }
    }
    
    func updateCurrentGames() {
        currentGames = currentGames.count == 3 ? [1, 2, 3, 4] : [2, 5, 7]
    }

    func updateDisplayedGame() {
        displayedGame = currentGames.randomElement() ?? 0
    }
}


struct ContentView: View {
    @StateObject private var vm:GamesViewModel = GamesViewModel()

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(isActive: vm.navigationBindingForGame(gameNumber: vm.displayedGame ?? -1)) {
                    GameView(gameNumber: vm.displayedGame ?? 0)
                } label: {
                    EmptyView()
                }
                List {
                    ForEach(vm.currentGames, id: \.self) { gameNumber in
                        NavigationLink(destination: GameView(gameNumber: gameNumber),
                                       isActive: vm.navigationBindingForGame(gameNumber: gameNumber)
                        ) {
                            Text("Game #\(gameNumber)")
                        }
                    }
                }
                Button(
                    action: { vm.updateCurrentGames() },
                    label: { Text("Update games") }
                ).padding(4)

                Button(
                    action: { vm.updateDisplayedGame() },
                    label: { Text("Join a random game") }
                ).padding(4)
                
          }.navigationBarTitle("Select a game")
       }
    }
}

I changed displayedGame to an Int? because I think semantically it makes a little more sense to be an Optional rather than set to 0 if there's no displayed game, but that could easily be changed back if need be.

jnpdx
  • 45,847
  • 6
  • 64
  • 94
  • Yes, I actually wanted an `Int?` thank you :-) – Alexander Farber Oct 31 '21 at 21:39
  • 1
    IIRC, this will only work for the NavigationLinks visible on screen – Orijhins Nov 02 '21 at 08:26
  • Ouch, you are correct! Tested it with `Array(10..50)`. How to fix? – Alexander Farber Nov 02 '21 at 11:34
  • Apologies @jnpdx while I appreciate your answer, it does not work for longer Lists with NavigationLinks offscreen. I have to remove the green mark from your answer. And I have updated my question. – Alexander Farber Nov 02 '21 at 15:45
  • 1
    @AlexanderFarber I've updated my answer -- you were on the right track adding a NavigationLink at the top, but you also have to use the `set` side of the Binding -- otherwise it'll only work once. – jnpdx Nov 02 '21 at 15:57
  • Thank you, I think that the second `isActive` in your updated answer is not needed (i.e. the List -> ForEach -> NavigationLink -> isActive is not needed). Because it is always the isActive of the EmptyView parent that is used for programmatically triggering the navigation to GameView – Alexander Farber Nov 02 '21 at 16:44
  • 1
    Maybe… I’d have to do some testing. There may be scenarios on iPad (or Mac) where two columns are shown and it would be important that the highlighting was still correct. – jnpdx Nov 02 '21 at 16:51
  • 1
    The highlighting in two-columns seems like it doesn't *really* work reliably with two-column navigation either way... Maybe there's another trick – jnpdx Nov 02 '21 at 19:34
  • Just 1 last question please: when do you think the `set` method of the `navigationBindingForGame` is called? Why is it important? – Alexander Farber Nov 03 '21 at 08:47
  • 1
    It is called when the user navigates back from the game page. – jnpdx Nov 03 '21 at 12:50
1

You also pass like this

ForEach(vm.currentGames, id: \.self) { gameNumber in
    NavigationLink(destination: GameView(gameNumber: gameNumber),
                   isActive: Binding(get: {
                            vm.displayedGame == gameNumber
                        }, set: { (v) in
                            vm.displayedGame = v ? gameNumber : nil
                        })) {
        Text("Game #\(gameNumber)")
    }
}
Dhaval Bera
  • 109
  • 4
  • Will that really work? The `.constant` will probably never change? I have tried your suggestion in [my test project](https://github.com/afarber/ios-questions/tree/master/NaviLinkProg/NaviLinkProg) and it produces erratic behaviour after few clicks on the "Join a random game" button. – Alexander Farber Nov 01 '21 at 14:39
  • 1
    @AlexanderFarber l check my code and update answer please check – Dhaval Bera Nov 02 '21 at 06:27
  • That works too! (Sadly, also only for the NavigationLinks shown on the screen). Thank you, upvoted – Alexander Farber Nov 02 '21 at 12:21