skip to Main Content

Background

I want a view that will allow the user to create a new core data item. That item will have a link to another core data item which the user will select from a picker which will be pre-populated with a default item selected when the view first loads (the most relevent one for that user).

## Problem

But when I change the pickers "selection" variable during the .onAppear (in the actual code it’s a modified version on .onAppear that only runs the code the first time .onAppear shows but that’s good enough for this question) it doesn’t change the picker’s selected value.

If I then change it again using say a button that runs the same selection code it DOES change the picker.

Also if I add something that references the picker’s selection var (which is a @State. See example code) it works perfectly!

How to replicate the issue

To replicate the issue create a new project in Xcode (14.3), select App and tick Use Core Data.

Now replace the whole of the ContentView with the following code:

import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(.managedObjectContext) private var viewContext
    
    @FetchRequest(
        sortDescriptors: []
    ) var items: FetchedResults<Item>
    
    @State private var selectedItem: Item?
    
    var body: some View {
        Form {
            // ---------------------------------------------
            // Uncommenting the next line fixes the pickers!
            //let _ = selectedItem
            // ---------------------------------------------
            Picker ("Part of Item Session", selection: $selectedItem) {
                Text("New Item").tag(nil as Item?)
                ForEach(items) { item in
                    Text("(item.timestamp!.description)").tag(item as Item?)
                }
            }
            Button {
                ContentView.addItems(withViewContext: viewContext)
            } label: {
                Text("Add Items")
            }
            Button {
                selectedItem = ContentView.getItem(withViewContext: viewContext)
            } label: {
                Text("Select Random Item")
            }
            Button {
                if (selectedItem != nil) {
                    print("Item: (String(describing: selectedItem!.timestamp))")
                } else {
                    print("Item is nil!")
                }
            } label: {
                Text("View Item (print to console)")
            }
        }
        .onAppear {
            selectedItem = ContentView.getItem(withViewContext: viewContext)
            if (selectedItem != nil) {
                print("Item: (String(describing: selectedItem!.timestamp))")
            } else {
                print("Item is nil!")
            }
        }
    }
    
    static func addItems(withViewContext viewContext: NSManagedObjectContext) {
        for _ in 0..<10 {
            let newItem = Item(context: viewContext)
            newItem.timestamp = Date().addingTimeInterval(-Double(Int.random(in: 1..<5000)))
        }
        do {
            try viewContext.save()
        } catch {
            let nsError = error as NSError
            fatalError("Unresolved error (nsError), (nsError.userInfo)")
        }
    }
    
    static func getItem(withViewContext viewContext: NSManagedObjectContext) -> Item? {
        let fetchRequest: NSFetchRequest<Item> = Item.fetchRequest()
        fetchRequest.sortDescriptors = []
        
        var items: [Item] = []
        do {
            // Fetch
            items = try viewContext.fetch(fetchRequest)
            
            // Pick a random(ish) item
            if (items.count > 0) {
                let randomInt = Int.random(in: 0..<items.count)
                print("Setting Item: '(items[randomInt].timestamp!.description)'")
                // Return the random(ish) item
                return items[randomInt]
            }
        } catch {
            print("Unable to Fetch Item, ((error))")
        }
        
        return nil
    }
}

Then:

  • Running the project in an iPhone 14 Pro Max simulator
  • Click the Add Items button to add some examples to your DB
  • Re-run the app in the simulator from Xcode to re-start it and you’ll see the picker still has New Item selected but if you click the View Item ... button it’ll print the selection’s actual value which is not nil (the value for New Item)!
  • If you change the selection use Select Random Item (which just picks another random item for the picker) it will correctly change the Picker correctly.

Also if you:

  • Restart the app again
  • Click the View Item button
  • Select the same item from the picker that was printed in the console it won’t change the picker.
  • IF however you change the picker to any other item it WILL change the picker!

Strange hack that fixes it

To see the picker working like I expected it to un-comment the line let _ = selectedItem (line 17) and re-run the app… Now right away the picker is correct!

Question

What’s going on here. Is it a bug in Swift or am I doing something wrong?

2

Answers


  1. The reason this doesn’t work is that the write to selectedItem in onAppear doesn’t trigger a refresh of the UI. My guess is that this is because it all (first create/init of UI and .onAppear) happens in the same SwiftUI update cycle.

    I am not sure how much of a real world problem this is and how much is caused by the simulator, for example running this as a MacOS project made it very hard to reproduce while in the simulator it was 100% reproducible.

    If we add let _ = Self._printChanges() as the first line in the body we get

    ContentView: @self, @identity, _viewContext, _items, @32, @56, _selectedItem changed.

    and this prints right before the print statements in onAppear prints. When the line let _ = selectedItem is added it works because for some reason this triggers a new UI update and _printChanges() now prints

    ContentView: _selectedItem changed.

    after the other prints when we run the app again.

    This isn’t the most scientific explanation 🙂 but understanding the inner workings of SwiftUI isn’t easy. Anyway, a simple way to make the writing to selectedItem trigger an UI update is to change from .onAppear to the .task modifier.

    .task {
        selectedItem = ContentView.getItem(withViewContext: viewContext)
        if (selectedItem != nil) {
            print("Item: (String(describing: selectedItem))")
        } else {
            print("Item is nil!")
        }
    }
    

    Update

    I did another test where I removed the FetchRequest and instead let getItem load the items property and now it worked fine using onAppear and without the extra let _ = selectedItem. This makes me think it is the FetchRequest that by causing a delay, or for some other reason, makes the view creation clash with the onAppear execution.

    With this solution it’s clear that onAppear causes a SwiftUI update since

    ContentView: _items changed.

    gets printed after it has been executed.

    I am not sure if this is even a real answer or rather some random conclusions of my own testing of the code. Let me know once you read this if it’s worth keeping or if I should delete it.

    Login or Signup to reply.
  2. SwiftUI only calls body for states that are set if they have previously been read, ie their getter has been called. This is SwiftUI’s dependency tracking system. Your way of doing a fake read is fine; here is another way:

    .onAppear { [selectedItem] in
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search