List jumps back to the top

Following on from this thread: https://developer.apple.com/forums/thread/805037 my list of items is now correctly maintaining state (no more disappearing rows), but I'm now hitting a really annoying issue: Every time something changes - even just changing the dark mode of the device - the entire list of items is refreshed, and the list jumps back to the top.

A simple representation:

// modelData.filteredItems is either all items or some items, depending on whether the user is searching
List {
	ForEach(modelData.filteredItems) { item in
		ItemRow(item: item)
	}
}

When the user isn't searching, filteredItems has everything in it. When they turn on search, I filter and sort the data in place:

// Called when the user turns on search, or when the searchString or searchType changes
func sortAndFilterItemsInModelData() {
	modelData.filteredItems.removeAll()  // Remove all items from the filtered array
	modelData.filteredItems.append(contentsOf: modelData.allItems)  // Add all items back in
	let searchString: String = modelData.searchString.lowercased()

	switch(modelData.searchType) {
		case 1:
			// Remove all items from the filtered array that don't match the search string
			modelData.filteredItems.removeAll(where: { !$0.name.lowercased().contains(searchString) })
		...
	}

	// Sorting
	switch(modelData.sortKey) {
		case sortKeyDate:
			modelData.sortAscending ? modelData.filteredItems.sort { $0.date < $1.date } : modelData.filteredItems.sort { $0.date > $1.date }  // Sorts in place
		...
	}
}

The method doesn't return anything because all the actions are done in place on the data, and the view should display the contents of modelData.filteredItems.

If you're searching and there are, say 10 items in the list and you're at the bottom of the list, then you change the search so there are now 11 items, it jumps back to the top rather than just adding the extra ItemRow to the bottom. Yes, the data is different, but it hasn't been replaced; it has been altered in place.

The biggest issue here is that you can simply change the device to/from Dark Mode - which can happen automatically at a certain time of day - and you're thrown back to the top of the list. The array of data hasn't changed, but SwiftUI treats it as though it has.

There's also a section in the List that can be expanded and contracted. It shows or hides items of a certain type. When I expand it, I expect the list to stay in the same place and just show the extra rows, but again, it jumps to the top. It's a really poor user experience.

Am I doing something wrong (probably, yes), or is there some other way to retain the scroll position in a List? The internet suggests switching to a LazyVStack, but I lose left/right swipe buttons and the platform-specific styling.

Thanks.

Answered by DTS Engineer in 863534022

I still can't figure out a way to stop the list from jumping back to the top when the device's Dark Mode setting changes. Why does SwiftUI think the data is different?

A user changing the system appearance in isolation should not cause the the list scroll position to change. However. the body of a view will be called whenever you change the view such as by passing a value into its initializer from the parent view, and that value changes, or a dynamic property changes either explicitly because you changed an @State variable, or implicitly because you read something from the environment and that value changed such as like the color scheme.

You can include let _ = Self._printChanges() in a view body while debugging to see why the view’s body was triggered.

If that may help, I found this, but did not test, using ScrollViewReader, but you've probably found as well:

Get the scroll position: https://stackoverflow.com/questions/62588015/get-the-current-scroll-position-of-a-swiftui-scrollview

Set the scroll position (in .onAppear ?): https://www.hackingwithswift.com/quick-start/swiftui/how-to-scroll-to-a-specific-row-in-a-list

Or would it be enough to set the selection when it exists ? https://stackoverflow.com/questions/74365665/force-deselection-of-row-in-swiftui-list-view/74368833#74368833

Thanks @Claude31, I have found stuff like that, yeah.

For the section in my list that can be expanded and contracted, ScrollViewReader and .scrollTo work to keep you in the right place, so thanks for the hint.

This is the code for the List, if it helps:

ScrollViewReader { proxy in
	List {
		// Standard items
		ForEach(modelData.filteredItems.filter { !$0.archived }) { item in
			ItemRow(item)
		}

		// Archived items
		if(modelData.filteredItems.filter { $0.archived }.count > 0) {
			ListSectionHeader_Archived()
				.id("ListSectionHeader_Archived")
				.onTapGesture {
					modelData.archivedItemsExpanded.toggle()
					DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
						proxy.scrollTo("ListSectionHeader_Archived")
					}
				}

			if(modelData.archivedItemsExpanded) {
				ForEach(modelData.filteredItems.filter { $0.archived }) { item in
					ItemRow(item)
				}
			}
		}
	}  // List
	.id(UUID())
}

I still can't figure out a way to stop the list from jumping back to the top when the device's Dark Mode setting changes. Why does SwiftUI think the data is different?

Why does SwiftUI think the data is different?

Maybe it's not that it evaluates data has changed but just that it needs redraw (even only for dark mode switch). But I'm not expert enough in SwiftUI to be sure.

That's an issue I find with SwiftUI, understanding and possibly controlling what it is doing in the back or find a workaround. I'm still much more comfortable with UIKit.

