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)
            }
        }
    }
}
Swift Gems by Natalia Panferova book coverSwift Gems by Natalia Panferova book cover

Level up your Swift skills!$35

100+ tips to take your Swift code to the next level

Swift Gemsby Natalia Panferova

  • Advanced Swift techniques for experienced developers bypassing basic tutorials
  • Curated, actionable tips ready for immediate integration into any Swift project
  • Strategies to improve code quality, structure, and performance across all platforms

Level up your Swift skills!

100+ tips to take your Swift code to the next level

Swift Gems by Natalia Panferova book coverSwift Gems by Natalia Panferova book cover

Swift Gems

by Natalia Panferova

$35

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


Integrating SwiftUI into UIKit Apps by Natalia Panferova book coverIntegrating SwiftUI into UIKit Apps by Natalia Panferova book cover

Enhance older apps with SwiftUI!$45

A detailed guide on gradually adopting SwiftUI in UIKit projects

Updated for iOS 18 and Xcode 16!

Integrating SwiftUI into UIKit Appsby Natalia Panferova

  • Upgrade your apps with new features like Swift Charts and Widgets
  • Support older iOS versions with effective backward-compatible strategies
  • Seamlessly bridge state and data between UIKit and SwiftUI using the latest APIs

Enhance older apps with SwiftUI!

A detailed guide on gradually adopting SwiftUI in UIKit projects

Integrating SwiftUI into UIKit Apps by Natalia Panferova book coverIntegrating SwiftUI into UIKit Apps by Natalia Panferova book cover

Integrating SwiftUI
into UIKit Apps

by Natalia Panferova

Updated for iOS 18 and Xcode 16!

$45