2

(on macOS Big Sur with Xcode 12 beta) One thing I've been struggling with in SwiftUI is navigating from e.g. a SetUpGameView which needs to create a Game struct depending on e.g. player name inputted by the user in this view and then proceed with navigation to GameView via a NavigationLink, being button-like, which should be disabled when var game: Game? is nil. The game cannot be initialized until all necessary data has been inputted by the user, but in my example below I just required playerName: String to be non empty.

I have a decent solution, but it seems too complicated.

Below I will present multiple solutions, all of which seems too complicated, I was hoping you could help me out coming up with an even simpler solution.

Game struct

struct Game {
    let playerName: String
    init?(playerName: String) {
        guard !playerName.isEmpty else {
            return nil
        }
        self.playerName = playerName
    }
}

SetUpGameView

The naive (non-working) implementation is this:

struct SetUpGameView: View {
    // ....
    var game: Game? {
        Game(playerName: playerName)
    }
    
    var body: some View {
        NavigationView {
            // ...
            
            NavigationLink(
                destination: GameView(game: game!),
                label: { Label("Start Game", systemImage: "play") }
            )
            .disabled(game == nil)
            
            // ...
        }
    }
    // ...
}

However, this does not work, because it crashes the app. It crashes the app because the expression: GameView(game: game!) as destionation in the NavigationLink initializer does not evaluate lazily, the game! evaluates early, and will be nil at first thus force unwrapping it causes a crash. This is really confusing for me... It feels just... wrong! Because it will not be used until said navigation is used, and using this particular initializer will not result in the destination being used until the NavigationLink is clicked. So we have to handle this with an if let, but now it gets a bit more complicated. I want the NavigationLink label to look the same, except for disabled/enabled rendering, for both states game nil/non nil. This causes code duplication. Or at least I have not come up with any better solution than the ones I present below. Below is two different solutions and a third improved (refactored into custom View ConditionalNavigationLink View) version of the second one...

struct SetUpGameView: View {
    
    @State private var playerName = ""
    
    init() {
        UITableView.appearance().backgroundColor = .clear
    }
    
    var game: Game? {
        Game(playerName: playerName)
    }
    
    var body: some View {
        NavigationView {
            VStack {
                Form {
                    TextField("Player name", text: $playerName)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                }

                // All these three solution work
                
                //       navToGameSolution1
                //       navToGameSolution2
                navToGameSolution2Refactored
            }
        }
    }
    
    // MARK: Solutions

    // N.B. this helper view is only needed by solution1 to avoid duplication in if else, but also used in solution2 for convenience. If solution2 is used this code could be extracted to only occur inline with solution2.
    var startGameLabel: some View {
        // Bug with new View type `Label` see: https://stackoverflow.com/questions/62556361/swiftui-label-text-and-image-vertically-misaligned
        HStack {
            Image(systemName: "play")
            Text("Start Game")
        }
    }
    
    var navToGameSolution1: some View {
        Group { // N.B. this wrapping `Group` is only here, if if let was inline in the `body` it could have been removed...
            if let game = game {
                NavigationLink(
                    destination: GameView(game: game),
                    label: { startGameLabel }
                )
            } else {
                startGameLabel
            }
        }
    }
    
    var navToGameSolution2: some View {
        NavigationLink(
            destination: game.map { AnyView(GameView(game: $0)) } ?? AnyView(EmptyView()),
            label: { startGameLabel }
        ).disabled(game == nil)
    }
    
    var navToGameSolution2Refactored: some View {
        NavigatableIfModelPresent(model: game) {
            startGameLabel
        } destination: {
            GameView(game: $0)
        }
    }
}

NavigatableIfModelPresent

Same solution as navToGameSolution2 but refactored, so that we do not need to repeat the label, or construct multiple AnyView...

struct NavigatableIfModelPresent<Model, Label, Destination>: View where Label: View, Destination: View {
    
    let model: Model?
    let label: () -> Label
    let destination: (Model) -> Destination
    
    var body: some View {
        NavigationLink(
            destination: model.map { AnyView(destination($0)) } ?? AnyView(EmptyView()),
            label: label
        ).disabled(model == nil)
    }
}

Feels like I'm missing something here... I don't want to automatically navigate when game becomes non-nil and I don't want the NavigationLink to be enabled until game is non nil.

8
  • Similar question on Apple Dev Forums: developer.apple.com/forums/thread/650835?page=1#616787022 Commented Jun 27, 2020 at 11:45
  • You seem to have a working solution and your question is about making it simpler. "Simpler" is probably going to be subjective and opinion-based, unless you specify exactly the issue that you want to avoid with your current solution - i.e. along which dimension you feel you'd need to simplify Commented Jun 27, 2020 at 15:36
  • I am asking for a no eager Navigation solution, feels like there ought to be one? Commented Jun 27, 2020 at 15:42
  • By eager, do you mean that you don't want to evaluate the destination view until you click to navigate? That's just not how SwiftUI View works. The body property is evaluated when the view is being added to the view hierarchy. To construct a NavigationLink view, it needs to have some View as its destination Commented Jun 27, 2020 at 16:00
  • @Sajjon I feel like you could use a @Binding here. Instead of GameView(game: game!), you could pass in a binding for @State private var game: Game? as GameView(game: $game). This means that whatever GameView needs to do, by that point game will have been updated to have the correct value. Commented Jun 27, 2020 at 23:03

1 Answer 1

2

You could try use @Binding instead to keep track of your Game. This will mean that whenever you navigate to GameView, you have the latest Game as it is set through the @Binding and not through the initializer.

I have made a simplified version to show the concept:

struct ContentView: View {
    
    @State private var playerName = ""
    @State private var game: Game?
    
    var body: some View {
        NavigationView {
            VStack {
                Form {
                    TextField("Player Name", text: $playerName) {
                        setGame()
                    }
                }
                
                NavigationLink("Go to Game", destination: GameView(game: $game))
                
                Spacer()
            }
        }
    }
    
    private func setGame() {
        game = Game(title: "Some title", playerName: playerName)
    }
}



struct Game {
    let title: String
    let playerName: String
}



struct GameView: View {
    
    @Binding var game: Game!
    
    var body: some View {
        VStack {
            Text(game.title)
            Text(game.playerName)
        }
    }
}
Sign up to request clarification or add additional context in comments.

5 Comments

IUO should be avoided at all times
@Sajjon I don't know what IUO means
@Sajjon Did you also downvote my answer, when I'm trying to help you? This seemed like a sensible solution to me and for the information you have given. If this is not what you wanted, you could leave a comment specifying more information instead.
IOU is a common abbreviation for "Implicitly Unwrapped Optional", which is what you are using now in GameView, @Binding var game: Game! this opens up for unsafe code. It is a not as good and generally regarded lazy version of using a true Optional, which results in safe code. So yes I think this is not a good solution, hence downvote. Appreciate the effort though!
@Sajjon Oh it's implicitly unwrapped optionals - why is that a problem here? You could set a default Game if there has been no input, or disable the button until there is a player name set. Then you can guarantee that game is not nil. IUO is not a problem if you are sure you won't run into problems (as a disabled button would make us certain that game is not nil.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.