Quick Tip Icon
Quick Tip

State restoration for DisclosureGroup expansion in List rows

In SwiftUI we can use SceneStorage property wrapper to add state restoration support for our apps.

It's easy to save simple types in SceneStorage but it requires a bit more work from us to add support for more advanced use cases.

Let's say we have a nested list of items like the following list of network request groups.

let requestGroups = [
    RequestGroup(
        id: UUID(
            uuidString: "7bbd0224-3a31-4ba7-9dbb-b7138de7aaee"
        ) ?? UUID(),
        name: "httpbin.org",
        requests: [
            Request(url: "https://httpbin.org/get"),
            Request(url: "https://httpbin.org/post")
        ]
    ),
    RequestGroup(
        id: UUID(
            uuidString: "6df253c4-3c91-4712-a398-605c67b1e5b5"
        ) ?? UUID(),
        name: "echo.websocket.org",
        requests: [
            Request(url: "wss://echo.websocket.org")
        ]
    )
]

We would like to display these requests in a List view with DisclosureGroup in each row, so that the user can expand and collapse each group.

List {
    ForEach(requestGroups) { group in
        DisclosureGroup(
            group.name
        ) {
            ForEach(group.requests) { request in
                Text(request.url)
            }
        }
    }
}

To persist the expansion state per row, we can save a set of expanded item ids in SceneStorage.

First, we need to add conformance to RawRepresentable for the set of UUIDs, so that it can be saved in SceneStorage.

extension Set: RawRepresentable where Element == UUID {
    public init?(rawValue: String) {
        guard
            let data = rawValue.data(using: .utf8),
            let result = try? JSONDecoder()
                .decode(Set<UUID>.self, from: data) else {
            return nil
        }
        self = result
    }
    
    public var rawValue: String {
        guard
            let data = try? JSONEncoder().encode(self),
            let result = String(data: data, encoding: .utf8)
        else { return "[]" }
        return result
    }
}

Then we need to create a custom Binding to read and write the current expansion, so that it can be passed to DiscloreGroup.

@SceneStorage("expandedGroups") var isExpanded: Set<UUID> = []

var isExpandedBinding: Binding<Bool> {
    Binding<Bool> {
        self.isExpanded.contains(self.group.id)
    } set: { isExpanded in
        if isExpanded {
            self.isExpanded.insert(self.group.id)
        } else {
            self.isExpanded.remove(self.group.id)
        }
    }
}

And here is the final code for the entire example with expandable rows and state restoration support.

struct ContentView: View {
    let requestGroups = [
        RequestGroup(
            id: UUID(
                uuidString: "7bbd0224-3a31-4ba7-9dbb-b7138de7aaee"
            ) ?? UUID(),
            name: "httpbin.org",
            requests: [
                Request(url: "https://httpbin.org/get"),
                Request(url: "https://httpbin.org/post")
            ]
        ),
        RequestGroup(
            id: UUID(
                uuidString: "6df253c4-3c91-4712-a398-605c67b1e5b5"
            ) ?? UUID(),
            name: "echo.websocket.org",
            requests: [
                Request(url: "wss://echo.websocket.org")
            ]
        )
    ]
    
    var body: some View {
        NavigationView {
            List {
                ForEach(requestGroups) { group in
                    RequestGroupRow(group: group)
                }
            }
            .listStyle(SidebarListStyle())
        }
    }
}

struct RequestGroupRow: View {
    @SceneStorage("expandedGroups") var isExpanded: Set<UUID> = []
    
    let group: RequestGroup
    
    var isExpandedBinding: Binding<Bool> {
        Binding<Bool> {
            self.isExpanded.contains(self.group.id)
        } set: { isExpanded in
            if isExpanded {
                self.isExpanded.insert(self.group.id)
            } else {
                self.isExpanded.remove(self.group.id)
            }
        }
    }
    
    var body: some View {
        DisclosureGroup(
            group.name, isExpanded: isExpandedBinding
        ) {
            ForEach(group.requests) { request in
                Text(request.url)
            }
        }
    }
}

struct RequestGroup: Identifiable {
    var id: UUID
    var name: String
    
    var requests: [Request]
}

struct Request: Identifiable {
    let id = UUID()
    let url: String
    
    var name: String { url }
}

extension Set: RawRepresentable where Element == UUID {
    public init?(rawValue: String) {
        guard
            let data = rawValue.data(using: .utf8),
            let result = try? JSONDecoder()
                .decode(Set<UUID>.self, from: data) else {
            return nil
        }
        self = result
    }
    
    public var rawValue: String {
        guard
            let data = try? JSONEncoder().encode(self),
            let result = String(data: data, encoding: .utf8)
        else { return "[]" }
        return result
    }
}