Skip to content

Instantly share code, notes, and snippets.

@janodev
Created March 6, 2025 01:22
Show Gist options
  • Save janodev/7e7806b8544e280c6adc820656e60595 to your computer and use it in GitHub Desktop.
Save janodev/7e7806b8544e280c6adc820656e60595 to your computer and use it in GitHub Desktop.
Reproduction of a bug with Observable + ForEach + Equatable + child views.
import SwiftUI
import Observation
@Observable
final class Folder: Equatable {
var name: String
var isExpanded: Bool
init(name: String, isExpanded: Bool) {
self.name = name
self.isExpanded = isExpanded
}
func copy(name: String? = nil, isExpanded: Bool? = nil) -> Folder {
Folder(
name: name ?? self.name,
isExpanded: isExpanded ?? self.isExpanded
)
}
static func == (lhs: Folder, rhs: Folder) -> Bool {
lhs.name == rhs.name && lhs.isExpanded == rhs.isExpanded
}
}
@Observable
final class Category: Equatable {
var name: String
var folders: [Folder]
init(name: String, folders: [Folder]) {
self.name = name
self.folders = folders
}
func copy(name: String? = nil, folders: [Folder]? = nil) -> Category {
Category(
name: name ?? self.name,
folders: folders ?? self.folders
)
}
static func == (lhs: Category, rhs: Category) -> Bool {
// Uncommenting this line also fixes the issue
// ObjectIdentifier(lhs) == ObjectIdentifier(rhs) &&
lhs.name == rhs.name &&
lhs.folders == rhs.folders
}
}
@Observable
final class Library {
var categories: [Category] = [
Category(name: "Work", folders: [
Folder(name: "Meetings", isExpanded: false)
])
]
/*
Given a structure: Library --♢ Categories --♢ Folder,
the following three change the 'isExpanded' property in different ways:
- folder.isExpanded.toggle()
- folder.copy(isExpanded:)
- category.copy(folders:) <-- this one causes the other to stop working
*/
func toggleFolderExpanded(categoryIndex: Int, folderIndex: Int) {
categories[categoryIndex].folders[folderIndex].isExpanded.toggle()
print("Direct: toggled isExpanded at [\(categoryIndex)][\(folderIndex)]: \(categories[categoryIndex].folders[folderIndex].isExpanded)")
}
func toggleFolderWithCopy(categoryIndex: Int, folderIndex: Int) {
let oldFolder = categories[categoryIndex].folders[folderIndex]
let newFolder = oldFolder.copy(isExpanded: !oldFolder.isExpanded)
categories[categoryIndex].folders[folderIndex] = newFolder
// Uncommenting this line fixes the issue:
// categories[categoryIndex] = categories[categoryIndex]
print("Copy Folder: toggled isExpanded at [\(categoryIndex)][\(folderIndex)]: \(categories[categoryIndex].folders[folderIndex].isExpanded)")
}
func toggleFolderWithCategoryReplacement(categoryIndex: Int, folderIndex: Int) {
let foldersCopy = categories[categoryIndex].folders
foldersCopy[folderIndex].isExpanded.toggle()
let newCategory = categories[categoryIndex].copy(folders: foldersCopy)
categories[categoryIndex] = newCategory
print("Copy Category: toggled isExpanded at [\(categoryIndex)][\(folderIndex)]: \(categories[categoryIndex].folders[folderIndex].isExpanded)")
}
}
struct CategoryView: View {
let category: Category
var body: some View {
ForEach(category.folders, id: \.name) { folder in
Circle()
.frame(width: 44)
.foregroundColor(folder.isExpanded ? .green : .yellow)
}
}
}
struct NestedBugTestView: View {
@State private var library = Library()
var body: some View {
VStack(spacing: 20) {
ForEach(library.categories, id: \.name) { category in
CategoryView(category: category)
}
HStack {
Button("Direct") {
library.toggleFolderExpanded(categoryIndex: 0, folderIndex: 0)
}
Button("Copy Folder") {
library.toggleFolderWithCopy(categoryIndex: 0, folderIndex: 0)
}
.tint(.green)
Button("Copy Category") {
library.toggleFolderWithCategoryReplacement(categoryIndex: 0, folderIndex: 0)
}
.tint(.orange)
}
.buttonStyle(.borderedProminent)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment