SwiftUI state is maddening

I honestly thought I was getting somewhere with this, but alas, no. Every time I do anything in my List of ItemRows it jumps back to the top.

Here's the setup:

DataService.swift:
final class DataService {
	static let shared = DataService()
	private init() {}
	let coreData: CoreData = CoreData()
	let modelData: ModelData = ModelData()
}


ModelData.swift:
@Observable
class ModelData: ObservableObject {
	var allItems: [ItemDetails]
	var standardItems: [ItemDetails]
	var archivedItems: [ItemDetails]

	init() {
		allItems = []
		standardItems = []
		archivedItems = []
	}

	func getInitialData() {
		// Get all items, then split them into archived and non-archived sets, because you can't use `.filter` in a view...
		allItems = dataService.coreData.getAllItems()
		standardItems.append(contentsOf: allItems.filter { !$0.archived })
		archivedItems.append(contentsOf: allItems.filter { $0.archived })
	}
}


MainApp.swift:
// Get access to the data; this singleton is a global as non-view-based functions, including the `Scene`, need to access the model data
let dataService: DataService = DataService.shared

@main
struct MainApp: App {
	// Should this be @ObservedObject or @StateObject?
	@ObservedObject private var modelData: ModelData = dataService.modelData

	// I would use @StateObject if the line was...
	//@StateObject private var modelData: ModelData = ModelData()  // right?
	// But then I couldn't use modelData outside of the view hierarchy

	var body: some Scene {
		WindowGroup {
			ZStack {
				MainView()
					.environment(modelData)
			}
		}
		.onAppear {
			modelData.getInitialData()
		}
	}
}


MainView.swift:
struct MainView: View {
	@Environment(ModelData.self) private var modelData: ModelData

	var body: some View {
		...
		ForEach(modelData.standardItems) { item in
			ItemRow(item)
		}
		ForEach(modelData.archivedItems) { item in
			ItemRow(item)
		}
	}
}


ItemRow.swift:
struct ItemRow: View {
	@Environment(\.accessibilityDifferentiateWithoutColor) private var accessibilityDifferentiateWithoutColor

	var item: ItemDetails

	@State private var showDeleteConfirmation: Bool = false

	var body: some View {
		// Construct the row view
		// `accessibilityDifferentiateWithoutColor` is used within the row to change colours if DWC is enabled, e.g. use different symbols instead of different colours for button images.
		// Add the .leftSwipeButtons, .rightSwipeButtons, and .contextMenu
		// Add the .confirmationDialog for when I want to ask for confirmation before deleting an item
	}
}

Now, the problems:

  1. Swipe an item row, tap one of the buttons, e.g. edit, and the list refreshes and jumps back to the top. In the console I see: ItemRow: @self, @identity, _accessibilityDifferentiateWithoutColor changed. Why did accessibilityDifferentiateWithoutColor change? The setting in Settings > Accessibility > Display & Text Size has not been changed, so why does the row's view think it changed?

  2. With a .confirmationDialog attached to the end of the ItemRow (as seen in the code above), if I swipe and tap the delete button the list refreshes and jumps back to the top again. In the console I see: ItemRow: @self, @identity, _accessibilityDifferentiateWithoutColor, _showDeleteConfirmation changed. Right, it changed for the one row that I tapped the button for. Why does every row get redrawn?

I already had to shift from using the colorScheme environment variable to add new asset colours with light and dark variants to cover this, but you can't do that with DWC.

Honestly, managing state in SwiftUI is a nightmare. I had zero problems until iOS 26 started removing one or two rows when I scrolled, and the fix for that - using @Statebject/@ObservedObject - has introduced multiple further annoying, mind-bending problems, and necessitated massive daily refactorings. And, of course, plenty of my time islost trying to figure out where a problem is in the code because "The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions"...

I sympathise with you. 🥲

SwiftUI may be great in some aspects, but I often find that fine tuning to achieve a precise UI behaviour is a nightmare. When it is impossible to understand why SwiftUI reacts in a certain way, you end up spending enormous time just to turn around SwiftUI to mimic UIKit behaviour.

Big sigh.

This isn't even fine-tuning, this is just trying to get the entire List to stop being redrawn when something minor happens, end even when nothing has changed!

Accessibility DWC hasn't changed, but SwiftUI thinks it has, and so the List redraws every row.

I swipe one row and tap the edit button, the List jumps back to the top. No reason at all for that. Nothing has changed, but SwiftUI decides @self, @identity changed so it redraws every row. How did anything change? I didn't change the .id() of the rows - they use the id of the Item which has not changed.

When this @State private var showDeleteConfirmation: Bool = false changes on one row because the user swiped and tapped the delete button, the entire List is redrawn. Why?

I fear I'm going to end up with hundreds of stupid little hacks to get things to work, and they'll just fail on the next point release of the OS.

And, as I've said on multiple occasions over the past week, across three other posts on this subject, this did not happen until iOS/iPadOS 26.

In MainApp I've tried creating the modelData variable with:

// These two require ModelData to conform to ObservableObject, and vars must be marked with @Published:
@StateObject private var modelData: ModelData = dataService.modelData
@ObservedObject private var modelData: ModelData = dataService.modelData

// This one uses @Observable instead, and vars aren't explicitly marked as @Published:
@State private var modelData: ModelData = dataService.modelData

// This is a simple let var:
let modelData: ModelData = dataService.modelData

In all four cases the entire list refreshes every time I swipe a row and press a button. In the case of the delete button, the confirmation dialog appears then disappears because the row changes.

ObservableObject is the 'old' way of doing this, right? ModelData instead has the @Observable annotation.

As stated earlier, the DataService is a singleton:

DataService.swift:
final class DataService {
	static let shared = DataService()
	private init() {}
	let coreData: CoreData = CoreData()
	let modelData: ModelData = ModelData()
}

Do I make the creation of the modelData variable here a @State variable instead of in MainApp?

I'm really at a loss here. Nothing I try makes any difference.

If Apple are listening, you really need to make this stuff easier to figure out and fix. Other IDEs will proactively warn you about code issues, but Xcode just lets you do anything, and doesn't help. The compiler can't even tell you where an error is in a thousand lines of code when you've typed a comma instead of a period. It's frustrating.

Oh, would you look at that? It's nothing at all to do with my variables. It's SwiftUI and these damned Environment variables.

This time it was @Environment(\.dismiss) on the view that contains the List, not the rows inside the List.

This is what is supposed to happen:

  1. The user taps a button in MainView.
  2. ItemsListView opens, which contains the List.
  3. The user does something in ListItemsView, such as deleting an Item.
  4. In order to close ItemsListView the user taps a button in that view, which merely calls dismiss(). dismiss() was used to avoid using a @State var in MainView and a corresponding @Binding var in ItemsListView.

This should work, right?

Wrong.

What actually happens is this:

  1. The user taps a button in MainView.
  2. ItemsListView opens, which contains the List.
  3. The user swipes an ItemRow, then taps the delete button.
  4. The confirmation dialog appears, and is attached to the specific row.
  5. _dismiss is changed - for some reason - in ItemsListView, which causes the view to redraw, causing the List to redraw, causing every ItemRow to redraw, causing the confirmation dialog to disappear because the row it was attached to no longer exists.

Obviously, the fix here is to revert to using @State and @Binding vars, and that does indeed work.

So the question becomes: Why on Earth did dismiss change in ItemsListView? Your guess is as good as mine.

7 hours wasted.

This happens on the Simulator only.

Raised FB20880253.

SwiftUI state is maddening
 
 
Q