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
}
}