Collapsible component in SwiftUI
As already stated in my previous posts I wanted to match the look and feel of Reeder Classic for my personal RSS reader app. One rather tricky challenge I faced was to recreate the collapsible component to expand or collapse a folder of rss feeds inside a List. Since then I found this excellent blog post which seems to solve this exact issue: Expanding Animations in SwiftUI Lists. Unfortunately this was not published when I tackled this problem and I came up with a different solution.
Without further ado lets look at the implementation and highlight some of the key components:
import SwiftUI
extension EnvironmentValues {
@Entry var dsCollapsibleDepth: Int = 0
}
struct DSCollapsible<Header: View, Content: View>: View {
@Environment(\.theme) private var theme
@Environment(\.dsCollapsibleDepth) private var depth
@Binding private var isExpanded: Bool
private let action: (() -> Void)?
private let header: Header
private let content: () -> Content
private let accessibilityLabel: () -> Text
init(
isExpanded: Binding<Bool>,
action: (() -> Void)? = nil,
accessibilityLabel: @escaping () -> Text,
@ViewBuilder header: () -> Header,
@ViewBuilder content: @escaping () -> Content
) {
self._isExpanded = isExpanded
self.action = action
self.accessibilityLabel = accessibilityLabel
self.header = header()
self.content = content
}
private func headerAction() {
if let action {
action()
} else {
withAnimation(theme.tokens.animation.collapsible) {
isExpanded.toggle()
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: headerAction) {
HStack(spacing: 0) {
HStack {
header
.font(theme.tokens.font.button)
.foregroundStyle(.text.primary)
Spacer()
}
.padding(.leading, theme.tokens.spacing.s + CGFloat(depth) * theme.tokens.spacing.s)
.padding(.vertical, theme.tokens.spacing.s)
.frame(maxWidth: .infinity, minHeight: 44)
DSIcon("chevron.right")
.font(theme.tokens.font.button)
.foregroundStyle(.text.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.animation(theme.tokens.animation.collapsible, value: isExpanded)
.padding(.trailing, theme.tokens.spacing.s)
.padding(.vertical, theme.tokens.spacing.s)
.frame(minHeight: 44)
// When there is a navigation action, intercept chevron taps
// so they only toggle expand/collapse, not navigate.
.highPriorityGesture(action != nil ? TapGesture().onEnded {
withAnimation(theme.tokens.animation.collapsible) {
isExpanded.toggle()
}
} : nil)
}
.contentShape(.rect)
}
.buttonStyle(DSListItemStyle())
.accessibilityLabel(accessibilityLabel())
.accessibilityAddTraits(.isButton)
.accessibilityValue(isExpanded ? Text("Expanded") : Text("Collapsed"))
.accessibilityAction(named: isExpanded ? Text("Collapse") : Text("Expand")) {
withAnimation(theme.tokens.animation.collapsible) { isExpanded.toggle() }
}
VStack(spacing: 0) {
if isExpanded {
VStack(spacing: 0) {
content()
}
.environment(\.dsCollapsibleDepth, depth + 1)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.clipped()
}
.dsSectionCardStyle(.transparent)
}
}
struct DSCollapsibleSection<Content: View>: View {
@State private var isExpanded: Bool
private let title: TextContent
private let action: (() -> Void)?
private let content: () -> Content
init(
_ title: LocalizedStringKey,
initiallyExpanded: Bool = true,
action: (() -> Void)? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.title = .localized(title)
self._isExpanded = State(initialValue: initiallyExpanded)
self.action = action
self.content = content
}
init(
verbatim title: String,
initiallyExpanded: Bool = true,
action: (() -> Void)? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.title = .verbatim(title)
self._isExpanded = State(initialValue: initiallyExpanded)
self.action = action
self.content = content
}
@_disfavoredOverload
init(
_ title: String,
initiallyExpanded: Bool = true,
action: (() -> Void)? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.title = .verbatim(title)
self._isExpanded = State(initialValue: initiallyExpanded)
self.action = action
self.content = content
}
var body: some View {
DSCollapsible(isExpanded: $isExpanded, action: action, accessibilityLabel: { title.textView }) {
title.textView
} content: {
content()
}
.dsSectionCardStyle(.transparent)
}
}
The non obvious solution to animation issues I had is replicated in these few lines:
VStack(spacing: 0) {
if isExpanded {
VStack(spacing: 0) {
content()
}
.environment(\.dsCollapsibleDepth, depth + 1)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.clipped()
Without the clipping of the outer VStack the expanded content would always animate in weird and non obvious ways. The fix is to provide a stable anchor to the SwiftUI layout system which is rendered regardless of the isExpanded flag.
Also you might have noticed the dsCollapsibleDepth environment value. This environment value allows me to nest collapsible components. For each nesting the depth is increased by 1 and the rendered item then can read this information and use it for setting the correct padding:
struct DSListItem<Content: View>: View {
var body: some View {
Button(action: action) {
content
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, theme.tokens.spacing.s + CGFloat(depth) * theme.tokens.spacing.s) // <-- this is the important part
.padding(.trailing, theme.tokens.spacing.s)
.padding(.vertical, theme.tokens.spacing.s)
.frame(minHeight: 44)
}
.containerValue(\.dsSuppressTrailingDivider, true)
.buttonStyle(DSListItemStyle())
.dsSectionCardStyle(.transparent)
}
}
With this in place we can nest this component freely:
DSList {
Section {
DSCollapsibleSection("Folders") {
DSCollapsibleSection("Tech") {
DSListItem(action: {}) {
Text("Some feed name")
}
}
}
} header: {
Text("Nested Collapsible")
}
}
If we would use this component in the builtin List component from SwiftUI I think we would need to apply the fixes from the referenced blog post above. (e.g. using an Animatable view that can animate its height). Since I am using my custom DSList component (showcased in SwiftUI Container API) this works flawlessly however.
I really had hoped to be able to use the existing DisclosureGroup API but unfortunately Apple does not provide any customization possibilities for it. A custom DisclosureGroup style would be a very welcome addition to the API surface in my opinion. Until then I am using my custom component.
If you want to see the component in action and how it behaves head over to my other post Building an iOS RSS reader where I included a demo video of the application.
Posted in swift