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.
