The body of view to be destroyed gets called (but it shouldn't) - ios

While verifying how binding invalidates a view (indirectly), I find an unexpected behavior.
If the view hierarchy is
list view -> detail view
it works fine (as expected) to press a button in the detail view to delete the item.
However, if the view hierarchy is
list view -> detail view -> another detail view (containing the same item)
it crashes when I press a button in the top-most detail view to delete the item. The crash occurs in the first detail view (the underlying one), because its body gets called.
To put it in another way, the behavior is:
If the detail view is the top-most view in the navigation stack, its body doesn't get called.
Otherwise, its body gets called.
I can't think out any reason for this behavior. My debugging showed below are what happened before the crash:
I pressed a button in top-most detail view to delete the item.
The ListView's body got called (as a result of ContentView body got called). It created only the detail view for the left item.
Then the first DetailView's body get called. This is what caused the crash. I can't think out why this occurred, because it certainly didn't occur for the top-most detail view.
Below is the code. Note the ListView and DetailView contains only binding and regular properties (they don't contain observable object or environment object, which I'm aware complicate the view invalidation behavior).
import SwiftUI
struct Foo: Identifiable {
var id: Int
var value: Int
}
// Note that I use forced unwrapping in data model's APIs. This is intentional. The rationale: the caller of data model API should make sure it passes a valid id.
extension Array where Element == Foo {
func get(_ id: Int) -> Foo {
return first(where: { $0.id == id })!
}
mutating func remove(_ id: Int) {
let index = firstIndex(where: { $0.id == id })!
remove(at: index)
}
}
class DataModel: ObservableObject {
#Published var foos: [Foo] = [Foo(id: 1, value: 1), Foo(id: 2, value: 2)]
}
struct ListView: View {
#Binding var foos: [Foo]
var body: some View {
NavigationView {
List {
ForEach(foos) { foo in
NavigationLink {
DetailView(foos: $foos, fooID: foo.id, label: "First detail view")
} label: {
Text("\(foo.value)")
}
}
}
}
}
}
struct DetailView: View {
#Binding var foos: [Foo]
var fooID: Int
var label: String
var body: some View {
// The two print() calls are for debugging only.
print(Self._printChanges())
print(label)
print(fooID)
return VStack {
Text(label)
Divider()
Text("Value: \(foos.get(fooID).value)")
NavigationLink {
DetailView(foos: $foos, fooID: fooID, label: "Another detail view")
} label: {
Text("Create another detail view")
}
Button("Delete It") {
foos.remove(fooID)
}
}
}
}
struct ContentView: View {
#StateObject var dataModel = DataModel()
var body: some View {
ListView(foos: $dataModel.foos)
}
}
Test 1: Start the app, click on an item in the list view to go to the detail view, then click on "Delete It" button. This works fine.
The view hierarchy: list view -> detail view
Test 2: Start the app, click on an item in the list view to go to the detail view, then click on "Create another detail view" to go to another detail view. Then click on "Delete It" button. The crashes the first detail view.
The view hierarchy: list view -> detail view -> another detail view
Could it be just another bug of #Binding? Is there any robust way to work around the issue?

You need to use your data model rather than performing procedural code in your views. Also, don't pass items by id; Just pass the item.
Because you use the id of the Foo instead of the Foo itself, and you have a force unwrap in your get function, you get a crash.
If you refactor to use your model and not use ids it works as you want.
You don't really need your array extension. Specialised code as an extension to a generic object doesn't look right to me.
The delete code is so simple you can just handle it in your model, and do so safely with conditional unwrapping.
class DataModel: ObservableObject {
#Published var foos: [Foo] = [Foo(id: 1, value: 1), Foo(id: 2, value: 2)]
func delete(foo: Foo) {
if let index = firstIndex(where: { $0.id == id }) {
self.foos.remove(at: index)
}
}
}
struct ListView: View {
#ObservedObject var model: DataModel
var body: some View {
NavigationView {
List {
ForEach(model.foos) { foo in
NavigationLink {
DetailView(model: model, foo: foo, label: "First detail view")
} label: {
Text("\(foo.value)")
}
}
}
}
}
}
struct DetailView: View {
#ObservedObject var model: DataModel
var foo: Foo
var label: String
var body: some View {
// The two print() calls are for debugging only.
print(Self._printChanges())
print(label)
print(foo.id)
return VStack {
Text(label)
Divider()
Text("Value: \(foo.value)")
NavigationLink {
DetailView(model: model, foo: foo, label: "Another detail view")
} label: {
Text("Create another detail view")
}
Button("Delete It") {
model.delete(foo:foo)
}
}
}
}

I think this is very much like Paul's approach. I just kept the Array extension with the force unwrap as in OP.
struct Foo: Identifiable {
var id: Int
var value: Int
}
// Note that I use forced unwrapping in data model's APIs. This is intentional. The rationale: the caller of data model API should make sure it passes a valid id.
extension Array where Element == Foo {
func get(_ id: Int) -> Foo {
return first(where: { $0.id == id })!
}
mutating func remove(_ id: Int) {
let index = firstIndex(where: { $0.id == id })!
remove(at: index)
}
}
class DataModel: ObservableObject {
#Published var foos: [Foo] = [Foo(id: 1, value: 1), Foo(id: 2, value: 2), Foo(id: 3, value: 3)]
}
struct ListView: View {
#EnvironmentObject var dataModel: DataModel
var body: some View {
NavigationView {
List {
ForEach(dataModel.foos) { foo in
NavigationLink {
DetailView(foo: foo, label: "First detail view")
} label: {
Text("\(foo.value)")
}
}
}
}
}
}
struct DetailView: View {
#EnvironmentObject var dataModel: DataModel
var foo: Foo
var label: String
var body: some View {
// The two print() calls are for debugging only.
print(Self._printChanges())
print(label)
print(foo.id)
return VStack {
Text(label)
Divider()
Text("Value: \(foo.value)")
NavigationLink {
DetailView(foo: foo, label: "Yet Another detail view")
} label: {
Text("Create another detail view")
}
Button("Delete It") {
dataModel.foos.remove(foo.id)
}
}
}
}
struct ContentView: View {
#StateObject var dataModel = DataModel()
var body: some View {
ListView()
.environmentObject(dataModel)
}
}

Here is a working version. It's best to pass the model around so you can use array subscripting to mutate.
I also changed your id to UUID because that's what I'm used to and changed some vars that should be lets.
import SwiftUI
struct Foo: Identifiable {
//var id: Int
let id = UUID()
var value: Int
}
// Note that I use forced unwrapping in data model's APIs. This is intentional. The rationale: the caller of data model API should make sure it passes a valid id.
//extension Array where Element == Foo {
// func get(_ id: Int) -> Foo {
// return first(where: { $0.id == id })!
// }
//
// mutating func remove(_ id: Int) {
// let index = firstIndex(where: { $0.id == id })!
// remove(at: index)
// }
//}
class DataModel: ObservableObject {
//#Published var foos: [Foo] = [Foo(id: 1, value: 1), Foo(id: 2, value: 2)]
#Published var foos: [Foo] = [Foo(value: 1), Foo(value: 2)]
func foo(id: UUID) -> Foo? {
foos.first(where: { $0.id == id })
}
}
struct ListView: View {
//#Binding var foos: [Foo]
#StateObject var dataModel = DataModel()
var body: some View {
NavigationView {
List {
//ForEach(foos) { foo in
ForEach(dataModel.foos) { foo in
NavigationLink {
//DetailView(foos: $foos, fooID: foo.id, label: "First detail view")
DetailView(dataModel: dataModel, foo: foo, label: "First detail view")
} label: {
Text("\(foo.value)")
}
}
}
}
}
}
struct DetailView: View {
//#Binding var foos: [Foo]
#ObservedObject var dataModel: DataModel
//var fooID: Int
let foo: Foo
let label: String
var body: some View {
// The two print() calls are for debugging only.
print(Self._printChanges())
print(label)
//print(fooID)
print(foo.id)
return VStack {
Text(label)
Divider()
//Text("Value: \(foos.get(fooID).value)")
if let foo = dataModel.foo(id:foo.id) {
Text("Value: \(foo.value) ")
}
NavigationLink {
DetailView(dataModel: dataModel, foo: foo, label: "Another detail view")
} label: {
Text("Create another detail view")
}
Button("Delete It") {
//foos.remove(fooID)
if let index = dataModel.foos.firstIndex(where: { $0.id == foo.id } ) {
dataModel.foos.remove(at: index)
}
}
}
}
}
struct ContentView: View {
// no need for # here because body doesn't need to update when model changes
//#StateObject var dataModel = DataModel()
var body: some View {
//ListView(foos: $dataModel.foos)
ListView()
}
}

This is a version that uses Paul's approach but still uses binding. Note both versions don't really "solve" the issue (the behavior I described in my original question still exists) but instead "avoid" the crash by not accessing data model when rendering the view hierarchy in the body. I think this is a key point to use a framework successfully - don't fight it.
Regarding the use of binding in the code example, I'm aware most people use ObservableObject or EnvironmentObject. I used to do that too. I noticed the use of binding in Apple's demo app. But I may consider to switch back to the view model approach.
import SwiftUI
struct Foo: Identifiable {
var id: Int
var value: Int
}
// Note that I use forced unwrapping in data model's APIs. This is intentional. The rationale: the caller of data model API should make sure it passes a valid id.
extension Array where Element == Foo {
func get(_ id: Int) -> Foo {
return first(where: { $0.id == id })!
}
mutating func remove(_ id: Int) {
let index = firstIndex(where: { $0.id == id })!
remove(at: index)
}
}
class DataModel: ObservableObject {
#Published var foos: [Foo] = [Foo(id: 1, value: 1), Foo(id: 2, value: 2)]
}
struct ListView: View {
#Binding var foos: [Foo]
var body: some View {
NavigationView {
List {
ForEach(foos) { foo in
NavigationLink {
DetailView(foos: $foos, foo: foo, label: "First detail view")
} label: {
Text("\(foo.value)")
}
}
}
}
}
}
struct DetailView: View {
#Binding var foos: [Foo]
var foo: Foo
var label: String
var body: some View {
// The two print() calls are for debugging only.
print(Self._printChanges())
print(label)
print(foo)
return VStack {
Text(label)
Divider()
Text("Value: \(foo.value)")
NavigationLink {
DetailView(foos: $foos, foo: foo, label: "Another detail view")
} label: {
Text("Create another detail view")
}
Button("Delete It") {
foos.remove(foo.id)
}
}
}
}
struct ContentView: View {
#StateObject var dataModel = DataModel()
var body: some View {
ListView(foos: $dataModel.foos)
}
}

Related

Updating a binding value pops back to the parent view in the navigation stack

I am passing a Person binding from the first view to the second view to the third view, when I update the binding value in the third view it pops back to the second view, I understand that SwiftUI updates the views that depend on the state value, but is poping the current view is the expected behavior or I am doing something wrong?
struct Person: Identifiable {
let id = UUID()
var name: String
var numbers = [1, 2]
}
struct FirstView: View {
#State private var people = [Person(name: "Current Name")]
var body: some View {
NavigationView {
List($people) { $person in
NavigationLink(destination: SecondView(person: $person)) {
Text(person.name)
}
}
}
}
}
struct SecondView: View {
#Binding var person: Person
var body: some View {
Form {
NavigationLink(destination: ThirdView(person: $person)) {
Text("Update Info")
}
}
}
}
struct ThirdView: View {
#Binding var person: Person
var body: some View {
Form {
Button(action: {
person.numbers.append(3)
}) {
Text("Append a new number")
}
}
}
}
When navigating twice you need to either use isDetailLink(false) or StackNavigationViewStyle, e.g.
struct FirstView: View {
#State private var people = [Person(name: "Current Name")]
var body: some View {
NavigationView {
List($people) { $person in
NavigationLink(destination: SecondView(person: $person)) {
Text(person.name)
}
.isDetailLink(false) // option 1
}
}
.navigationViewStyle(.stack) // option 2
}
}
SwiftUI works by updating the rendered views to match what you have in your state.
In this case, you first have a list that contains an element called Current Name. Using a NavigationLink you select this item.
You update the name and now that previous element no longer exists, it's been replaced by a new element called New Name.
Since Current Name no longer exists, it also cannot be selected any longer, and the view pops back to the list.
To be able to edit the name without popping back, you'll need to make sure that the item on the list is the same, even if the name has changed. You can do this by using an Identifiable struct instead of a String.
struct Person: Identifiable {
let id = UUID().uuidString
var name = "Current Name"
}
struct ParentView: View {
#State private var people = [Person()]
var body: some View {
NavigationView {
List($people) { $person in
NavigationLink(destination: ChildView(person: $person)) {
Text(person.name)
}
}
}
}
}
struct ChildView: View {
#Binding var person: Person
var body: some View {
Button(action: {
person.name = "New Name"
}) {
Text("Update Name")
}
}
}

SwiftUI NavigationView pop itself when a datasource is finished loading

Let say you are loading some data from network when you are at screen A that it takes sometime. While you are waiting you can navigate to other screens by using NavigationLink. So, at the moment you are at the screen B then the data from network is finished loading and send value back to datasource in screen A. The NavigationView pop itself automatically so you back to screen A by unintentionally. Do you have any idea? Thanks.
Example
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
NavigationView {
List(viewModel.dataSource, id: \.self) { item in
NavigationLink(destination: Text("\(item)")) {
Text("\(item)")
.padding()
}
}
}
}
}
class ViewModel: ObservableObject {
#Published private(set) var dataSource = [1, 2, 3, 4, 5]
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { // simulate calling webservice
self.dataSource[0] = 99
}
}
}
This happens because you're specifying id as item itself, and when list updated there's no original item anymore, so it closes
If you just wanna modify items without adding/removing/reordering, you can make index your item id:
NavigationView {
List(viewModel.dataSource.indices, id: \.self) { i in
let item = viewModel.dataSource[i]
NavigationLink(destination: Text("\(item)")) {
Text("\(item)")
.padding()
}
}
}
But with more complex data you need to have your items Identifiable with unique ids, and you won't have such problem. Check out this example:
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
NavigationView {
List(viewModel.dataSource) { item in
NavigationLink(destination: Text("\(item.value)")) {
Text("\(item.value)")
.padding()
}
}
}
}
}
class ViewModel: ObservableObject {
#Published private(set) var dataSource: [Item] = [1, 2, 3, 4, 5]
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [self] in // simulate calling webservice
// you're modifying value but id stays the same
self.dataSource[0].value = 99
}
}
}
struct Item: Identifiable, ExpressibleByIntegerLiteral {
let id = UUID()
var value: Int
init(integerLiteral value: IntegerLiteralType) {
self.value = value
}
}

