BLACK FRIDAY: Save 50% on all my Swift books and bundles! >>

SOLVED: Change of property in detail view not updating sidebar list view

Forums > SwiftUI

This is a follow-up to my follow-up, with many thank yous to @jakcharvat for all their help so far. It might be a little long, apologies in advance, but I may need to get into a bit of the nitty-gritty with my views and data model.

I've got a pretty straight-forward side-by-side list and detail view set up. The list view presents options for sorting and filtering a library of books, series, authors, etc. (All credit for David Weber for creating a series convoluted enough that I decided I needed to learn Swift in order to create a library management app capable of dealing not just with series, but series within series. No copyright infringement intended with the following screenshots.)

As you can see here, I've got two "refinement" options for the list on the side:

"Sort" is simply the list of books, sectioned in various ways (by author, by series, and so forth.) That button to the upper right of the cover image in the detail view is a cycling button that allows the user to set the book's status to four different states: unread, in progress, completed, and favorite.

Last week, when I was working on that portion of things, I had trouble getting the list to update when that button was used to change the book's status. If you were browsing books with a status of "To Be Read" and changed the selected book's status to "Completed", it wouldn't be removed from the "To Be Read" list until the list view was changed by chosing another sorting option and then returning to "To Be Read".

To solve that, I added an @ObservedObject property for the book record itself:

struct LibraryView: View {
    @State private var sorting: Sorting = .title
    @State private var filtering: Filtering = .none

    @ObservedObject var audiobook: AudiobookObject
    @EnvironmentObject var library: LibraryObject

    var body: some View {
        return Group {
            if filtering == .none {
                switch sorting {
                    default: sorting.view(library.database)
                }
            } else {
                switch filtering {
                    default: FilteredLibraryView(filtering: $filtering,
                                                 audiobook: audiobook)
                }
            }
        }
        // snip toobar
    }
}

enum Sorting {
    // snip cases

    func view(_ database: LibraryDatabase) -> some View {
        return Group {
            switch self {
                case .title, .favorites, .toBeRead, .completed: Sorting.titleListView(self, database)
                case .author: Sorting.authorListView(database)
                case .series: Sorting.seriesListView(database)
                case .collection: Sorting.collectionListView(database)
                case .genre: Sorting.genreListView(database)
            }
        }
    }

    private static func titleListView(_ sorting: Sorting, _ database: LibraryDatabase) -> some View {
        let books: [Audiobook]
        if sorting == .favorites {
            books = database.books.favoriteBooks.sorted(by: {$0.title < $1.title})
        } else if sorting == .completed {
            books = database.books.completedBooks.sorted(by: {$0.title < $1.title})
        } else if sorting == .toBeRead {
            books = database.books.toBeReadBooks.sorted(by: {$0.title < $1.title})
        } else {
            books = database.books.sorted(by: {$0.title < $1.title})
        }

        var firstCharacters = [Character]()
        for book in books {
            if let character = book.title.first, !firstCharacters.contains(character) {
                firstCharacters.append(character)
            }
        }

        return List {
            if books.count <= 20 {
                ForEach(books.sorted(by: {$0.title < $1.title})) { book in
                    let bookObject = AudiobookObject(book)
                    BookRowView(audiobook: bookObject)
                }
            } else {
                ForEach(firstCharacters) { character in
                    Section(header: Text(String(character))) {
                        ForEach(books.filter({$0.title.first == character }).sorted(by: {$0.title < $1.title})) { book in
                            let bookObject = AudiobookObject(book)
                            BookRowView(audiobook: bookObject)
                        }
                    }
                }
            }
        }
        .navigationTitle("Books")
        .listStyle(InsetListStyle())
    }

class AudiobookObject: ObservableObject {
    @Published var book: Audiobook

    init(_ book: Audiobook) {
        self.book = book
    }

    var bookStatus: BookStatus {
        get { book.status }
        set {
            objectWillChange.send()
            book.status = newValue
        }
    }

