Stretchable Header in SwiftUI for Vertical and Horizontal ScrollView


Greetings, traveler!

Stretchy headers and elastic overscroll effects have become a familiar part of modern iOS interfaces. They add visual feedback, reinforce the scroll direction, and make content feel more responsive and alive. With newer APIs, the platform now offers a clean solution — and with the right abstraction, the same behavior can be made available on older iOS versions as well.

In this article, we’ll look at a small SwiftUI component that provides a reusable stretchable view modifier, works natively on iOS 17 and newer, and includes a backported implementation for iOS 13+. We’ll cover when such a component is useful, how it behaves, and how to apply it in real projects.

You can check out the full code on my GitHub.

A Unified SwiftUI API

The goal of this component is to offer a single, minimal API that behaves consistently across iOS versions.

At its core is a simple view extension:

extension View {
    func stretchable(
        axis: Axis = .vertical,
        uniform: Bool = false
    ) -> some View
}

This modifier can be applied to any view inside a ScrollView and requires no custom containers or layout wrappers.

Configuration Options

  • Axis
    Controls whether the stretching reacts to vertical or horizontal scrolling.
  • Uniform scaling
    When enabled, the view scales on both axes. When disabled, scaling is limited to the scroll direction.

This makes the modifier flexible enough for both traditional vertical lists and horizontal carousels.

How It Works Conceptually

iOS 17 and Newer

On iOS 17+, SwiftUI provides access to scroll-relative geometry through visualEffect and the .scrollView coordinate space. This allows the modifier to measure how far a view has been pulled past the scroll view’s starting edge and derive a scale factor directly from that value.

extension View {
    @available(iOS 17.0, *)
    func stretchableView(
        axis: Axis,
        uniform: Bool
    ) -> some View {
        visualEffect { effect, geometry in
            let frame = geometry.frame(in: .scrollView)
            
            let offset: CGFloat
            let currentLength: CGFloat
            
            switch axis {
            case .vertical:
                offset = frame.minY
                currentLength = geometry.size.height
            case .horizontal:
                offset = frame.minX
                currentLength = geometry.size.width
            }
            
            let positiveOffset = max(0, offset)
            let scale = (currentLength + positiveOffset) / max(currentLength, 0.0001)
            
            let resolvedAnchor: UnitPoint = axis == .vertical ? .bottom : .trailing
            
            if uniform {
                return effect.scaleEffect(
                    x: scale,
                    y: scale,
                    anchor: resolvedAnchor
                )
            } else {
                return effect.scaleEffect(
                    x: axis == .horizontal ? scale : 1,
                    y: axis == .vertical ? scale : 1,
                    anchor: resolvedAnchor
                )
            }
        }
    }

}

The result is a native, scroll-aware stretch effect that integrates seamlessly with SwiftUI’s rendering pipeline.

iOS 13+ Backport

For earlier system versions, the same public API is preserved while the internal implementation adapts to the available tools. Geometry information is captured using SwiftUI’s layout system and normalized so that the stretch behavior remains visually consistent.

extension View {    
    func stretchableViewBackported(
        axis: Axis = .vertical,
        uniform: Bool = false,
    ) -> some View {
        modifier(StretchyModifier(axis: axis, uniform: uniform))
    }
}
private struct StretchyMetrics: Equatable {
    var minX: CGFloat
    var minY: CGFloat
    var width: CGFloat
    var height: CGFloat
    
    static let zero = Self(minX: 0, minY: 0, width: 0, height: 0)
}

private struct StretchyMetricsKey: @MainActor PreferenceKey {
    @MainActor static var defaultValue: StretchyMetrics = .zero
    static func reduce(value: inout StretchyMetrics, nextValue: () -> StretchyMetrics) {
        value = nextValue()
    }
}

private struct StretchyModifier: ViewModifier {
    let axis: Axis
    let uniform: Bool

    @State private var metrics: StretchyMetrics = .zero
    @State private var baseline: CGFloat? = nil

    func body(content: Content) -> some View {
        let rawOffset: CGFloat = (axis == .vertical) ? metrics.minY : metrics.minX
        let currentLength: CGFloat = (axis == .vertical) ? metrics.height : metrics.width

        let base = baseline ?? rawOffset
        let normalizedOffset = rawOffset - base

        let positiveOffset = max(0, normalizedOffset)

        let safeLength = max(currentLength, 0.0001)
        let scale = (currentLength + positiveOffset) / safeLength

        let anchor: UnitPoint = (axis == .vertical) ? .bottom : .trailing

        return content
            .scaleEffect(
                x: uniform ? scale : (axis == .horizontal ? scale : 1),
                y: uniform ? scale : (axis == .vertical ? scale : 1),
                anchor: anchor
            )
            .background(
                GeometryReader { geo in
                    let frame = geo.frame(in: .global)
                    Color.clear.preference(
                        key: StretchyMetricsKey.self,
                        value: .init(
                            minX: frame.minX,
                            minY: frame.minY,
                            width: geo.size.width,
                            height: geo.size.height
                        )
                    )
                }
            )
            .onPreferenceChange(StretchyMetricsKey.self) { newValue in
                metrics = newValue
                if baseline == nil {
                    baseline = (axis == .vertical) ? newValue.minY : newValue.minX
                }
            }
    }
}

From the caller’s perspective, there is no difference in usage. The modifier automatically selects the appropriate implementation based on the OS version at runtime.

public extension View {
    /// Stretch when the scroll view is pulled past its start edge (top/leading).
    /// - Parameters:
    ///   - axis: .vertical for vertical ScrollView, .horizontal for horizontal ScrollView
    ///   - uniform: if true, scales both axes. If false, scales only along `axis`.
    @ViewBuilder
    func stretchable(
        axis: Axis = .vertical,
        uniform: Bool = true
    ) -> some View {
        if #available(iOS 17.0, *) {
            stretchableView(axis: axis, uniform: uniform)
        } else {
            stretchableViewBackported(axis: axis, uniform: uniform)
        }
    }
}

Applying the Modifier in Practice

Stretchy Header in a Vertical Scroll View

A common pattern is a header that expands when the user pulls down at the top of the list:

ScrollView {
    VStack(spacing: 0) {
        HeaderView()
            .frame(height: 150)
            .stretchable(axis: .vertical, uniform: true)

        ContentView()
    }
}

As the scroll view is pulled downward, the header scales from its bottom edge, creating a natural stretch effect.

Horizontal Stretch in a Carousel

The same modifier can be used in horizontal layouts:

ScrollView(.horizontal) {
    HStack {
        HeaderView()
        .frame(height: 150)
            .stretchable(axis: .horizontal, uniform: false)

        ContentView()
    }
}

Here, the view stretches when the scroll view is pulled past its leading edge, which works well for horizontally scrolling feature sections.

Design Characteristics

This component follows a few clear principles:

  • Stretching activates only when pulling past the start edge of the scroll view
  • Regular scrolling does not trigger scaling
  • The scaling anchor matches the scroll direction for a natural visual result
  • The API remains stable and identical across iOS versions

Conclusion

Stretch effects are a small detail, but they contribute significantly to the overall feel of an interface. By encapsulating this behavior in a focused SwiftUI modifier, you can apply it consistently across your app while supporting a wide range of iOS versions.

With native support on iOS 17+ and a backported implementation for iOS 13+, this approach provides a practical balance between modern APIs and broad platform compatibility — without complicating your view hierarchy or public API.