I'm not sure either, @Claude31, but before I implemented the @StateObject for my model data this didn't happen.

I understand that using @StateObject is the correct thing to do, but why does SwiftUI throw in these extra problems when you're doing things the right way?

I still can't figure out a way to stop the list from jumping back to the top when the device's Dark Mode setting changes. Why does SwiftUI think the data is different?

A user changing the system appearance in isolation should not cause the the list scroll position to change. However. the body of a view will be called whenever you change the view such as by passing a value into its initializer from the parent view, and that value changes, or a dynamic property changes either explicitly because you changed an @State variable, or implicitly because you read something from the environment and that value changed such as like the color scheme.

You can include let _ = Self._printChanges() in a view body while debugging to see why the view’s body was triggered.

Here's the code for creating an item in the List:

fileprivate func drawItemRow(_ item: ItemDetails) -> some View {
	return ItemRow(item: item)
		.id(item.id)
		.swipeActions(edge: .leading) {
			LeftSwipeButtons(item: item)
		}
		.swipeActions(edge: .trailing, allowsFullSwipe: false) {
			RightSwipeButtons(item: item)
		}
		.contextMenu {
			ItemContextMenu(item: item)
		}
}
...

List {
	ForEach(modelData.filteredItems) { item in
		drawItemRow(item)
	}
}
.id(UUID())

struct itemRow: View {
//	@Environment(ModelData.self) private var modelData: ModelData
	@Environment(\.accessibilityDifferentiateWithoutColor) var accessibilityDifferentiateWithoutColor
	@Environment(\.colorScheme) var colorScheme

	var item: ItemDetails

	var body: some View {
		if(item.name == "TestItem") {
			let _ = Self._printChanges()
		}
		...
	}
}

I added the _printChanges call for a specific row (so I can isolate it). On first displaying it, I get:

ItemRow: @self, @identity, _accessibilityDifferentiateWithoutColor, _colorScheme changed.

When I manually change to Dark Mode, I get this:

ItemRow: @identity, _colorScheme changed.
itemRow: @self, @identity, _accessibilityDifferentiateWithoutColor, _colorScheme changed.

Right now I'm using the singleton for the modelData, i.e. dataService.modelData anywhere it's needed in the ItemRow struct, but if I uncomment @Environment(ModelData.self) private var modelData: ModelData (and use modelData) it says _modelData also changed. The model data did not change.

If I also print out the Item.id that's used as the .id() on the row, it stays the same, so I'm not sure what @identity refers to.

I don't get where I'm going wrong with this? Perhaps you can spot the obvious?

Accepted Answer

Seems like you have some UI which needs to change its appearance based on color scheme so SwiftUI reads the color scheme value from the environment and your view updates. Because of the way State works, transient instances of ModelData could be created and destroyed.

Are you performing any side effect-like work in the model initializer? You should defer side effect-like work in the initializer to task or onAppear modifier.

Yes, I change the UI when colorScheme changes, otherwise what's the point of that environment variable?

For example:

RoundedRectangle(cornerRadius: 8)
	.shadow(color: (colorScheme == .dark ? Color.systemGray.opacity(0.6) : Color.systemGray3.opacity(0.9)), radius: 4, x: 0, y: 0)

Is there some other way of doing this? If I create a new colour in Assets with a dark and light appearance to cover that use case, I would have to create tons of colours to cover every other use case, i.e.:

.background(colorScheme == .dark ? Color.green : Color.white)
.background(colorScheme == .dark ? Color.blue : Color.red)

What is the reason that SwiftUI will redraw a view - and bounce you back to the top of a List of views - because Dark Mode was turned on?

This is how modelData is created:

// A global:
let dataService: DataService = DataService.shared

// The singleton:
final class DataService {
	static let shared = DataService()
	private init() {}
	let coreData: CoreData = CoreData()
	let modelData: ModelData = ModelData()
}

The model initialiser does this:

availableItems = []
filteredItems = []
mainItem = itemDetails.blankItem
// Then the rest are settings that pull their values from defaults, such as:
lastUpdated = defaultsGetLastUpdatedDate()

It's a singleton, so when it's created at the app level, I call a method in the .onAppear {} of the app's WindowGroup:

func getInitialModelData() {
	availableItems = dataService.coreData.getAllEvents()
	filteredItems(contentsOf: availableItems)
	mainItem = dataService.coreData.getMainItem()
}

That method is only ever called once.

At no point when I turn on Dark Mode does anything happen in ModelData.

These issues didn't happen before I switched to @StateObject.

I've spent the last three hours changing every instance of colorScheme == .dark ? Color1 : Color2 into asset colours, and this fixes the Dark Mode issue.

All it seems you can do with @Environment(\.colorScheme) var colorScheme is read its value, but not do anything with it for fear it will redraw the entire view. And, when you use it in a view that's used in a List, your entire list will be redrawn.

Anyway, that's fixed now. Thanks for your help.

List jumps back to the top
 
 
Q