    var rating: Double {
        get { book.rating }
        set {
            objectWillChange.send()
            book.rating = newValue
        }
    }
}

That worked, so as you can see, now when the bookStatus property is changed by clicking that button in the detail view, the list view updates appropriately:

Now I'm trying to do the same with my filtering views, particularly with my rating view without much success.

The filtering list view is a bit more complicated, because of the user-interactive elements. I don't yet have any code in place to handle situations where the user edits book metadata (like title or language), so I can't say what would happen to the filtering view if the user made those edits. That will come later. For now, the only two parts of the book record that are editable from the detail view are bookStatus and rating.

And as you can see, when changing the rating, the list view does not update:

If you change the list view (by selecting a different filtering criteria), you can see the book record has had its "rating" property changed as it should, and that it shows up when filtering by that rating:

And when you return to the previous filtering criteria, the book is no longer in the list:

But that change isn't happening in real-time in the current view, the way it does with bookStatus in the sorting view, and I've rewritten this view from the ground up five times and still haven't puzzled it out.

struct FilterByRatingView: View {
    @State private var rating: Double = 0.0
    @ObservedObject var audiobook: AudiobookObject
    @State var filteredBooks: [AudiobookObject] = []
    @EnvironmentObject var library: LibraryObject

    func applyFilter() {
        let books = library.database.books.booksByRating(rating)
        var list = [AudiobookObject]()
        for book in books {
            list.append(AudiobookObject(book))
        }
        filteredBooks = list
    }

    let ratings = [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0]

    var body: some View {
        VStack {
            Picker(selection: $rating,
                   label: Text("Search by rating"),
                   content: {
                    ForEach(ratings, id: \.self) { rating in
                        viewForRating(rating)
                    }
                   })
                .onChange(of: rating, perform: { rating in
                    applyFilter()
                    audiobook.rating = rating
                })
            List(filteredBooks, id: \.book.id) { book in
                BookRowView(audiobook: book)
            }
            .onAppear(perform: applyFilter)
        }
        .navigationBarTitle("Rating")
        .listStyle(SidebarListStyle())
    }

Unlike with bookStatus when I was having the same issue, having the book object present as an @ObservedObject doesn't work.

The results are the same regardless of whether or not I include the audiobook.rating = rating line in the .onChange block, which is no surprise, because that part of things is already handled in my RatingView:

struct RatingView: View {
    @Binding var rating: Double
    @ObservedObject var audiobook: AudiobookObject

    // much snippage
                .gesture(
                    DragGesture(minimumDistance: 0, coordinateSpace: .local)
                        .onChanged { value in
                            rating = rating(at: value.location)
                            audiobook.rating = rating // <- here
                        }
                )
        }
    }

I've also tried creating an ObservableObject class for an array of books with a specific method for filtering by rating:

class AudiobookListObject: ObservableObject {
    @Published var books: [Audiobook]

    init(_ books: [Audiobook]) {
        self.books = books
    }

func filterByRating(_ rating: Double) {
        DispatchQueue.main.async { [weak self] in
            guard let myself = self else { return }
            myself.objectWillChange.send()
            myself.books = myself.books.booksByRating(rating)
        }
    }
}

And used @ObservedObject var filteredbooks: AudiobookListObject in place of @State var filteredBooks: [Audiobook] (or @State var filteredBooks: [AudiobookObject]) and then edited applyFilter() as follows:

struct FilterByRatingView: View {
    @State var filteredBooks: AudiobookListObject

    func applyFilter() {
        filteredBooks.filterByRating(rating)
    }