How to jump from one detail page to another and return to the list page in SwiftUI?

When I use the "next article" button to jump to the article details page with index 3, I want to go directly back to the article list page instead of the article details page with index 2.I tried to search for methods to return to the specified page and destroy the page, but I didn't find them.How to achieve this effect in swiftui?Thanks.I guess the same scenario will happen in other mobile development, right?
The ArticleListView is :
struct ArticleListView: View {
#EnvironmentObject var modelData:ModelData
var body: some View {
NavigationView{
List{
ForEach(modelData.articleList){ article in
NavigationLink(destination:ArticleDetail(index:article.index)){
ArticleItem(index:article.index);
}
}
}
.listStyle(PlainListStyle())
}
}
}
The ArticleDetail is like this:
struct ArticleDetail: View {
#EnvironmentObject var modelData:ModelData
var index:Int
var body: some View {
VStack{
Text(modelData.articleList[index].htmlText)
NavigationLink(destination:ArticleDetail(index:self.index+1)){
Text("next article")
}
}
}
}
The Article/ArticleItemView/ModelData is like this:
struct Article:Identifiable{
var id = UUID()
var index:Int
var htmlText:String
}
struct ArticleItem: View {
#EnvironmentObject var modelData:ModelData
var index:Int
var body: some View {
Text(modelData.articleList[index].htmlText)
}
}
final class ModelData:ObservableObject {
#Published var articleList = [Article(index:0,htmlText: "first test text "),Article(index:1,htmlText: "second test text"),Article(index:2,htmlText: "third test text")]
}
This solution has some potential scalability issues, but it gets the basic job done:
struct Article {
var id = UUID()
}
struct ContentView: View {
var articles = [Article(), Article(), Article(), Article()]
#State private var activeId : UUID?
func activeBinding(id: UUID) -> Binding<Bool> {
.init { () -> Bool in
activeId == id
} set: { (newValue) in
activeId = newValue ? id : nil
}
}
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 20) {
ForEach(articles, id: \.id) { article in
NavigationLink(destination: ArticleView(article: article,
articles: articles,
popToTop: { activeId = nil }),
isActive: activeBinding(id: article.id)) {
Text("Link to article: \(article.id)")
}
}
}
}
}
}
struct ArticleView : View {
var article : Article
var articles : [Article]
var popToTop: () -> Void
var body : some View {
VStack(alignment: .leading, spacing: 20) {
Text("Current: \(article.id)")
Button("Pop") {
popToTop()
}
ForEach(articles, id: \.id) { listArticle in
NavigationLink(destination: ArticleView(article: article, articles: articles, popToTop: popToTop)) {
Text("Link to article: \(listArticle.id)")
}
}
}
}
}
On the main page, the top-level article ID is stored in a #State variable. That is tied with a custom binding to an isActive property on the top-level link. Basically, when the article is active, the link is presented and when activeId is nil, the link becomes inactive, and pops to the top.
Because that's the top level view, any views lower in the stack will get popped off if that top-level NavigationLink is inactive.
popToTop is a function that gets passed down to the subsequent article views and gets called if the "Pop" button is pressed.

