Implementing a Collapsible Header in SwiftUI

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 and ScrollView 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:

  1. How do you handle collapsible headers in your SwiftUI projects? Are there alternative approaches that work well for you?
  2. Have you found any limitations or performance concerns with the approach I used? If so, how would you improve it?
  3. 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.

If you want your header to go away when the user scrolls, how about just including it in the scrolled content?
This sort of weird custom UI behaviour is just an annoyance for users. As if there weren’t enough of those already!

Thank you for your input! In our implementation of a collapsible header in SwiftUI, the header is designed to remain fixed at the top while resizing dynamically as the user scrolls. This approach ensures that the header provides contextual information and maintains a seamless user experience without fully disappearing.

Additionally, the header can be used to add a title or any relevant content at the top, ensuring that important information is always accessible to the user.