        var body: some View {
            //
            List(filteredBooks.books, id: \.book.id) { book in
                let object = AudiobookObject(book)
                BookRowView(audiobook: object)
            }
            .onAppear(perform: applyFilter)
        }
        .navigationBarTitle("Rating")
        .listStyle(SidebarListStyle())
    }

But that made things even worse, because the list in the listview wouldn't populate at all, or the very first listview displayed would be populated, but if you changed to another filtering criteria, the list view would be empty (including the first list view that was displayed, upon returning to it.)

I've pretty much exhausted my bag of tricks at this point and have no idea what to try next.

Edit: Hmm. Screenshots didn't come through. Here is a link if interested.

3      

Hello once again, and thanks for the shoutout 😊

This time I haven't used a lot of your code, but created a proof of concept that should have the behaviour you're after. Working with Bindings inside List (and for that matter also ForEach) views is a pain in SwiftUI, and every time I have face it (and it's quite often) I seem to come up with a different solution. At the bottom of the post is a full example that you can paste into Xcode and play around with, but let me start off by walking you through the important bits one by one.

Data Types

enum Rating: CaseIterable, Hashable {
    case notFiltering
    case rating(Int)

    var description: String {
        switch self {
        case .notFiltering: return "---"
        case .rating(let rating): return "\(rating)"
        }
    }

    static let allCases: [Self] = [.notFiltering] + (0...5).map(Rating.rating)
}

The enum I use for my rating selection. You're using plain doubles and that should work as well, I wanted to incorporate a "not filtering" state into my rating picker as well so that's why I use this. The allCases property just has notFiltering and then rating(0) through rating(5).


struct Book {
    let title: String
    var rating: Int

    static let empty = Book(title: "Missing Book", rating: 0)
}

My book. Very simple, no ObservableObjects, no change observers, nothing. Just the plain properties I need.


extension Array where Element == Book {
    static let samples: [Book] = (0...5).flatMap { r in
        (1...5).map {
            Book(title: "Originally \(r) star rated book \($0)", rating: r)
        }
    }
}

Just a convenience property with sample books. Generates 5 books of each rating (0...5) and names them by their initial rating and idx within that rating.


App Data Object

class ContentData: ObservableObject {
    @Published var allbooks: [Book] = .samples
    @Published var rating: Rating = .notFiltering

    var filteredBookIndices: [Int] {
        switch rating {
        case .notFiltering: return Array(0..<allbooks.count)
        case .rating(let rating):
            return allbooks
                .enumerated()
                .filter { $0.element.rating == rating }
                .map(\.offset)
        }
    }
}

This is an important bit. This has all the data for the app. It keeps track of all the books, even when filtering, and the currently selected rating filter. The filteredBookIndices property gives the indices within the allbooks array of the books matching the current filter.


Views

struct ContentView: View {
    @StateObject private var data = ContentData()

    var body: some View {
        NavigationView {
            VStack {
                Picker("Filter Rating", selection: $data.rating) {
                    ForEach(Rating.allCases, id: \.description) { rating in
                        Text(rating.description).tag(rating)
                    }
                }
                .pickerStyle(SegmentedPickerStyle())
                List(data.filteredBookIndices, id: \.self) { idx in
                    NavigationLink(data.allbooks[idx].title, destination: DetailView(bookIdx: idx))
                }
                .listStyle(SidebarListStyle())

                // Do NOT put the environmentObject modifier here
            }

            // DetailView gets attached here
        }
        .environmentObject(data)
    }
}

struct DetailView: View {
    let bookIdx: Int
    @EnvironmentObject var data: ContentData

    var body: some View {
        Stepper("Rating: \(data.allbooks[bookIdx].rating)", value: $data.allbooks[bookIdx].rating, in: 0...5)
            .frame(width: 200)
            .navigationTitle(data.allbooks[bookIdx].title)
    }
}

Those are the two views. Few things to note here. Standard picker, just segmented because I think it fits better in the sidebar. The List is where the interesting stuff happens though. Notice that I'm not iterating over the data's allbooks array here, in fact I'm not touching the books at all (except to get their title for the NavigationLink), but rather I'm getting the indices of the books I want to show in the sidebar (determined by the ContentData and passing those indices directly on to the DetailView. I'm using the environment to pass the ContentData object to the DetailView here, you could do it using an ObservableObject in an initialiser as well, but if you're using the environment make sure you have your environmentObject modifier surrounding the top level NavigationView. The reason is that when you open a book detail, it gets added in place of the "DetailView gets attached here" comment. When you change its rating it gets removed from the sidebar, and so if you put the environmentObject modifier in place of the "Do NOT put the environmentObject modifier here" comment or anywhere further up, DetailView would lose access to it as soon as you changed the book's rating.

The rest is pretty simple: in the DetailView you're modifying the book directly in the single source of truth, the allbooks array. You never make any duplicate of the array. The DetailView accesses the original book object through its index and modifies it directly, enabling reloads of the sidebar whilst the detailview still shows what you want.


Full Code

struct ContentView: View {
    @StateObject private var data = ContentData()

    var body: some View {
        NavigationView {
            VStack {
                Picker("Filter Rating", selection: $data.rating) {
                    ForEach(Rating.allCases, id: \.description) { rating in
                        Text(rating.description).tag(rating)
                    }
                }
                .pickerStyle(SegmentedPickerStyle())
                List(data.filteredBookIndices, id: \.self) { idx in
                    NavigationLink(data.allbooks[idx].title, destination: DetailView(bookIdx: idx))
                }
                .listStyle(SidebarListStyle())
            }
        }
        .environmentObject(data)
    }
}

struct DetailView: View {
    let bookIdx: Int
    @EnvironmentObject var data: ContentData

    var body: some View {
        Stepper("Rating: \(data.allbooks[bookIdx].rating)", value: $data.allbooks[bookIdx].rating, in: 0...5)
            .frame(width: 200)
            .navigationTitle(data.allbooks[bookIdx].title)
    }
}

class ContentData: ObservableObject {
    @Published var allbooks: [Book] = .samples
    @Published var rating: Rating = .notFiltering

    var filteredBookIndices: [Int] {
        switch rating {
        case .notFiltering: return Array(0..<allbooks.count)
        case .rating(let rating):
            return allbooks
                .enumerated()
                .filter { $0.element.rating == rating }
                .map(\.offset)
        }
    }
}

enum Rating: CaseIterable, Hashable {
    case notFiltering
    case rating(Int)

    var description: String {
        switch self {
        case .notFiltering: return "---"
        case .rating(let rating): return "\(rating)"
        }
    }

    static let allCases: [Self] = [.notFiltering] + (0...5).map(Rating.rating)
}

struct Book {
    let title: String
    var rating: Int
}

extension Array where Element == Book {
    static let samples: [Book] = (0...5).flatMap { r in
        (1...5).map {
            Book(title: "Originally \(r) star rated book \($0)", rating: r)
        }
    }
}

5      

Thank you again. I will work with it and see what I can do as far as modifying it to fit with my data model and the fact that filtering can also be applied to my library's Series and Author and other properties.

I also wanted an "unrated" options in the ratings datasource, but hadn't thought to make it an enum with associated values to accomplish that.

I've suspected since the beginning that my approach to having the whole database floating around as an @EnvironmentObject was probably incorrect (I suspected it would end up being a huge memory hog), but couldn't work out a better way, and everything I tried didn't work the way I needed it to (possibly because of the issue you mentioned about bindings inside a List/ForEach, because so much of my app to date has been focused on presenting the library in various list views). But your approach is quite elegant and certainly a much better option.

Thank you. I will let you know if I have any questions.

4      

You're welcome :) I wrote the sample with your code in mind, so hopefully it should work without issues.

That enum idea for ratings struck me when I wanted to implement it by also allowing nil or -1 in the picker, and it just seemed like a much better option to me.

As for the data management, that's still a big question regarding SwiftUI. I myself battle with it in every project, where do I put this stuff, how can I bind this together etc. I always tend to fall to just attaching it to my environment at the topmost view, usually right inside my WindowGroup (or Scene/AppDelegate) and leave it like that because it makes everything so much simpler. But if you have some data that only matters to one view, then you should definitely only introduce it there.

Do let me know if it doesn't work, or if I can help you with anything else, I'm happy to do so :)

3      

I've wrote and discard two extremely code-heavy replies tonight, because in the greater context of trying to present a view where there are multiple filtering and sorting options, I haven't really been able to implement an approach where I can just refer to a particular book by its index in the whole array of books in the database. Because sometimes the array I'm dealing with isn't the whole array of books in the database. Sometimes it's the array of books written by an author, or the books in a series (which is an array of tuples, [(seriesIndex: Double, book: Audiobook)]) or... you get the picture.

But every time I try to get into what issue I'm having, I end up putting in so much code, trying to explain what I'm doing, and what I've tried, that I figure the message is unreadable and I delete it and go back to trying to muddle through on my own, and getting nowhere doing it.

So. Let's start at the very top and work our way down, view by view.

This is the way I've had it set up since before your last reply to me, @jakcharvat, but it's reassuring to know that this approach isn't entirely wrong.

At the top, I have this:

@main
struct _: App {
    @StateObject var library: LibraryObject = LibraryObject.default

    private var audiobook: AudiobookObject {
        if library.database.books.isEmpty {
            return AudiobookObject(Audiobook(UUID())) // empty placeholder
        } else {
            return AudiobookObject(library.database.books[0])
        }
    }

    var body: some Scene {
        WindowGroup {
            return ContentView(audiobook: audiobook)
                .environmentObject(library)
        }
    }
}

class LibraryObject: ObservableObject {
    @Published var database: LibraryDatabase

    init(_ database: LibraryDatabase) {
        self.database = database
    }

    static let `default`: LibraryObject = LibraryObject(LibraryCache.default.database)
}

So, focusing on just this part for now, to make sure I understand the most fundamental concepts, let me ask this:

The documentation says that an @EnvironmentObject will invalidate the views in which it's declared when it changes.

An Audiobook instance is a record in the LibraryDatabase, which is the @Published property of the LibraryObject class.

Audiobook has a property, rating: Double.

If I make a change to the rating property of an Audiobook record, that change is made to LibraryDatabase, correct? So wouldn't the simple fact of changing the rating of a book (in a detail view, which uses that @EnvironmentObject) invalidate/redraw all the views that use that @EnvironmentObject?

If the answer to that question is supposed to be "yes", then I'm clearly doing something wrong from the get-go, because that isn't happening.

(More questions to come once I know whether or not my understanding of that portion of things is accurate.)

3      

Ok, your AudiobookObject is an ObservableObject, that makes sense. The question is if your Audiobook is a struct or a class. If it's a struct then what you said should be working, if it's a class then it won't be, and we'll have to talk about getting it Observable and then combining its publisher with the one in AudiobookObject.

3      

Audiobook is a class because I needed the reference semantics to get around creating loops when retrieving data from my database. But trying to make Audiobook an ObservableObject itself messed with its Codable conformance, which is why I wrapped it in AudiobookObject instead of making it an ObservableObject itself.

With some help from my mentor, I've managed to solve this problem with methods querying everything from my environmentObject (LibraryObject):

class LibraryObject: ObservableObject {
    @Published var database: LibraryDatabase

    init(_ database: LibraryDatabase) {
        self.database = database
    }

    static let `default`: LibraryObject = LibraryObject(LibraryCache.default.database)

    private var tracked: [AnyCancellable] = []
    private func track<T>(_ object: T) where T: ObservableObject {
      tracked.append(object.objectWillChange.sink(receiveValue: { [weak self] _ in
        self?.objectWillChange.send()
      }))
    }

    func books(with rating: Double) -> [AudiobookObject] {
        var objects = [AudiobookObject]()
        for book in database.books.booksByRating(rating) {
            let object = AudiobookObject(book)
            track(object)
            objects.append(object)
        }
        return objects
    }

So the result looks like this and behaves the way it's supposed to:

enum Filtering: String, CaseIterable {
    // ...
    case rating = "Rating"

    func view(library: LibraryObject,
              filterString: Binding<String>,
              rating: Binding<Double>,
              language: Binding<Language>) -> some View {
        return Group {
            switch self {
                // ...
                case .rating: filterByRatingsView(library: library, rating: rating)
            }
        }
    }

    private func filterByRatingsView(library: LibraryObject, rating: Binding<Double>) -> some View {
        let ratings = [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0]
        var filteredBooks = library.books(with: rating.wrappedValue)
        return VStack {
            Picker(selection: rating,
                   label: Text("Search by rating"),
                   content: {
                    ForEach(ratings, id: \.self) { rating in
                        viewForRating(rating)
                    }
                   })
                .onChange(of: rating.wrappedValue, perform: { _ in
                    filteredBooks = library.books(with: rating.wrappedValue)
                })
            List(filteredBooks) { book in
                BookRowView(audiobook: book)
            }
            .onAppear(perform: {
                filteredBooks = library.books(with: rating.wrappedValue)
            })
        }
        .navigationBarTitle("Rating")
        .listStyle(InsetListStyle())
    }

struct LibraryView: View {
    @State private var sorting: Sorting = .titleAscending
    @State private var filtering: Filtering = .none

    @State private var filterString = ""
    @State private var rating: Double = 0.0
    @State private var language: Language = .unspecified

    @ObservedObject var audiobook: AudiobookObject
    @EnvironmentObject var library: LibraryObject

    var body: some View {
        return Group {
            switch filtering {
                case .none: sorting.view(library)
                default: filtering.view(library: library,
                                        filterString: $filterString,
                                        rating: $rating,
                                        language: $language)
            }
        }
    }
}

3      

Save 50% in my WWDC sale.

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.