Hi Swift Community,
I recently wrote an article on Medium about implementing a collapsible header in SwiftUI. The article demonstrates how to collapse and expand a header view based on the scroll position using a custom CollapsableHeader
component. You can check out the full article here: Implementing a Collapsible Header in SwiftUI.
Core Implementation
enum HeaderState {
case expanded
case collapsed
}
struct CollapsableHeader<HeaderView: View, ScrollView: View>: View {
let expandedHeaderHeight: CGFloat
let collapsedHeaderHeight: CGFloat
let headerView: (() -> HeaderView)
let scrollView: (() -> ScrollView)
@Binding var offset: CGFloat
@Binding var headerState: HeaderState
init(expandedHeaderHeight: CGFloat,
collapsedHeaderHeight: CGFloat,
offset: Binding<CGFloat>,
headerState: Binding<HeaderState>,
headerView: @escaping () -> HeaderView,
scrollView: @escaping () -> ScrollView) {
self.expandedHeaderHeight = expandedHeaderHeight
self.collapsedHeaderHeight = collapsedHeaderHeight
self._offset = offset
self._headerState = headerState
self.headerView = headerView
self.scrollView = scrollView
}
var body: some View {
ZStack(alignment: .top) {
scrollView() // Scrollable content
headerView() // Collapsible header
.frame(height: expandedHeaderHeight)
.offset(y: getOffset(offset: offset))
.zIndex(1) // Ensure the header stays on top
}
}
private func getOffset(offset: CGFloat) -> CGFloat {
guard offset < .zero else { return .zero }
if offset > -(expandedHeaderHeight - collapsedHeaderHeight) {
updateHeaderState(currentState: headerState, futureState: .expanded)
return offset
} else {
updateHeaderState(currentState: headerState, futureState: .collapsed)
return -(expandedHeaderHeight - collapsedHeaderHeight)
}
}
private func updateHeaderState(currentState: HeaderState,
futureState: HeaderState) {
if currentState != futureState {
DispatchQueue.main.async {
self.headerState = futureState
}
}
}
}
Example Usage
struct CollapsableHeaderExampleView: View {
@State var headerState: HeaderState = .expanded
@State var offset: CGFloat = .zero
private let expandedHeaderHeight: CGFloat = 150
private let collapsedHeaderHeight: CGFloat = 40
var body: some View {
CollapsableHeader(expandedHeaderHeight: expandedHeaderHeight,
collapsedHeaderHeight: collapsedHeaderHeight,
offset: $offset,
headerState: $headerState,
headerView: headerView,
scrollView: scrollableView)
}
@ViewBuilder
func headerView() -> some View {
Rectangle()
.foregroundStyle(.green)
.frame(maxWidth: .infinity)
}
@ViewBuilder
func scrollableView() -> some View {
ScrollView {
LazyVStack {
ForEach(0..<40, id: \.self) { val in
Text("Title \(String(val))")
.padding()
}
}
.padding(.top, expandedHeaderHeight) // Set top padding as expanded header height
.trackOffset(completion: { offset in
self.offset = offset.y
}, coordinatorSpace: "scrollView")
}
.coordinateSpace(name: "scrollView")
}
}
Key Highlights from the Article:
- The
CollapsableHeader
component allows you to pass a header view andScrollView
and automatically manages the expand/collapse behavior. - The enum
HeaderState
(expanded, collapsed) tracks the header state. - The header's height adjusts dynamically based on the
ScrollView
's content offset, creating a seamless collapsing effect.
Discussion Points:
- How do you handle collapsible headers in your SwiftUI projects? Are there alternative approaches that work well for you?
- Have you found any limitations or performance concerns with the approach I used? If so, how would you improve it?
- Do you think SwiftUI could benefit from more built-in features for handling collapsible elements and scrolling interactions?
I’d love to hear your feedback on this implementation and your experiences with similar features in SwiftUI.