Thanks for being a part of WWDC25!

How did we do? We’d love to know your thoughts on this year’s conference. Take the survey here

Custom @Observable RandomAcccessCollection List/ForEach issues

I'm trying to understand the behavior I'm seeing here. In the following example, I have a custom @Observable class that adopts RandomAccessCollection and am attempting to populate a List with it.

If I use an inner collection property of the instance (even computed as this shows), the top view identifies additions to the list.

However, if I just use the list as a collection in its own right, it detects when a change is made, but not that the change increased the length of the list. If you add text that has capital letters you'll see them get sorted correctly, but the lower list retains its prior count. The choice of a List initializer with the model versus an inner ForEach doesn't change the outcome, btw.

If I cast that type as an Array(), effectively copying its contents, it works fine which leads me to believe there is some additional Array protocol conformance that I'm missing, but that would be unfortunate since I'm not sure how I would have known that. Any ideas what's going on here? The new type can be used with for-in scenarios fine and compiles great with List/ForEach, but has this issue. I'd like the type to not require extra nonsense to be used like an array here.

import SwiftUI

fileprivate struct _VExpObservable6: View {
	@Binding var model: ExpModel
	@State private var text: String = ""
    var body: some View {
		NavigationStack {
			VStack(spacing: 20) {
				Spacer()
					.frame(height: 40)
				HStack {
					TextField("Item", text: $text)
						.textFieldStyle(.roundedBorder)
						.textContentType(.none)
						.textCase(.none)
					Button("Add Item") {
						guard !text.isEmpty else { return }
						model.addItem(text)
						text = ""
						print("updated model #2 using \(Array(model.indices)):")
						for s in model {
							print("- \(s)")
						}
					}
				}
				
				InnerView(model: model)
				OuterView(model: model)
			}
			.listStyle(.plain)
			.padding()
		}
    }
}

// - displays the model data using an inner property expressed as
//   a collection.
fileprivate struct InnerView: View {
	let model: ExpModel
	
	var body: some View {
		VStack {
			Text("Model Inner Collection:")
				.font(.title3)
     				
			List {
				ForEach(model.sorted, id: \.self) { item in
					Text("- \(item)")
				}
			}
			.border(.darkGray)
		}
	}
}

// - displays the model using the model _as the collection_
fileprivate struct OuterView: View {
	let model: ExpModel
	
	var body: some View {
		VStack {
			Text("Model as Collection:")
				.font(.title3)

			// - the List/ForEach collections do not appear to work
			//   by default using the @Observable model (RandomAccessCollection)
			//   itself, unless it is cast as an Array here.
			List {
//				ForEach(Array(model), id: \.self) { item in
				ForEach(model, id: \.self) { item in
					Text("- \(item)")
				}
			}
			.border(.darkGray)
		}
	}
}

#Preview {
	@Previewable @State var model = ExpModel()
    _VExpObservable6(model: $model)
}


@Observable
fileprivate final class ExpModel: RandomAccessCollection {
	typealias Element = String
	
	var startIndex: Int { 0 }
	var endIndex: Int { sorted.count }
	
	init() {
		_listData = ["apple", "yellow", "about"]
	}
	
	subscript(_ position: Int) -> String {
		sortedData()[position]
	}
	
	var sorted: [String] {
		sortedData()
	}
	
	func addItem(_ item: String) {
		_listData.append(item)
		_sorted = nil
	}

	private var _listData: [String]
	private var _sorted: [String]?
	
	private func sortedData() -> [String] {
		if let ret = _sorted { return ret }
		let ret = _listData.sorted()
		_sorted = ret
		return ret
	}
}

Ultimately you do need to use a data structure that provides you with collections of identifiable elements. If you're down the path of using a custom data type, for example ExpModel, you'll a IndexedIdentifierCollection.

And using an Array in the view body has its performance issues because every time the body is evaluated a whole new array is being allocated.

I recommend you review Demystify SwiftUI performance - WWDC23 - Videos - Apple Developer. It covers this topics.

Custom @Observable RandomAcccessCollection List/ForEach issues
 
 
Q