Composing SwiftUI Views and Delegating the Delete Operation - ios

I am creating a small sample app, where I display a list of items. I have composed the app with several different views as shown below:
struct ItemCellView: View {
let item: Item
let onDelete: (Item) -> Void
var body: some View {
HStack {
Text(item.name)
Spacer()
Image(systemName: "trash")
.onTapGesture {
onDelete(item)
}
}
}
}
struct ItemListView: View {
let items: [Item]
#EnvironmentObject private var model: Model
var body: some View {
List(items, id: \.id) { item in
ItemCellView(item: item, onDelete: {
model.deleteItem(id: $0.id)
})
}
}
}
struct ContentView: View {
#EnvironmentObject private var model: Model
var body: some View {
ItemListView(items: model.items)
}
}
The model is defined below:
struct Item {
let id = UUID()
let name: String
}
class Model: ObservableObject {
#Published var items: [Item] = [Item(name: "A"), Item(name: "B"), Item(name: "C"), Item(name: "D")]
func deleteItem(id: UUID) {
items = items.filter { $0.id != id }
}
}
My question is that how can I make sure that my views ItemListView and ItemCellView are reusable. Currently, I am deleting the item in ItemListView which makes it less reusable. I can add another closure in ItemListView and then transfer the control to ContentView but that seems like a lot of work.
How would you accomplish the deletion task which this view hierarchy?

Related

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

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

SwiftUI MVVM Binding List Item

I am trying to create a list view and a detailed screen like this:
struct MyListView: View {
#StateObject var viewModel: MyListViewModel = MyListViewModel()
LazyVStack {
// https://www.swiftbysundell.com/articles/bindable-swiftui-list-elements/
ForEach(viewModel.items.identifiableIndicies) { index in
MyListItemView($viewModel.items[index])
}
}
}
class MyListViewModel: ObservableObject {
#Published var items: [Item] = []
...
}
struct MyListItemView: View {
#Binding var item: Item
var body: some View {
NavigationLink(destination: MyListItemDetailView(item: $item), label: {
...
})
}
}
struct MyListItemDetailView: View {
#Binding var item: Item
#StateObject var viewModel: MyListViewItemDetailModel
init(item: Binding<Item>) {
viewModel = MyListViewItemDetailModel(item: item)
}
var body: some View {
...
}
}
class MyListViewItemDetailModel: ObservableObject {
var item: Binding<Item>
...
}
I am not sure what's wrong with it, but I found that item variables are not synced with each other, even between MyListItemDetailView and MyListItemDetailViewModel.
Is there anyone who can provide the best practice and let me know what's wrong in my implmentation?
I think you should think about a minor restructure of your code, and use only 1
#StateObject/ObservableObject. Here is a cut down version of your code using
only one StateObject source of truth:
Note: AFAIK Binding is meant to be used in View struct not "ordinary" classes.
PS: what is identifiableIndicies?
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct Item: Identifiable {
let id = UUID().uuidString
var name: String = ""
}
struct MyListView: View {
#StateObject var viewModel: MyListViewModel = MyListViewModel()
var body: some View {
LazyVStack {
ForEach(viewModel.items.indices) { index in
MyListItemView(item: $viewModel.items[index])
}
}
}
}
class MyListViewModel: ObservableObject {
#Published var items: [Item] = [Item(name: "one"), Item(name: "two")]
}
struct MyListItemView: View {
#Binding var item: Item
var body: some View {
NavigationLink(destination: MyListItemDetailView(item: $item)){
Text(item.name)
}
}
}
class MyAPIModel {
func fetchItemData(completion: #escaping (Item) -> Void) {
// do your fetching here
completion(Item(name: "new data from api"))
}
}
struct MyListItemDetailView: View {
#Binding var item: Item
let myApiModel = MyAPIModel()
var body: some View {
VStack {
Button(action: fetchNewData) {
Text("Fetch new data")
}
TextField("edit item", text: $item.name).border(.red).padding()
}
}
func fetchNewData() {
myApiModel.fetchItemData() { itemData in
item = itemData
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
MyListView()
}.navigationViewStyle(.stack)
}
}
EDIT1:
to setup an API to call some functions, you could use something like this:
class MyAPI {
func fetchItemData(completion: #escaping (Item) -> Void) {
// do your stuff
}
}
and use it to obtain whatever data you require from the server.
EDIT2: added some code to demonstrate the use of an API.

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.

Cannot remove element from foreach if list row contains a textField in SwiftUI

