Consider a rather simple SwiftUI app: a list view showing a list of models, where each row is a NavigationLink to a detail view. With SwiftUI, any changes to the currently viewing model automatically result in an updated UI; the detail view is always showing the latest version of the model. Hooray! :)
But what about when the model is deleted while you're on the detail view? That doesn't do anything, you're left on the detail view. See below for a very simple example that does illustrate the problem:
struct Model: Identifiable {
let id: Int
var title: String
}
class Store: ObservableObject {
#Published var models = [Model(id: 0, title: "a")]
}
struct ListView: View {
#EnvironmentObject private var store: Store
var body: some View {
NavigationView {
List(store.models) { model in
NavigationLink(destination: DetailView(model: model)) {
Text(model.title)
}
}
.navigationBarTitle("List")
}
}
}
struct DetailView: View {
#EnvironmentObject private var store: Store
var model: Model
var body: some View {
Text(model.title)
.navigationBarTitle("Detail")
.navigationBarItems(trailing: trailingNavigationBarItems)
}
private var trailingNavigationBarItems: some View {
HStack {
Button("Change title") {
self.store.models[0].title = "AAA"
}
Button("Delete model") {
self.store.models.remove(at: 0)
}
}
}
}
How would the detail view recognize that its model no longer exist and pop back to the list view? As I said, currently you're just left on the detail view, looking at a model that really no longer exists. When you manually go back to the list, that is updated and the model is gone.
If you want to run this code, just use ListView().environmentObject(Store()) as the rootView in the SceneDelegate.
(Before anyone says that I could just pop back in the delete button action, that button is just there to demonstrate the problem. In reality the model could be deleted on the server for example, so not by an action initiated from the detail view.)
I would solve the problem like this way. For example, if I want to know if the model has no content, i automatically go back to the list view by programatically popup the current view. And it works.
struct DetailView: View {
#EnvironmentObject private var store: Store
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var model: Model
var body: some View {
ZStack {
Text(model.title)
}
.navigationBarTitle("Detail")
.navigationBarItems(trailing: trailingNavigationBarItems)
.onReceive(self.store.$models) { model in
if model.count == 0 {
self.presentationMode.wrappedValue.dismiss()
}
}
}
Related
I'm looking for some help or clarification on whether my current understanding of SwiftUI breaks MVVM or not. This is part of a code challenge for a job I desperately need so really appreciate any comments.
My main view pulls in models from my view model characterManager to populate a list. Each item in the list contains a NavigationLink to navigate to a detail view. I need to pass the model to the detail view and build a view model for the detail view to use but I'm not sure where this view model should be created. Currently I'm creating the new CharacterDetailViewModel in the CharacterListView and passing to the detail view's init function. I'm not sure if it is right to create a view model directly in the view.
Here's my main view where the list is populated using character models and passed into a detail view with a new CharacterDetailViewModel:
struct CharacterListView: View {
#StateObject var characterManager = CharacterManager()
#StateObject var realmManager = RealmManager()
var body: some View {
NavigationStack {
if characterManager.isLoading {
ProgressView()
} else {
List(characterManager.characters) { character in
NavigationLink {
CharacterDetailView(characterViewModel: CharacterDetailViewModel(character: character))
} label: {
Text(character.name)
}
}
.navigationTitle("Characters")
}
}
.environmentObject(characterManager)
.environmentObject(realmManager)
}
}
And this is the detail view where the view model is set in the init
struct CharacterDetailView: View {
#EnvironmentObject var characterManager: CharacterManager
#ObservedObject var viewModel: CharacterDetailViewModel
init(characterViewModel: CharacterDetailViewModel) {
self.viewModel = characterViewModel
}
var body: some View {
...
}
}
Is this a legal way to do what I'm trying to achieve or is there a better way to do this? The company I'm applying to uses MVVM with SwiftUI so I have to stick with this architecture.
I would make it simple and directly use the Character model into the application without the use if a view model. In SwiftUI, view is the View Model so in most cases you don't need to create separate VM for each screen. I would use the following approach.
#MainActor
class CharacterManager: ObservableObject {
#Published var characters: [Character] = []
func fetchCharacters() {
characters = [Character(name: "Character 1"), Character(name: "Character 2")]
}
}
struct Character: Identifiable {
let id = UUID()
let name: String
}
struct CharacterDetailView: View {
let character: Character
var body: some View {
Text(character.name)
.font(.largeTitle)
}
}
struct ContentView: View {
#StateObject private var characterManager = CharacterManager()
var body: some View {
NavigationStack {
List(characterManager.characters) { character in
NavigationLink {
CharacterDetailView(character: character)
} label: {
Text(character.name)
}
}.onAppear {
characterManager.fetchCharacters()
}
}
}
}
When I update a binding property from an array in a pushed view 2+ layers down, the navigation pops back instantly after a change to the property.
Xcode 13.3 beta, iOS 15.
I created a simple demo and code is below.
Shopping Lists
List Edit
List section Edit
Updating the list title (one view deep) is fine, navigation stack stays same, and changes are published if I return. But when adjusting a section title (two deep) the navigation pops back as soon as I make a single change to the property.
I have a feeling I'm missing basic fundamentals here, and I have a feeling it must be related to the lists id? but I'm struggling to figure it out or work around it.
GIF
Code:
Models:
struct ShoppingList {
let id: String = UUID().uuidString
var title: String
var sections: [ShoppingListSection]
}
struct ShoppingListSection {
let id: String = UUID().uuidString
var title: String
}
View Model:
final class ShoppingListsViewModel: ObservableObject {
#Published var shoppingLists: [ShoppingList] = [
.init(
title: "Shopping List 01",
sections: [
.init(title: "Fresh food")
]
)
]
}
Content View:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}
}
}
ShoppingListsView
struct ShoppingListsView: View {
#StateObject private var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
ShoppingListEditView
struct ShoppingListEditView: View {
#Binding var shoppingList: ShoppingList
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("Title", text: $shoppingList.title)
}
Section(header: Text("Sections")) {
List($shoppingList.sections, id: \.id) { $section in
NavigationLink(destination: ShoppingListSectionEditView(section: $section)) {
Text(section.title)
}
}
}
}
.navigationBarTitle("Edit list")
}
}
ShoppingListSectionEditView
struct ShoppingListSectionEditView: View {
#Binding var section: ShoppingListSection
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("title", text: $section.title)
}
}
.navigationBarTitle("Edit section")
}
}
try this, works for me:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}.navigationViewStyle(.stack) // <--- here
}
}
Try to make you object confirm to Identifiable and return value which unique and stable, for your case is ShoppingList.
Detail view seems will pop when object id changed.
The reason your stack is popping back to the root ShoppingListsView is that the change in the list is published and the root ShoppingListsView is registered to listen for updates to the #StateObject.
Therefore, any change to the list is listened to by ShoppingListsView, causing that view to be re-rendered and for all new views on the stack to be popped in order to render the root ShoppingListsView, which is listening for updates on the #StateObject.
The solution to this is to change the #StateObject to #EnvironmentObject
Please refactor your code to change ShoppingListsViewModel to use an #EnvironmentObject wrapper instead of a #StateObject wrapper
You may pass the environment object in to all your child views and also add a boolean #Published flag to track any updates to the data.
Then your ShoppingListView would look as below
struct ShoppingListsView: View {
#EnvironmentObject var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
Don't forget to pass the viewModel in to all your child views.
That should fix your problem.
I have two views written in swiftUI , say for example ViewA and ViewB.
onAppear() of ViewA has an apiCall which calls when initially the view is loaded.
I navigate to ViewB from ViewA using navigation link and on clicking back button in ViewB the onAppear() of ViewA is called.
• Is there any way to stop calling onAppear() while navigated back from a view
• I am looking swiftUI for something like 'ViewDidLoad' in UIKit
given a sample of my code
struct ContentView: View {
var body: some View {
NavigationView{
List(viewModel.list){ item in
NavigationLink(
destination: Text("Destination"),
label: {
Text(item.name)
})
}
.onAppear{
viewModel.getListApiCall()
}
}
}
}
Overview
SwiftUI is quite different from the way UIKit works.
It would be best to watch the tutorials (links below) to understand how SwiftUI and Combine works.
SwiftUI is a declarative framework so the way we approach is quite different. It would be best not to look for a direct comparison to UIKit for equivalent functions.
Model:
Let the model do all the work of fetching and maintaining the data
Ensure that your model conforms to ObservableObject
When ever any #Published property changes, it would imply that the model has changed
View:
Just display the contents of the model
By using #ObservedObject / #EnvironmentObject SwiftUI would observe the model and ensure that the view states in sync with any changes made to the model
Notice that though the model fetches the data after 2 seconds, the view reacts to it and displays the updated data.
Model Code:
class Model: ObservableObject {
#Published var list = [Item]()
init() {
fetchItems()
}
private func fetchItems() {
//To simulate some Async API call
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.list = (1...10).map { Item(name: "name \($0)") }
}
}
}
struct Item: Identifiable {
var name: String
var id : String {
name
}
}
View Code:
import SwiftUI
struct ContentView: View {
#ObservedObject var model: Model
var body: some View {
NavigationView{
List(model.list){ item in
NavigationLink(destination: Text("Destination")) {
Text(item.name)
}
}
}
}
}
Reference:
SwiftUI
https://developer.apple.com/videos/play/wwdc2020/10119
https://developer.apple.com/videos/play/wwdc2020/10037
https://developer.apple.com/videos/play/wwdc2020/10040
Combine
https://developer.apple.com/videos/play/wwdc2019/722
https://developer.apple.com/videos/play/wwdc2019/721
https://developer.apple.com/videos/play/wwdc2019/226
You could add a variable to check if the getListApiCall() has been invoked.
struct ContentView: View {
#State var initHasRun = false
var body: some View {
NavigationView{
List(viewModel.list){ item in
NavigationLink(
destination: Text("Destination"),
label: {
Text(item.name)
})
}
.onAppear{
if !initHasRun {
viewModel.getListApiCall()
initHasRun=true
}
}
}
}
}
The root view of my onboarding process has a NavigationView. The root view of my app is a login page that also contains a NavigationView. That means when someone launches the app for the first time, they will go through the onboarding process and land at the login screen - resulting in a navigation view within a navigation view.
Is there a way to reset the view stack or simply remove the extra navigation view when necessary?
This is how I implemented #New Dev's solution below. First comes the Tower class. (The name helps me visualize the fact that it's an ObservableObject.) Its job is to keep track of the currentPage and let interested views know when it has changed.
import Foundation
import SwiftUI
import Combine
class Tower: ObservableObject {
enum Views {
case onboarding, login, dashboard
}
let objectWillChange = PassthroughSubject<Tower, Never>()
#Published var currentPage: Views = .onboarding {
didSet {
objectWillChange.send(self)
}
}
}
Next comes the ConductorView. It is notified by the Tower when currentPage changes, and loads the corresponding view.
struct ConductorView: View {
#EnvironmentObject var tower: Tower
var body: some View {
VStack {
if tower.currentPage == .onboarding {
ContentViewA()
} else if tower.currentPage == .login {
ContentViewB()
}
}
}
}
And lastly, a content view.
struct ContentViewA: View {
#EnvironmentObject var tower: Tower
var body: some View {
Button(action: {
self.tower.currentPage = .login
}) {
Text("Go to Login")
}
}
}
}
In addition to New Devs greatly appreciated solution, I also used this article from BLCKBIRDS.
I'll expand on my comment. NavigationView/NavigationLink aren't the only ways to change views - a simple conditional can also be used to determine which view is rendered.
So, say, you have some class that contains the state of the login/onboarding information:
class AppState: ObservableObject {
enum UserFlow {
case onboarding, login, home
}
#Published var userFlow: UserFlow = .onboarding
// ...
}
Then your RootView could determine which user flow to show:
struct RootView: View {
#EnvironmentObject var appState: AppState
var body: some View {
if appState.userFlow == .onboarding {
OnboardingRootView()
} else if appState.userFlow == .login {
LoginRootView()
} else {
ContentView()
}
}
}
I'm working on a SwiftUI practice app and I ran into an issue with the NavigationView/NavigationLink. I am currently using the Metropolitan Museum of Art API and I wanted to make a list of departments that segues to another list of objects in that department, then that list segues to the object's information. Currently the NavigationView/NavigationLink setup I have is creating multiple NavigationViews and is resulting in multiple back buttons/navigation bars. Is there a way to have the new NavigationView replace the old one or have them work in line with one another? The only way I know how to create a segue in SwiftUI is through a NavigationView/NavigationLink but creating it twice seems to be the wrong way to go about things. I have a screen shot of the current state of my app.
App Image
This is my code at the moment.
struct ContentView: View {
#ObservedObject var model = DepartmentListViewModel()
var body: some View {
NavigationView {
List {
ForEach(model.departments, id: \.self) { department in
NavigationLink(destination: DetailView(viewModel: DetailListViewModel(selectedDepartment: department))) {
Text(department.displayName)
}
}
}
.navigationBarTitle("Departments")
}
}
}
struct DetailView: View {
#ObservedObject var viewModel: DetailListViewModel
init(viewModel: DetailListViewModel) {
self.viewModel = viewModel
}
var body: some View {
NavigationView {
List {
ForEach(viewModel.objects, id: \.self) { object in
NavigationLink(destination: ObjectView(viewModel: ObjectListViewModel(selectedObject: object))) {
Text(String(object))
}
}
}
.navigationBarTitle("ObjectIDs")
}
}
}
You don't need NavigationView in your DetailView anymore, the first one handle it