SwiftUI NavigationLink - "lazy" destination? Or how to not code duplicate `label`? - swift

(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.

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)
}
}
}

Related

SwiftUI macOS NavigationView - onChange(of: Bool) action tried to update multiple times per frame

I'm seeing onChange(of: Bool) action tried to update multiple times per frame warnings when clicking on NavigationLinks in the sidebar for a SwiftUI macOS App.
Here's what I currently have:
import SwiftUI
#main
struct BazbarApp: App {
#StateObject private var modelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(modelData)
}
}
}
class ModelData: ObservableObject {
#Published var myLinks = [URL(string: "https://google.com")!, URL(string: "https://apple.com")!, URL(string: "https://amazon.com")!]
}
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
NavigationLink(destination: DetailView(selected: $selected) ) {
Text(url.absoluteString)
}
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
Text("Choose a link")
}
}
}
struct DetailView: View {
#Binding var selected: URL?
var body: some View {
if let selected = selected {
Text("Currently selected: \(selected)")
}
else {
Text("Choose a link")
}
}
}
When I alternate clicking on the second and third links in the sidebar, I eventually start seeing the aforementioned warnings in my console.
Here's a gif of what I'm referring to:
Interestingly, the warning does not appear when alternating clicks between the first and second link.
Does anyone know how to fix this?
I'm using macOS 12.2.1 & Xcode 13.2.1.
Thanks in advance
I think the issue is that both the List(selection:) and the NavigationLink are trying to update the state variable selected at once. A List(selection:) and a NavigationLink can both handle the task of navigation. The solution is to abandon one of them. You can use either to handle navigation.
Since List look good, I suggest sticking with that. The NavigationLink can then be removed. The second view under NavigationView is displayed on the right, so why not use DetailView(selected:) there. You already made the selected parameter a binding variable, so the view will update if that var changes.
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
Text(url.absoluteString)
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
DetailView(selected: $selected)
}
}
}
I can recreate this problem with the simplest example I can think of so my guess is it's an internal bug in NavigationView.
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink("A", destination: Text("A"))
NavigationLink("B", destination: Text("B"))
NavigationLink("C", destination: Text("C"))
}
}
}
}

Keeping data in SwiftUI MVVM hierarchy without persistence

