8

I have the following structs generated from protobufs so they're not modifiable directly:

// This file can not be modified
// It's auto-generated from protobufs

struct Shelf {
  var id: Int
  var title: String

  var books: [Books]
}

struct Book {
  var id: Int
  var title: String

  var pages: [Pages]
  var shelfId: Int
}

struct Page {
  var id: Int
  var content: String

  var bookId: Int
}

What's the proper way of passing the state between 3 nested SwiftUI views when creating or modifying a new Shelf with Books+Pages? I want to allow the user to create the whole shelf at once going through nested view and save Shelf+Books+Pages to the backend only once when he's on the top view and clicking "Save".

views

I tried to create an extension for objects to conform to 'ObservableObject' but failed with: Non-class type 'Shelf' cannot conform to class protocol 'ObservableObject'

Maklaus
  • 538
  • 2
  • 16
  • 37
  • `ObservableObject` works in general. Please show code how do you use it. – Asperi Jan 13 '20 at 05:52
  • Hi Asperi. My code became very messy and long. I'd appreciate a simple example based on the inputs I provided. In the question I already showed the error you get if you try to use structs with ObservableObject, so the general approach where you'd conform classed to ObservableObject doesn't work for me. – Maklaus Jan 25 '20 at 23:06
  • I don't understand this question, even after re-reading it multiple times. When you say 3 nested SwiftUI views, I'm assuming you have some `ContentView` for example with 3 separate views in it. Then, what do you mean by `"I want to allow the user to create the whole shelf at once going through nested view"`? – George Jan 26 '20 at 00:01
  • @George_E Imagine navigation view controller with 3 SwiftUI views - Shelf, Book and Page. On Shelf view you're filling the title of the Shelf, then click + to add Book and get to Book view and so on. Sorry for not being clear! – Maklaus Jan 26 '20 at 00:03
  • @Maklaus So you want to add a `Shelf`, which in turn creates `Book`s and `Page`s? – George Jan 26 '20 at 00:07
  • I want user to be able to create all 3 objects on separate views - Shelf, Book and Page. And I need to persist the state of each object while the user is clicking + to add another Book to the Shelf or Page to the book. The button Save should only be on the Shelf which will send Shelf with all the Books and Pages to the backend. – Maklaus Jan 26 '20 at 00:10
  • @George_E I've added a mockup demonstrating the views layout. – Maklaus Jan 26 '20 at 00:22
  • @Maklaus Ok I have finished my answer now [below](https://stackoverflow.com/a/59919052/9607863)! :) – George Jan 26 '20 at 14:15

4 Answers4

14

I made the full project, to demonstrate how to pass the data.

It is available on GitHub at GeorgeElsham/BookshelvesExample if you want to download the full project to see all the code. This is what the project looks like:

GIF of project

This project is quite similar to my answer for SwiftUI - pass data to different views.

As a summary, I created an ObservableObject which is used with @EnvironmentObject. It looks like this:

class BookshelvesModel: ObservableObject {

    @Published var shelves = [...]
    var books: [Book] {
       shelves[shelfId].books
    }
    var pages: [Page] {
       shelves[shelfId].books[bookId].pages
    }
    
    var shelfId = 0
    var bookId = 0
    
    func addShelf(title: String) {
        /* ... */
    }
    func addBook(title: String) {
        /* ... */
    }
    func addPage(content: String) {
        /* ... */
    }
    
    func totalBooks(for shelf: Shelf) -> String {
        /* ... */
    }
    func totalPages(for book: Book) -> String {
        /* ... */
    }
}

The views are then all connected using NavigationLink. Hope this works for you!


If you are remaking this manually, make sure you replace

let contentView = ContentView()

with

let contentView = ContentView().environmentObject(BookshelvesModel())

in the SceneDelegate.swift.

George
  • 25,988
  • 10
  • 79
  • 133
3

Well, the preferable design in this case would be to use MVVM based on ObservableObject for view model (it allows do not touch/change generated model, but wrap it into convenient way for use in View).

It would look like following

class Library: ObservableObject {
  @Published var shelves: [Shelf] = []
}

However, of course, if required, all can be done with structs only based on @State/@Binding only.

Assuming (from mockup) that the initial shelf is loaded in some other place the view hierarchy (in simplified presentation just to show the direction) can be:

struct ShelfView: View {
    @State private var shelf: Shelf

    init(_ shelf: Shelf) {
        _shelf = State<Shelf>(initialValue: shelf)
    }

    var body: some View {
        NavigationView {
            List {
                ForEach(Array(shelf.books.enumerated()), id: \.1.id) { (i, book) in
                    NavigationLink("Book \(i)", destination: BookView(book: self.$shelf.books[i]))
                }
                .navigationBarTitle(shelf.title)
            }
        }
    }
}

struct BookView: View {
    @Binding var book: Book
    var body: some View {
        List {
            ForEach(Array(book.pages.enumerated()), id: \.1.id) { (i, page) in
                NavigationLink("Page \(i)", destination: PageView(page: page))
            }
            .navigationBarTitle(book.title)
        }
    }
}

struct PageView: View {
    var page: Page
    var body: some View {
        ScrollView {
            Text(page.content)
        }
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
3

Basically, you need a storage for your books/pages, and preferably that storage can be uniquely referenced among your views. This means a class :)

class State: ObservableObject {
    @Published var shelves = [Shelf]()

    func add(shelf: Shelf) { ... }
    func add(book: Book, to shelf: Shelf) { ... }
    func add(page: Page, to book: Book) { ... }
    func update(text: String, for page: Page) { ... }
}

You can then either inject the State instance downstream in the view hierarchy, on inject parts of it, like a Shelf instance:

struct ShelvesList: View {
    @ObserverdObject var state: State

    var body: some View {
        ForEach(state.shelves) { ShelfView(shelf: $0, shelfOperator: state) }
    }
}

// this conceptually decouples the storage and the operations, allowing
// downstream views to see only parts of the entire functionality
protocol ShelfOperator: BookOperator {
    func add(book: Book, to shelf: Shelf)
}

extension State: ShelfOperator { }

struct ShelfView: View
    var shelf: Shelf
    @State var selectedBook: Book
    var shelfOperator: ShelfOperator

    var body: some View {
        ForEach(shelf.books) { book in
            Text(book.title).tapGesture {
               // intercepting tap to update the book view with the new selected book
               self.selectedBook = book
            }
        }
        BookView(book: selectedBook, bookOperator: operator)
    }
}

// This might seem redundant to ShelfOperator, however it's not
// A view that renders a book doesn't need to know about shelf operations
// Interface Segregation Principle FTW :)
protocol BookOperator: PageOperator {
    func add(page: Page, to book: Book)
}

struct BookView: View {
    var book: Book
    var bookOperator: BookOperator

    var body: some View { ... }
}

// Segregating the functionality via protocols has multiple advantages:
// 1. this "leaf" view is not polluted with all kind of operations the big
// State would have
// 2. PageView is highly reusable, since it only depends on entities it needs
// to do its job.
protocol PageOperator {
    func update(text: String, for page: Page)
}

struct PageView: View {
    var page: Page
    var pageOperator: PageOperator

    var body: some View { ... }

What happens with the code above is that data flows downstream, and events propagate upstream, and any changes caused by events are then propagated downstream, meaning your views are always in sync with the data.

Once you're done with the editing, just grab the list of shelves from the State instance and send them to the backend.

Cristik
  • 30,989
  • 25
  • 91
  • 127
2

I made you a minimal example with NavigationView and @ObservedObjects in each View. This shows the basic usage of ObservableObject with nested class. This works because each View gets passed the Model and "observes" it.

If you have any questions please read the documentation first before asking them. You should find most of the stuff under Combine and SwiftUI.

Please Note int64 does not exist as far as I know and your Array declarations are wrong too! I corrected them in the example I provided.



class PageModel: ObservableObject {
    @Published var id: Int
    @Published var content: String

    init(id: Int, content: String) {
        self.id = id
        self.content = content
    }
}

class BookModel: ObservableObject {
    @Published var id: Int
    @Published var title: String
    @Published var pages: [PageModel] = []

    init(id: Int, title: String) {
        self.id = id
        self.title = title
    }

    func addDummies() {
        DispatchQueue.main.async {
            self.pages.append(PageModel(id: 0, content: "To"))
            self.pages.append(PageModel(id: 1, content: "tell"))
            self.pages.append(PageModel(id: 2, content: "you"))
            self.pages.append(PageModel(id: 3, content: "I'm"))
            self.pages.append(PageModel(id: 4, content: "sorry..."))
            self.pages.append(PageModel(id: 5, content: "for"))
            self.pages.append(PageModel(id: 6, content: "everything"))
            self.pages.append(PageModel(id: 7, content: "that"))
            self.pages.append(PageModel(id: 8, content: "I've"))
            self.pages.append(PageModel(id: 9, content: "done..."))
        }
    }
}

class ShelfModel: ObservableObject {
    @Published var id: Int
    @Published var title: String
    @Published var books: [BookModel] = []

    init(id: Int, title: String) {
        self.id = id
        self.title = title
    }

    func add() {
        DispatchQueue.main.async {
            self.books.append(BookModel(id: self.books.count, title: "frick I am new"))
        }
    }

    func addDummies() {
        DispatchQueue.main.async {
            self.books.append(BookModel(id: 0, title: "Hello"))
            self.books.append(BookModel(id: 1, title: "from"))
            self.books.append(BookModel(id: 2, title: "the"))
            self.books.append(BookModel(id: 3, title: "other"))
            self.books.append(BookModel(id: 4, title: "side..."))
            self.books.append(BookModel(id: 5, title: "I"))
            self.books.append(BookModel(id: 6, title: "must"))
            self.books.append(BookModel(id: 7, title: "have"))
            self.books.append(BookModel(id: 8, title: "called"))
            self.books.append(BookModel(id: 9, title: "a thousand"))
            self.books.append(BookModel(id: 10, title: "times..."))
        }
    }

}

struct PageView: View {
    @ObservedObject var page: PageModel

    var body: some View {
        HStack {
            Text("\(page.id)")
            Text("\(page.content)")
        }
    }
}

struct BookView: View {
    @ObservedObject var book: BookModel

    var body: some View {
        VStack {
            HStack {
                Text("\(book.id)")
                Text("\(book.title)")
            }
            List(book.pages, id: \.id) { page in
                PageView(page: page)
            }
        }
        .navigationBarItems(trailing: Button("Add Page") {
            self.book.addDummies()
        })
    }
}

struct ContentView: View {

    @ObservedObject var shelf = ShelfModel(id: 0, title: "Lolz")

    var body: some View {
        VStack {
            NavigationView {
                List(self.shelf.books, id: \.id) { book in
                    NavigationLink(destination: BookView(book: book)) {
                        Text("\(book.title)")
                    }.navigationBarItems(trailing: Button("Add Book") {
                        self.shelf.add()
                    })
                }
            }
        }.onAppear {
            self.shelf.addDummies()
        }
    }
}

Tested on iPad Pro.

I hope this helps!

krjw
  • 4,070
  • 1
  • 24
  • 49
  • Thanks for your answer. The first line of my question states that I have structs and not classes, so I can't use ObservableObject protocols. Any suggestions? – Maklaus Jan 25 '20 at 23:02