NavigationLink hides the Destination View, or causes infinite view updates - swiftui

Let us consider the situation when you have ContentView and DestinationView. Both of them depend on some shared data, that typically lies inside the #ObservedObject var viewModel, that you pass from parent to child either via #EnvironmentObject or directly inside init().
The DestinationView in this case wants to enrich the viewModel by fetching some additional content inside .onAppear.
In this case, when using NavigationLink you might encounter the situation when the DestinationView gets into an update loop when you fetching content, as it also updates the parent view and the whole structure is redrawn.
When using the List you explicitly set the row's ids and thus view is not changed, but if the NavigationLink is not in the list, it would update the whole view, resetting its state, and hiding the DestinationView.
The question is: how to make NavigationLink update/redraw only when needed?

In SwiftUI the update mechanism compares View structs to find out whether they need to be updated, or not. I've tried many options, like making ViewModel Hashable, Equatable, and Identifiable, forcing it to only update when needed, but neither worked.
The only working solution, in this case, is making a NavigationLink wrapper, providing it with id for equality checks and using it instead.
struct NavigationLinkWrapper<DestinationView: View, LabelView: View>: View, Identifiable, Equatable {
static func == (lhs: NavigationLinkWrapper, rhs: NavigationLinkWrapper) -> Bool {
lhs.id == rhs.id
}
let id: Int
let label: LabelView
let destination: DestinationView // or LazyView<DestinationView>
var body: some View {
NavigationLink(destination: destination) {
label
}
}
}
Then in ContentView use it with .equatable()
NavigationLinkWrapper(id: self.viewModel.hashValue,
label: myOrdersLabel,
destination: DestinationView(viewModel: self.viewModel)
).equatable()
Helpful tip:
If your ContentView also does some updates that would impact the DestinationView it's suitable to use LazyView to prevent Destination from re-initializing before it's even on the screen.
struct LazyView<Content: View>: View {
let build: () -> Content
init(_ build: #autoclosure #escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
P.S: Apple seems to have fixed this issue in iOS14, so this is only iOS13 related issue.

Related

Swift UI show modal sheet with tab bar visible

Is there anyway to keep the tab bar showing while presenting a modal / sheet view?
Here is a minimal failing example.
import SwiftUI
struct SheetView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
Button("Press to dismiss") {
dismiss()
}
.padding()
}
}
struct Tab1: View {
#State private var showingSheet = false
var body: some View {
Button("Show Sheet") {
showingSheet.toggle()
}
.sheet(isPresented: $showingSheet) {
SheetView()
}
}
}
struct MainView: View {
var body: some View {
TabView {
Tab1()
.tabItem {
Label("Tab 1", systemImage: "heart")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
MainView()
}
}
Thanks for answering my question in the comments.
Unfortunately the standard means of presenting views in SwiftUI is that they are truly modal – they capture the whole interaction context for the current scene, and you can’t interact with anything else until the modal is dismissed.
This is also the case for iPadOS. Even though a modal presented with .sheet on an iPad allows much more of the underlying view to be visible, you can’t interact with it until the sheet disappears. You can interact with different parts of the app by running two scenes side-by-side in split screen mode, but each half is a separate scene and any presented sheets are modal for that scene.
If you want one tab to optionally present a view over its usual content but still allow access to the tab view and its other tabs, that’s not a modal context and SwiftUI’s built-in sheet won’t work. You will have to implement something yourself - but I think that’s doable.
Rather than using .sheet, you could optionally add an overlay to your Tab1 view, using the same boolean state variable showingSheet. In this approach, the default dismiss environment variable won’t be available, so passing in the state variable as a binding value would be an alternative:
var body: some View
<main display>
.overlay(showingSheet ? Sheet1(presented: $showingSheet) : EmptyView())
You might also find that a ZStack works better than .overlay depending on what the contents of the tab view actually are.
You’ll definitely have a lot more structural work to do to make this work, but I hope you can see that it’s possible.

SwiftUI view updates even if it does not depend on a dynamic property

I created an ObservableObject that gathers data and a view that depends on this object. More specifically some parts of the UI depend on one property of it and other parts depend on other properties.
The way ObservableObject works is that if any of its Published property gets updated (event if it does not change) it sends an objectWillChange notification that triggers updates for subscribed views.
However, I did expect that only the parts of the views that depends on an ObservableObject property that actually changed would be updated, not the entire view because body computations are expensive.
Unfortunately this it not the behavior I observed during my experiments. For instance in the following code the List view depends only on the color dynamic property of the ObservedObject "preferences" and the TextField updates only the text property of the observed object. I observed with the debugPrint's that when I type text the List and its rows gets updated each time even if it does not depend on text.
struct ContentView: View {
#State private var list = ["foo", "bar", "baz"]
#StateObject private var preferences = Preferences()
var body: some View {
VStack {
List(list, id: \.self) { element in
Text(element)
.foregroundColor(preferences.color)
.debugPrint("Row view updated")
}
Spacer()
HStack {
TextField("Text", text: $preferences.text)
.debugPrint("Text field view updated")
Button("Toogle Color", action: toggleColor)
.debugPrint("Button view updated")
}
}
.padding()
.debugPrint("Content view updated")
}
func toggleColor() {
preferences.color = [.blue, .green, .orange, .red].randomElement()!
}
}
final class Preferences: ObservableObject {
#Published var color: Color = .blue
#Published var text: String = ""
}
extension View {
func debugPrint(_ elements: Any...) -> Self {
#if DEBUG
print(elements)
return self
#else
return self
#endif
}
}
Is this the correct and intended behavior? How the optimal behavior I described above could be obtained, i.e. not calling body when the ObservedObject changes but only the components that depends on specific properties?
I observed in bigger SwiftUI projects that this behavior can greatly slow down the application and is not easily debuggable because unique state is often enforced through heavy ObservedObjects (or StateObject) injected at the root view and used in many places. I Observed this behavior even with small lists of components with heavy UI layout.
Note 1: I was bugged by the StateObject definition which suggests that only the views that depend on those properties are updated (and not when the StateObject changes).
SwiftUI creates a new instance of the object only once for each
instance of the structure that declares the object. When published
properties of the observable object change, SwiftUI updates the parts
of any view that depend on those properties [...]
Note 2: The issue totally disappear if I define the List in its own component, the rendering is fast:
struct ListView: View {
let list: [String] // Also works with a #Binding
let color: Color // Also works if it would be `preferences`
var body: some View {
List {
ForEach(list, id: \.self) { element in
Row(text: element, color: color)
}
}
}
}
Why in this case the view updates are fast?

SwiftUI - How to create an edit view for each list view item based on #ObservedObject

I have created a simple example of the problem I'm facing. I have two views, ListView and EditView. ListView is observing the UsersViewModel (plural) which contain a list of user names.
Using NavigationLink I want to present a form where I can edit the user name and this is where I'm getting confused. I have created a UserViewModel (singular) which I have the EditView observing, but when I try to call the EditView passing the value from the ForEach loop, I get a type mismatch error as I am not passing a UserViewModel.
Maybe I am misunderstanding the observable object. I thought I could change the user name on the edit form, navigate back to the list view and I would see the change in the list.
struct ListView: View {
// Observe the Users View Model
#ObservedObject var usersViewModel = UsersViewModel()
var body: some View {
NavigationView {
List() {
ForEach(usersViewModel.users) { user in
// FAILS with cannot converted "user" to expected type userViewModel
NavigationLink (destination: EditView(userViewModel: user)) {
Text("Hello \(user.name)")
}
}
}
.navigationBarTitle("User List", displayMode: .inline)
.onAppear(){
usersViewModel.loadUsers()
}
The edit view
struct EditView: View {
#ObservedObject var userViewModel: UserViewModel
var body: some View {
TextField("User Name", text: $userViewModel.user)
cannot converted "user" to expected type userViewModel
You probably want to use the same UserViewModel in both views (assuming the user is a struct).
Change your EditView to expect a usersViewModel parameter in init:
struct EditView: View {
#ObservedObject var usersViewModel: UsersViewModel
and pass it in the parent view:
NavigationLink (destination: EditView(usersViewModel: usersViewModel))
Otherwise, as the user is probably a struct, you will modify the different copy of the user in the child view.
I have got around the issue by passing the usersViewModel to the editView rather than userViewModel as pawello2222 suggested, but obviously you need to know which element you want to edit.
So I have done this;
let users = usersViewModel.users.enumerated().map({ $0 })
NavigationView {
List() {
ForEach(users, id: \.element.id ) { index, user in
NavigationLink (destination: EditView(userViewModel: usersViewModel, index: index)) {
This seems long winded, surely having to enumerate the viewModel to provide the EditView with an index of which element to edit is not the correct way?
It works, but I would like to know the proper way

How can I "forward" the entire SwiftUI environment to another view?

It seems that certain SwiftUI views create new environment contexts, such as NavigationLink. None of the environment is available in the new view.
As a workaround, I've been just manually forwarding through environment variables, like this:
struct ExampleView: View {
#EnvironmentObject var foo: UserStore
#EnvironmentObject var bar: UserStore
var body: some View {
NavigationLink(destination:
SomeOtherView()
.environmentObject(self.foo)
.environmentObject(self.bar)
) {
Text("Open View")
}
}
}
However this seems broken, as it violates the purpose of the environment. Also, it's confusing because it's not clear (or documented?) where these boundaries are and it causes a runtime error when a view depends on a missing EnvironmentObject.
Is there a better way to do this?
Similarly, I want to create a wrapper UIViewControllerRepresentable that can contain SwiftUI children (via UIHostingController) and I would like those children to have access to the environment as well.

The Picker not visible in the view hierarchy warming

I tried to make a Picker in swiftui with below code.
import SwiftUI
struct AddUserUIView: View {
#Environment(\.managedObjectContext) var moc
#Environment(\.presentationMode) var presentationMode
#State private var type = 1
let types = ["type1", "type2"]
var body: some View {
NavigationView {
Form {
Section {
Picker("The type is", selection: $type) {
ForEach(0 ..< types.count) {
Text("\(self.types[$0])")
}
}
}
}
.navigationBarTitle("Add user")
}
}
}
This UIView is in sheet and I pass core data context into it so that it can be save in it. But it print a warming if I change the Picker value.
[TableView] Warning once only:
UITableView was told to layout its visible cells and other contents without being in the view hierarchy (the table view or one of its superviews has not been added to a window).
This may cause bugs by forcing views inside the table view to load and perform layout without accurate information (e.g. table view bounds, trait collection, layout margins, safe area insets, etc),
and will also cause unnecessary performance overhead due to extra layout passes.