How to update ParentView after updating SubView #ObservedObject SwiftUI

This is a simple example for my case.
I have a #ObservedObject viewModel (Object1), pass a property (Object2) to another view (View2) . Change value in View 2, and when i go back to View 1, i wish the value is updated too. What is the best solution?
In this Example, when i press the blue number, i wish black number is also updated.
Actually I don't know why do the black number is updated after pressing button "Show".
I would really appreciate if you could help me. Thanks.
import SwiftUI
import Combine
struct ContentView: View {
#ObservedObject var object1: Object1 = Object1(ob: Object2(n: 0))
#State var isShow = false
var body: some View {
NavigationView {
VStack {
Text("\(object1.object2.n)")
//NavigationLink(destination: View2(object2: object1.object2)) {
// Text("Go to view 2")
//}
View2(object2: object1.object2)
Button {
isShow = true
} label: {
Text("Show")
}.alert(isPresented: $isShow, content: {
Alert(title: Text("\(object1.object2.n)"))
})
}
}
}
}
struct View2: View {
#ObservedObject var object2: Object2
var body: some View {
Button {
object2.n += 1
} label: {
Text("\(object2.n)")
}
}
}
class Object1: ObservableObject {
#Published var object2: Object2
init(ob: Object2) {
self.object2 = ob
}
}
class Object2: ObservableObject {
#Published var n: Int = 0
init(n: Int) {
self.n = n
}
}
Here is possible solution:
var body: some View {
NavigationView {
VStack {
Text("\(object1.object2.n)")
.onChange(of: object1.object2.n) { _ in
object1.objectWillChange.send()
}
// .. other code
Alternate is to move every object2 dependent part into separated subview observed object2 explicitly.

SwiftUI List Rows Not Refreshed After Updating Binding In Other Screen

I have a simple watchOS SwiftUI Application. The application has three screens. The first screen consists of a List of items. When you press that item, it will redirect to another screen & when you tap a button there it will open up a .sheet View which allows you to edit the item in the list.
The first view looks like this:
class Object: NSObject {
var title: String
init(title: String) {
self.title = title
}
}
struct Row: View {
#Binding var object: Object
var body: some View {
Text(self.object.title)
}
}
struct ContentView: View {
#State private var objects = [Object]()
var body: some View {
NavigationView {
List {
ForEach(objects.indices, id: \.self) { idx in
NavigationLink(destination: SecondView(object: self.$objects[idx])) {
Row(object: self.$objects[idx])
}
}
}
}
.onAppear {
self.objects = [
Object(title: "Test 1"),
Object(title: "Test 2")
]
}
}
}
These are the second & third views:
struct SecondView: View {
#Binding var object: Object
#State private var showPicker: Bool = false
var body: some View {
VStack {
Text(object.title)
Button(action: {
self.showPicker.toggle()
}) {
Text("Press Here")
}
}
.sheet(isPresented: $showPicker) {
ThirdView(object: self.$object)
}
}
}
struct ThirdView: View {
#Binding var object: Object
var body: some View {
VStack {
Text(object.title)
Button(action: {
self.update()
}, label: {
Text("Tap here")
})
}
}
func update() {
let newObj = self.object
newObj.title = "Hello, World!"
self.object = newObj
}
}
I'd expect, whenever I tap the button in the third view, the Binding (and thus the State) get's updated with "Hello, World". However, that is not the case, although not immediately.
What I currently see happening is that when I tap the button in the third view, the Text in that view does not get updated. When I dismiss the third view and go back to the second view, I do see "Hello, World". But when I go back to the list, the row still has the old value.
One other thing I noticed is that, when I fill the array of objects directly, like so:
#State private var objects = [Object(title: "Test 1"), Object(title: "Test 2")]
and remove the filling of the array in .onAppear, this work totally how I'd expect it to (everything updates immediately to "Hello, World".
Does anyone one know what I'm doing wrong here or did I might hit a bug?
Thanks!
Complex objects need to be classes conforming to #ObservableObject.
Observed ivars need to be published.
class Object: ObservableObject {
#Published var title: String
[...]
}
Observing views would use them as #ObservedObject
struct Row: View {
#ObservedObject var object: Object
[...]
}
You might have to create an object wrapper for your lists
class ObjectList: ObservableObject {
#Published var objects: [Object]
[...]
}

Resources