How does the pattern of assigning `@State` in `onReceive` ensure that view state is in sync?

Viewed 74

I'm having a little trouble with the following pattern which integrates Combine publishers into SwiftUI so that view state is updated when publishers emit:

struct ItemList: View {
    var publisher: AnyPublisher<[Item], Never>
    @State private var items = [Item]()

    var body: some View {
        List(items) { item in
            ItemRow(item: item)
        }
        .onReceive(publisher) {
            items = $0
        }
    }
}

Above example from Swift by Sundell

I feel like I'm missing something when I read it.

Let's assume you initialize items to the correct (at that time) value. What ensures that the published value won't change between the creation of ItemList and the first call to body, where it first starts listening to changes? Or if there is no such guarantee, then what else is preventing the view from ending up in the wrong initial state because of this?

2 Answers

Consider a NavigationLink:

NavigationLink(
    destination: { ItemList(publisher: myPub) },
    label: { Text("Show List") }
)

Here we have a case where SwiftUI creates the ItemList immediately, but doesn't ask the ItemList for its body until the user taps the link.

(How do we know it creates the ItemList immediately? The destination argument is not declared @escaping, so SwiftUI has to call it inside the NavigationLink initializer.)

So in fact there is a real risk in this case that items should change between when the ItemList is created and when it appears on screen.

We solve this by using a publisher like CurrentValueSubject that publishes its current value immediately to each new subscriber. That way, it doesn't matter how much later SwiftUI decides to use the view. As soon as SwiftUI uses the view, it subscribes to the publisher and immediately gets the current value. SwiftUI can handle that update before updating the framebuffer, so the user doesn't see a flash of incorrect data.

We need to read it in sequence:

  1. State is initiailzed, supposing items = [Item1, Item2, Item3]

  2. body is called to render view

  3. List is constructed with current items, ie. List([Item1, Item2, Item3])

  4. onReceive is called on constructed List of 3) and creates view around that list with subscriber to publisher

  5. subscriber requests current events from publisher

  6. if there are events in publisher then onReceive's closure handler is called (see below) otherwise no changes and List of 3) is shown on screen

    6.1. if handler gets same initial [Item1, Item2, Item3] (subscriber extracts all available items) then state is not changed and List of 3) is shown on screen

    6.2. if handler gets different items [ItemX, ItemY] then state change invalidates view and List is rebuilt with [ItemX, ItemY] which are shown on screen (there is no cycling because refresh is synchronous and we get into 6.1 at second pass).

That's simplified logic of provided code snapshot.

Related