I have read several solutions to this issue which I believe I am doing correctly based on suggestions but this simple code still crashes.
import SwiftUI
struct AnItem : Identifiable {
let id = UUID()
var text: String
}
struct ContentView: View {
#State private var items : [AnItem] = [AnItem(text: "A"), AnItem(text: "B"), AnItem(text: "C")]
var body: some View {
List () {
ForEach (0 ..< items.count) {
index in
TextField("test", text: $items[index].text)
}.onDelete(perform: deleteItem)
}
}
private func deleteItem(at indexSet: IndexSet) {
self.items.remove(atOffsets: indexSet)
}
}
Anybody have an idea for a workaround, or maybe I'm just doing this wrong. The crash says
Fatal error: Index out of range: file Swift/ContiguousArrayBuffer.swift, line 444
As others have stated, just a Text widget works fine. This appears to be induced by using TextField.
Here is a possible solution. Using id:\.id to identity the items and then extract the TextField to another view
struct AnItem : Identifiable {
let id = UUID()
var text: String
}
struct ContentView: View {
#State private var items : [AnItem] = [AnItem(text: "A"), AnItem(text: "B"), AnItem(text: "C")]
var body: some View {
List {
ForEach(items, id: \.id) { item in
CustomTextField(item: item)
}.onDelete(perform: deleteItem)
}
}
private func deleteItem(at indexSet: IndexSet) {
items.remove(atOffsets: indexSet)
}
}
struct CustomTextField : View {
#State var item : AnItem
var body : some View {
TextField("Test", text: $item.text)
}
}
How about doing it like this:
First lets make AnItem conform to Hashable protocol.
struct AnItem : Identifiable, Hashable {
let id = UUID()
var text: String
}
Now let's change your Foreach and List a little bit:
List {
ForEach (items, id: \.self) { item in
Text("\(item.text)")
}.onDelete(perform: deleteItem)
}
Voila! everything is working perfectly now!

List reload animation glitches

So I have a list that changes when user fill in search keyword, and when there is no result, all the cells collapse and somehow they would fly over to the first section which looks ugly. Is there an error in my code or is this an expected SwiftUI behavior? Thanks.
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel = ViewModel(photoLibraryService: PhotoLibraryService.shared)
var body: some View {
NavigationView {
List {
Section {
TextField("Enter Album Name", text: $viewModel.searchText)
}
Section {
if viewModel.libraryAlbums.count > 0 {
ForEach(viewModel.libraryAlbums) { libraryAlbum -> Text in
let title = libraryAlbum.assetCollection.localizedTitle ?? "Album"
return Text(title)
}
}
}
}.listStyle(GroupedListStyle())
.navigationBarTitle(
Text("Albums")
).navigationBarItems(trailing: Button("Add Album", action: {
PhotoLibraryService.shared.createAlbum(withTitle: "New Album \(Int.random(in: 1...100))")
}))
}.animation(.default)
}
}
1) you have to use some debouncing to reduce the needs to refresh the list, while typing in the search field
2) disable animation of rows
The second is the hardest part. the trick is to force recreate some View by setting its id.
Here is code of simple app (to be able to test this ideas)
import SwiftUI
import Combine
class Model: ObservableObject {
#Published var text: String = ""
#Published var debouncedText: String = ""
#Published var data = ["art", "audience", "association", "attitude", "ambition", "assistance", "awareness", "apartment", "artisan", "airport", "atmosphere", "actor", "army", "attention", "agreement", "application", "agency", "article", "affair", "apple", "argument", "analysis", "appearance", "assumption", "arrival", "assistant", "addition", "accident", "appointment", "advice", "ability", "alcohol", "anxiety", "ad", "activity"].map(DataRow.init)
var filtered: [DataRow] {
data.filter { (row) -> Bool in
row.txt.lowercased().hasPrefix(debouncedText.lowercased())
}
}
var id: UUID {
UUID()
}
private var store = Set<AnyCancellable>()
init(delay: Double) {
$text
.debounce(for: .seconds(delay), scheduler: RunLoop.main)
.sink { [weak self] (s) in
self?.debouncedText = s
}.store(in: &store)
}
}
struct DataRow: Identifiable {
let id = UUID()
let txt: String
init(_ txt: String) {
self.txt = txt
}
}
struct ContentView: View {
#ObservedObject var search = Model(delay: 0.5)
var body: some View {
NavigationView {
VStack(alignment: .leading) {
TextField("filter", text: $search.text)
.padding(.vertical)
.padding(.horizontal)
List(search.filtered) { (e) in
Text(e.txt)
}.id(search.id)
}.navigationBarTitle("Navigation")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
and i am happy with the result

Resources