Skip to content

Profile Header Stretchy

A profile whose cover stretches when the page is pulled down and closes into a bar with the name as it scrolls, while the avatar on its edge shrinks away.

Profile Header Stretchy, playing
Download .swift
Origin
Original work
Found by
profile header · stretchy header · cover image · avatar · collapsing header · pull to stretch · social profile · cabecera de perfil · cabecera elástica · imagen de portada · avatar · cabecera que se contrae · tirar para estirar · perfil social

A profile whose cover stretches when the page is pulled down and closes into a bar with the name as it scrolls, while the avatar on its edge shrinks away.

The cover and the avatar follow the scroll itself, so there is nothing to slow down for Reduce Motion. Put it at the top of a screen: the cover runs under the status bar.

struct ProfileHeaderStretchyDemo: View {
private let posts = ["Morning light over the bay", "Notes from the design review", "A slow Sunday in the hills", "Why I draw every day", "The best coffee in Lisbon", "Sketches for the next app", "Ten minutes of quiet", "What the sea taught me", "Small tools, big ideas", "Walking home at dusk"]
var body: some View {
ProfileHeaderStretchy(name: "Maya Okafor") {
LinearGradient(colors: [.orange, .pink, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)
.overlay {
Image(systemName: "mountain.2.fill")
.resizable()
.scaledToFit()
.foregroundStyle(.white.opacity(0.25))
.padding(.top, 60)
.accessibilityHidden(true)
}
} avatar: {
LinearGradient(colors: [.indigo, .teal], startPoint: .top, endPoint: .bottom)
.overlay {
Text("MO")
.font(.title.bold())
.foregroundStyle(.white)
}
.accessibilityLabel("Maya Okafor")
} content: {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) {
Text("Maya Okafor")
.font(.title2.bold())
Text("Designer. Draws, walks and writes about both.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack(spacing: 28) {
stat("128", "Posts")
stat("4.2K", "Followers")
stat("310", "Following")
}
Divider()
ForEach(posts, id: \.self) { post in
HStack(spacing: 14) {
RoundedRectangle(cornerRadius: 12)
.fill(.quaternary)
.frame(width: 56, height: 56)
.overlay {
Image(systemName: "photo")
.foregroundStyle(.secondary)
}
.accessibilityHidden(true)
Text(post)
.font(.body)
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 32)
}
}
private func stat(_ value: String, _ label: LocalizedStringResource) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(value)
.font(.headline)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
}
}
ParameterTypeDefault
nameStringRequired
coverHeightCGFloat190
cover() -> CoverRequired
avatar() -> AvatarRequired
content() -> ContentRequired

Copy this code into your project, or download the file.

ProfileHeaderStretchy.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum ProfileHeaderStretchyMath {
/// How tall the cover is for a scroll offset: it grows when the content is pulled down,
/// and shrinks with the scroll until it is the bar.
static func coverHeight(offset: CGFloat, expanded: CGFloat, collapsed: CGFloat) -> CGFloat {
max(expanded - offset, min(collapsed, expanded))
}
/// From 0, the whole cover, to 1, only the bar.
static func progress(offset: CGFloat, expanded: CGFloat, collapsed: CGFloat) -> Double {
let travel = expanded - collapsed
guard travel > 0 else { return 1 }
return Double(min(max(offset / travel, 0), 1))
}
/// The avatar gets smaller as the cover closes, and is gone a little before it is the bar.
static func avatarScale(progress: Double) -> Double {
1 - 0.45 * min(max(progress, 0), 1)
}
static func avatarOpacity(progress: Double) -> Double {
1 - min(max((progress - 0.55) / 0.35, 0), 1)
}
/// The name shows in the bar over the last stretch, once the avatar is gone.
static func titleOpacity(progress: Double) -> Double {
min(max((progress - 0.8) / 0.2, 0), 1)
}
}
/// A profile whose cover stretches when the page is pulled down and closes into a bar with the
/// name as it scrolls, while the avatar on its edge shrinks away.
///
/// The cover and the avatar follow the scroll itself, so there is nothing to slow down for
/// Reduce Motion. Put it at the top of a screen: the cover runs under the status bar.
struct ProfileHeaderStretchy<Cover: View, Avatar: View, Content: View>: View {
let name: String
var coverHeight: CGFloat = 190
@ViewBuilder var cover: () -> Cover
@ViewBuilder var avatar: () -> Avatar
@ViewBuilder var content: () -> Content
@State private var offset: CGFloat = 0
@ScaledMetric(relativeTo: .title) private var avatarSize = 88.0
private let barHeight: CGFloat = 44
private let margin: CGFloat = 20
var body: some View {
GeometryReader { proxy in
let collapsed = proxy.safeAreaInsets.top + barHeight
let progress = ProfileHeaderStretchyMath.progress(offset: offset, expanded: coverHeight, collapsed: collapsed)
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// Room for the cover, and for the half of the avatar that hangs under it.
Color.clear
.frame(height: coverHeight + avatarSize / 2 + 12)
content()
}
}
.onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y + $0.contentInsets.top } action: { _, new in
offset = new
}
.overlay(alignment: .top) {
header(height: ProfileHeaderStretchyMath.coverHeight(offset: offset, expanded: coverHeight, collapsed: collapsed), progress: progress)
}
.ignoresSafeArea(edges: .top)
}
}
private func header(height: CGFloat, progress: Double) -> some View {
cover()
.frame(maxWidth: .infinity)
.frame(height: height)
.clipped()
.overlay {
Rectangle()
.fill(.ultraThinMaterial)
.opacity(progress)
}
.overlay(alignment: .bottom) {
Text(name)
.font(.headline)
.lineLimit(1)
.frame(height: barHeight)
.padding(.horizontal, margin)
.opacity(ProfileHeaderStretchyMath.titleOpacity(progress: progress))
.accessibilityHidden(progress < 1)
}
.overlay(alignment: .bottomLeading) {
avatar()
.frame(width: avatarSize, height: avatarSize)
.clipShape(.circle)
.overlay { Circle().stroke(.background, lineWidth: 4) }
.scaleEffect(ProfileHeaderStretchyMath.avatarScale(progress: progress), anchor: .bottomLeading)
.opacity(ProfileHeaderStretchyMath.avatarOpacity(progress: progress))
.padding(.leading, margin)
// Half of it hangs under the cover.
.alignmentGuide(.bottom) { $0[VerticalAlignment.center] }
}
.allowsHitTesting(false)
}
}
// MARK: - Demo
struct ProfileHeaderStretchyDemo: View {
private let posts = ["Morning light over the bay", "Notes from the design review", "A slow Sunday in the hills", "Why I draw every day", "The best coffee in Lisbon", "Sketches for the next app", "Ten minutes of quiet", "What the sea taught me", "Small tools, big ideas", "Walking home at dusk"]
var body: some View {
ProfileHeaderStretchy(name: "Maya Okafor") {
LinearGradient(colors: [.orange, .pink, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)
.overlay {
Image(systemName: "mountain.2.fill")
.resizable()
.scaledToFit()
.foregroundStyle(.white.opacity(0.25))
.padding(.top, 60)
.accessibilityHidden(true)
}
} avatar: {
LinearGradient(colors: [.indigo, .teal], startPoint: .top, endPoint: .bottom)
.overlay {
Text("MO")
.font(.title.bold())
.foregroundStyle(.white)
}
.accessibilityLabel("Maya Okafor")
} content: {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) {
Text("Maya Okafor")
.font(.title2.bold())
Text("Designer. Draws, walks and writes about both.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack(spacing: 28) {
stat("128", "Posts")
stat("4.2K", "Followers")
stat("310", "Following")
}
Divider()
ForEach(posts, id: \.self) { post in
HStack(spacing: 14) {
RoundedRectangle(cornerRadius: 12)
.fill(.quaternary)
.frame(width: 56, height: 56)
.overlay {
Image(systemName: "photo")
.foregroundStyle(.secondary)
}
.accessibilityHidden(true)
Text(post)
.font(.body)
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 32)
}
}
private func stat(_ value: String, _ label: LocalizedStringResource) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(value)
.font(.headline)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
}
}
#Preview {
ProfileHeaderStretchyDemo()
}