NEW: Start my new Ultimate Portfolio App course with a free Hacking with Swift+ trial! >>

Learning SwiftUI with a complicated data model

Forums > SwiftUI

I spent the better part of last year learning Swift by building the backend code for my app, but now I'm at the point where I need to start designing the interface, and I figured I would try to learn SwiftUI at the same time. However, I've gotten to the point in my learning journey where the "out of the box" solutions most examples in tutorials demonstrate aren't quite applicable to what I'm trying to do, but at the same time, I'm not yet expert enough to be able to understand what the more complicated examples I can examine in various open-source projects are doing.

My backend builds a database of Codable classes for curating an (audiobook) library. (It should be noted that I started this project long before I knew about the BookWorm project here at HWS, and my data model doesn't use Core Data or any other external database management.)

public struct LibraryDatabase: Codable {
    public var books: [Audiobook]
    public var series: [Series]
    public var authors: [Author]
    // etc
}

I'm wrapping the LibraryDatabase up as an ObservableObject so that I can pass it around as an @EnvironmentObject.

My individual types are classes, so I know I need to wrap them using @StateObject and @ObservedObject. But I'm not sure how to handle arrays of those classes. (For example, presenting them in list views):

struct SortedListView: View {
    @EnvironmentObject var library: LibraryObject
    @State var sorting: Sorting

    var body: some View {
        Group {
            switch sorting {
                case .title:
                    let books = library.database.books
                    BookListView(books: books)
                // etc
}

struct BookListView: View {
    @StateObject var books: [Audiobook]
    // Property type '[Audiobook]' does not match that of the 'wrappedValue' property of its wrapper type 'StateObject'
    // Same error if I try to use @ObservedObject

    var body: some View {
        List {
            if books.count <= 20 {
                ForEach(books.sorted(by: {$0.title < $1.title})) { book in
                    BookRowView(book: book, index: nil)
                }
            } 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
                            BookRowView(book: book, index: nil)
                        }
                    }
                }
            }
        }
        .listStyle(InsetListStyle())
    }
}

I don't actually get any errors when [Audiobook] is wrapped as @State but I'm concerned doing it that way will cause me problems down the line when I get to the point of actually adding and removing objects from the list I'm displaying.

   

I'm not sure if what you're doing is wrong, but the way I'd do it would be to have some object encapsulate the array. Something like:

class BookList: ObservableObject {
  @Published var books = [Audiobook]()

    // Example method to mutate the list of books. In this case,
    // we're just going to replace the contents entirely.
    func replaceBooks(with list: [Audiobook]) {
      // Sending this through the dispatch queue in case we are not
      // already on the main thread. If you aren't doing stuff in the
      // background, you don't need to do this.
      DispatchQueue.main.async { [weak self] in
        guard let myself = self else { return }
        myself.objectWillChange.send()
        books.removeAll()
        books.append(contentsOf: list)
    }
  }

  struct BookListView: View {
    @ObservedObject var bookList: BookList

    ...

This way, any code that updates the list of books (filtering it, adding new items, removing items) would call methods on the BookList, which would publish notifications to any observer(s) and cause any observing views to redraw.

   

Okay. Thank you. I had considered using that approach for my individual classes, instead of having the classes themselves conform to ObservableObject (which creates issueswith Codable conformance plus the complications of using accessing computed properties) but I hadn't considered doing it for the entire array.

Question:

Right now I've got my database as a whole wrapped like this to use as an @EnvironmentObject:

class LibraryObject: ObservableObject {
    @Published var database: LibraryDatabase

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

Would it be better to instead make the arrays within the database @Published instead,

class LibraryObject: ObservableObject {
    @Published var books: [Audiobook]
    @Published var authors: [Author]

    init(_ database: LibraryDatabase) {
        self.books = database.books
        self.authors = database.authors
        // etc
    }
}

And then add the method you suggested for replacing the arrays?

   

Good question, but unfortunately we're running right up against the limits of my experience. My gut tells me that this is one of those decisions that really comes down to how hard it will be to maintain/modify in the future.

The one insight I might offer is that so far, I've had better luck in Swift with shallow data structures than with really deep ones.

   

Okay, thank you.

With my far from solid-grasp of how ObservableObject is supposed to function, I'm really struggling with my data model, to the point where part of me is tempted to try to redo the whole thing as a CoreData database instead and see if I can't figure it out better that way, using the Bookworm tutorial as a guide.

Right now, as shown above, I have my JSON database wrapped as an ObservableObject, with the database itself @Published and declared as a @StateObject at the top level, and used as an @EnvironmentObject in all the views.

Part of me wants to think that would mean that whenever a book (or author, or series) is added or removed from one of those arrays in the database, or a particular record is edited (new title, new authors, whatever) then, with the way my data model is set up, that change would happen to the database, and therefore any view watching the published database as an @EnvironmentObject would be invalidated and redrawn.

And the other part of me thinks there's no way it can be that easy and that I probably need, like you recommended, a @Published variable for every array of objects in the database, with methods (or get/set accessors) for those arrays, and an ObservableObject wrapper for each of the individual classes and methods for altering all the properties of those classes and...

So it would look more like this:

class LibraryObject: ObservableObject {
    @Published var database: LibraryDatabase

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

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

    var books: BookListObject {
        get { return BookListObject(database) }
        set {
            DispatchQueue.main.async { [weak self] in
                guard let myself = self else { return }
                myself.objectWillChange.send()
                for bookObject in newValue.books {
                    if myself.database.books.contains(bookObject.book) {
                        myself.database.books.append(bookObject.book)
                    }
                }
            }
        }
    }
}

class BookListObject: ObservableObject {
    var database: LibraryDatabase
    @Published var books = [AudiobookObject]()

    init(_ database: LibraryDatabase) {
        self.database = database
        for book in database.books {
            self.books.append(AudiobookObject(book))
        }
    }
}

class AudiobookObject: ObservableObject {
    @Published var book: Audiobook

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

    var title: String {
        get { book.title }
        set {
            self.objectWillChange.send()
            book.title = newValue
        }
    }
    // etc
}

Yeah. I have no idea if I'm oversimplifying or overcomplicating things.

   

Hacking with Swift is sponsored by RevenueCat

SPONSORED Building and maintaining in-app subscription infrastructure is hard. Luckily there's a better way. With RevenueCat, you can implement subscriptions for your app in hours, not months, so you can get back to building your app.

Try it for free

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

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.