I am new to SwiftUI, coming from Java. I know MVC pattern and try to understand MVVM in SwiftUI. It kindly works, but I don't know how to keep data between View hierarchy... also the View should not know anything about models, so I tried using the ViewModels passing around the views. Each ViewModel than should manage their Model.
First Question: Is this a good way to implement MVVM in SwiftUI like I did?
Second: Anyone here who could help me out to divide strictly Model from View and getting this code working?
For now I can add a Garage, go into GarageView and add certain Car models. After switching back to GarageListView and adding another Garage model all Car models from first Garage model are gone... what am I doing wrong? ;(
import SwiftUI
// Models
struct Car: Identifiable, Codable {
var id = UUID()
var name: String
}
struct Garage: Identifiable, Codable {
var id = UUID()
var name: String
var cars: [Car] = []
}
// ViewModels
class GarageListViewModel: ObservableObject {
#Published var garages: [Garage] = []
}
class GarageViewModel: ObservableObject {
#Published var garage: Garage
init(garage: Garage) {
self.garage = garage
}
}
class CarViewModel: ObservableObject {
#Published var car: Car
init(car: Car) {
self.car = car
}
}
// Views
struct GarageListView: View {
#ObservedObject var viewModel: GarageListViewModel
var body: some View {
List {
ForEach(viewModel.garages) { garage in
NavigationLink {
GarageView(viewModel: GarageViewModel(garage: garage))
} label: {
Text(garage.name)
}
}
}
.toolbar {
Button {
viewModel.garages.append(Garage(name: "My Garage"))
} label: {
Label("Add", systemImage: "plus")
}
}
}
}
struct GarageView: View {
#ObservedObject var viewModel: GarageViewModel
var body: some View {
List {
ForEach(viewModel.garage.cars) { car in
NavigationLink {
CarDetailView(viewModel: CarViewModel(car: car))
} label: {
Text(car.name)
}
}
}
.toolbar {
Button {
viewModel.garage.cars.append(Car(name: "My Car"))
} label: {
Label("Add", systemImage: "plus")
}
}
}
}
struct CarDetailView: View {
#ObservedObject var viewModel: CarViewModel
var body: some View {
Text(viewModel.car.name)
}
}
struct ContentView: View {
#StateObject var viewModel = GarageListViewModel()
var body: some View {
NavigationView {
GarageListView(viewModel: viewModel)
}
}
}
I would switch from using struct to class. Structs are passed by value, so you'd never get the exact same struct but a copy of it. By adding a new GarageViewModel, SwiftUI will rerender the List, that's why you lose your garages' cars when you add a new garage.
On a side note, you also forgot to add a NavigationView around your GarageListView and the List in the GarageView, rendering the NavigationLinks useless.
It can be a bit overwhelming to get into MVVC. I would definitely go through Apple's SwiftUI Tutorials as they subtly go through how to set up an MVVC.
As #loremipsum said, you should never init your ViewModel in a view struct because structs are immutable value types, and SwiftUI simply discards the view and recreates it, recreating EVERYTHING inside of it. Also, as you said yourself, your views shouldn't know about the inner workings of the model, so the model should change itself. Therefore, adding garages and cars should be handled in the ViewModel.
Another thing with the ViewModel; you do not need one for EACH view. In this case, one GaragesViewModel can handle all your data. And it should. Apple talks about having a "single source of truth'. That means that there is ONE place your views can go and get the data. That is your ViewModel. Unless you have a wholly unrelated set of data, keep it in one model.
Lastly, before some example code, not every view needs to have the model, or even a mutable parameter. Remember, views are disposable, and with them their parameters are disposed of. If you mutate the model, you will get a new view, so let constants are fine to use. I left your models alone and removed the CarViewModel. It is not needed in this example.
// ViewModel
class GaragesViewModel: ObservableObject {
// This initializes the model IN the model and provides a Singleton.
// You can refer to it anywhere you need to in code.
static var shared = GaragesViewModel()
#Published var garages: [Garage] = []
// Data manipulation in the model
public func addNewGarage() {
garages.append(Garage(name: "My Garage"))
}
public func add(car: Car, to garage:Garage) {
// The guard will stop the func if garage is not in garages.
guard let index = garages.firstIndex(where: { $0.id == garage.id }) else { return }
garages[index].cars.append(car)
}
}
// Views
struct ContentView: View {
var body: some View {
NavigationView {
GarageListView()
}
}
}
struct GarageListView: View {
// Since this is the only view that needs the model, it is called here.
// There is no need to pass it in.
#ObservedObject var viewModel = GaragesViewModel.shared
var body: some View {
List {
ForEach(viewModel.garages) { garage in
NavigationLink {
GarageView(garage: garage)
} label: {
Text(garage.name)
}
}
}
.toolbar {
Button {
GaragesViewModel.shared.addNewGarage()
} label: {
Label("Add", systemImage: "plus")
}
}
}
}
struct GarageView: View {
// GarageView doesn't mutate garage, so it can be a let constant. KISS principal.
let garage: Garage
var body: some View {
List {
ForEach(garage.cars) { car in
NavigationLink {
CarDetailView(car: car)
} label: {
Text(car.name)
}
}
}
.toolbar {
Button {
// if you wanted, you could add a func to car to return a new "My Car"
// and further separate the model.
GaragesViewModel.shared.add(car: Car(name: "My Car"), to: garage)
} label: {
Label("Add", systemImage: "plus")
}
}
}
}
struct CarDetailView: View {
// CarDetailView doesn't mutate car, so it can be a let constant.
let car: Car
var body: some View {
Text(car.name)
}
}
Welcome to StackOverflow! You just got yourself too far into the weeds trying to implement this, but it was a good first shot.
edit:
You can add something like:
public func changeName(of car: Car, in garage: Garage, to name: String) {
guard let garageIndex = garages.firstIndex(where: { $0.id == garage.id }),
let carIndex = garage.cars.firstIndex(where: { $0.id == car.id }) else { return }
garages[garageIndex].cars[carIndex].name = name
}
to your model, and then use it like this:
struct CarDetailView: View {
// Make car an #State variable so you can change it, and then pass the change to the func in the model.
#State var car: Car
let garage: Garage
var body: some View {
VStack {
TextField("", text: $car.name)
.padding()
Button {
GaragesViewModel.shared.changeName(of: car, in: garage)
} label: {
Text("Save")
}
}
}
}
Now, this is for learning purposes only. I would not use this in a shipping app as you have no persistence of your data. If you need to create a database of cars and garages, etc. I would use CoreData to track it, and it works a little differently than just using the structs and class we have here.
Also, if you have any more questions, you really need to make a new question. The purpose of StackOverflow is to get discrete answers to discrete questions, and so follow on questions are discouraged.

SwiftUI problems with Multiplatform NavigationView

I'm currently having all sorts of problems with a NavigationView in my multi-platform SwiftUI app.
My goal is to have a NavigationView with an item for each object in a list from Core Data. And each NavigationLink should lead to a view that can read and write data of the object that it's showing.
However I'm running into many problems, so I figured I'm probably taking the wrong approach.
Here is my code as of now:
struct InstanceList: View {
#StateObject private var viewModel = InstancesViewModel()
#State var selectedInstance: Instance?
var body: some View {
NavigationView {
List {
ForEach(viewModel.instances) { instance in
NavigationLink(destination: InstanceView(instance: instance), tag: instance, selection: $selectedInstance) {
InstanceRow(instance)
}
}
.onDelete { set in
viewModel.deleteInstance(viewModel.instances[Array(set)[0]])
for reverseIndex in stride(from: viewModel.instances.count - 1, through: 0, by: -1) {
viewModel.instances[reverseIndex].id = Int16(reverseIndex)
}
}
}
.onAppear {
selectedInstance = viewModel.instances.first
}
.listStyle(SidebarListStyle())
.navigationTitle("Instances")
.toolbar {
ToolbarItemGroup {
Button {
withAnimation {
viewModel.addInstance(name: "4x4", puzzle: "3x3") // temporary
}
} label: {
Image(systemName: "plus")
}
}
}
}
}
}
and the view model (which probably isn't very relevant but I'm including it just in case):
class InstancesViewModel: ObservableObject {
#Published var instances = [Instance]()
private var cancellable: AnyCancellable?
init(instancePublisher: AnyPublisher<[Instance], Never> = InstanceStorage.shared.instances.eraseToAnyPublisher()) {
cancellable = instancePublisher.sink { instances in
self.instances = instances
}
}
func addInstance(name: String, puzzle: String, notes: String? = nil, id: Int? = nil) {
InstanceStorage.shared.add(
name: name,
puzzle: puzzle,
notes: notes,
id: id ?? (instances.map{ Int($0.id) }.max() ?? -1) + 1
)
}
func deleteInstance(_ instance: Instance) {
InstanceStorage.shared.delete(instance)
}
func deleteInstance(withId id: Int) {
InstanceStorage.shared.delete(withId: id)
}
func updateInstance(_ instance: Instance, name: String? = nil, puzzle: String? = nil, notes: String? = nil, id: Int? = nil) {
InstanceStorage.shared.update(instance, name: name, puzzle: puzzle, notes: notes, id: id)
}
}
and then the InstanceView, which just shows some simple information for testing:
struct InstanceView: View {
#ObservedObject var instance: Instance
var body: some View {
Text(instance.name)
Text(String(instance.id))
}
}
Some of the issues I'm having are:
On iOS and iPadOS, when the app starts, it will show a blank InstanceView, pressing the back button will return to a normal instanceView and pressing it again will show the navigationView
Sometime pressing on a navigationLink will only highlight it and won't go to the destination
On an iPhone in landscape, when scrolling through the NavigationView, sometimes the selected Item will get unselected.
When I delete an item, the InstanceView shows nothing for the name and 0 for the id, as if its showing a "ghost?" instance, until you select a different one.
I've tried binding the selecting using the index of the selected Instance but that still has many of the same problems.
So I feel like I'm making some mistake in the way that I'm using NavigationView, and I was wondering what the best approach would be for creating a navigationView from an Array that works nicely across all devices.
Thanks!

How to correctly handle Picker in Update Views (SwiftUI)

I'm quite new to SwiftUI and I'm wondering how I should use a picker in an update view correctly.
At the moment I have a form and load the data in with .onAppear(). That works fine but when I try to pick something and go back to the update view the .onAppear() gets called again and I loose the picked value.
In the code it looks like this:
import SwiftUI
struct MaterialUpdateView: View {
// Bindings
#State var material: Material
// Form Values
#State var selectedUnit = ""
var body: some View {
VStack(){
List() {
Section(header: Text("MATERIAL")){
// Picker for the Unit
Picker(selection: $selectedUnit, label: Text("Einheit")) {
ForEach(API().units) { unit in
Text("\(unit.name)").tag(unit.name)
}
}
}
}
.listStyle(GroupedListStyle())
}
.onAppear(){
prepareToUpdate()
}
}
func prepareToUpdate() {
self.selectedUnit = self.material.unit
}
}
Does anyone has experience with that problem or am I doing something terribly wrong?
You need to create a custom binding which we will implement in another subview. This subview will be initialised with the binding vars selectedUnit and material
First, make your MaterialUpdateView:
struct MaterialUpdateView: View {
// Bindings
#State var material : Material
// Form Values
#State var selectedUnit = ""
var body: some View {
NavigationView {
VStack(){
List() {
Section(header: Text("MATERIAL")) {
MaterialPickerView(selectedUnit: $selectedUnit, material: $material)
}
.listStyle(GroupedListStyle())
}
.onAppear(){
prepareToUpdate()
}
}
}
}
func prepareToUpdate() {
self.selectedUnit = self.material.unit
}
}
Then, below, add your MaterialPickerView, as shown:
Disclaimer: You need to be able to access your API() from here, so move it or add it in this view. As I have seen that you are re-instanciating it everytime, maybe it is better that you store its instance with let api = API() and then refer to it with api, and even pass it to this view as such!
struct MaterialPickerView: View {
#Binding var selectedUnit: String
#Binding var material : Material
#State var idx: Int = 0
var body: some View {
let binding = Binding<Int>(
get: { self.idx },
set: {
self.idx = $0
self.selectedUnit = API().units[self.idx].name
self.material.unit = self.selectedUnit
})
return Picker(selection: binding, label: Text("Einheit")) {
ForEach(API().units.indices) { i in
Text(API().units[i].name).tag(API().units[i].name)
}
}
}
}
That should do,let me know if it works!

Insert, update and delete animations with ForEach in SwiftUI

I managed to have a nice insert and delete animation for items displayed in a ForEach (done via .transition(...) on Row). But sadly this animation is also triggered when I just update the name of Item in the observed array. Of course this is because it actually is a new view (you can see that, since onAppear() of Row is called).
As we all know the recommended way of managing lists with cool animations would be List but I think that many people would like to avoid the standard UI or the limitations that come along with this element.
A working SwiftUI example snippet is attached (Build with Xcode 11.4)
So, the question:
Is there a smart way to suppress the animation (or have another one) for just updated items that would keep the same position? Is there a cool possibility to "reuse" the row and just update it?
Or is the answer "Let's wait for the next WWDC and let's see if Apple will fix it..."? ;-)
Cheers,
Orlando 🍻
Edit
bonky fronks answer is actually a good approach when you can distinguish between edit/add/delete (e.g. by manual user actions). As soon as the items array gets updated in background (for example by synced updates coming from Core Data in your view model) you don't know if this is an update or not. But maybe in this case the answer would be to manually implement the insert/update/delete cases in the view model.
struct ContentView: View {
#State var items: [Item] = [
Item(name: "Tim"),
Item(name: "Steve"),
Item(name: "Bill")
]
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(name: item.name)
}
}
}
.navigationBarItems(leading: AddButton, trailing: RenameButton)
}
}
private var AddButton: some View {
Button(action: {
self.items.insert(Item(name: "Jeff"), at: 0)
}) {
Text("Add")
}
}
private var RenameButton: some View {
Button(action: {
self.items[0].name = "Craigh"
}) {
Text("Rename first")
}
}
}
struct Row: View {
#State var name: String
var body: some View {
HStack {
Text(name)
Spacer()
}
.padding()
.animation(.spring())
.transition(.move(edge: .leading))
}
}
struct Item: Identifiable, Hashable {
let id: UUID
var name: String
init(id: UUID = UUID(), name: String) {
self.id = id
self.name = name
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Luckily this is actually really easy to do. Simply remove .animation(.spring()) on your Row, and wrap any changes in withAnimation(.spring()) { ... }.
So the add button will look like this:
private var AddButton: some View {
Button(action: {
withAnimation(.spring()) {
self.items.insert(Item(name: "Jeff"), at: 0)
}
}) {
Text("Add")
}
}
and your Row will look like this:
struct Row: View {
#State var name: String
var body: some View {
HStack {
Text(name)
Spacer()
}
.padding()
.transition(.move(edge: .leading))
}
}
The animation must be added on the VStack with the modifier animation(.spring, value: items) where items is the value with respect to which you want to animate the view. items must be an Equatable value.
This way, you can also animate values that you receive from your view model.
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(name: item.name)
}
}
.animation(.spring(), value: items) // <<< here
}
.navigationBarItems(leading: AddButton, trailing: RenameButton)
}
}