What You Will Build
A vertical scrolling list of colored cards with a progress bar at the top that fills as you scroll. Each card also applies a subtle 3D rotation based on its position in the viewport. This combines two popular patterns: scroll-linked progress bars (common in article readers) and perspective card effects.
Why This Pattern Matters
Scroll progress indicators help users gauge how much content remains. Combined with 3D card rotations, this pattern makes lists feel dynamic and premium. The implementation teaches PreferenceKey, coordinateSpace, and GeometryReader — three foundational SwiftUI layout tools.
Key SwiftUI Concepts
- PreferenceKey passes scroll offset data from child to parent.
- .coordinateSpace(name:) establishes a reference frame for position calculations.
- .rotation3DEffect tilts cards based on their vertical position.
Step 1: Define a PreferenceKey for Scroll Offset
private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
Step 2: Build the Progress Bar
@State private var scrollProgress: CGFloat = 0
GeometryReader { _ in
Capsule()
.fill(.gray.opacity(0.2))
.overlay(alignment: .leading) {
Capsule()
.fill(.blue.gradient)
.frame(width: max(0, scrollProgress * UIScreen.main.bounds.width))
}
}
.frame(height: 4)
Step 3: Cards with 3D Rotation and Progress Tracking
ScrollView(.vertical) {
LazyVStack(spacing: 16) {
ForEach(0..<20, id: \.self) { index in
GeometryReader { proxy in
let minY = proxy.frame(in: .named("SCROLL_INDICATOR")).minY
RoundedRectangle(cornerRadius: 16)
.fill(colors[index % colors.count].gradient)
.overlay {
Text("Card \(index + 1)")
.font(.title2.bold())
.foregroundStyle(.white)
}
.rotation3DEffect(
.degrees(Double(minY - 100) / 10),
axis: (x: 1, y: 0, z: 0),
perspective: 0.5
)
}
.frame(height: 120)
}
}
.padding()
.background(
GeometryReader { proxy in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: proxy.frame(in: .named("SCROLL_INDICATOR")).minY
)
}
)
}
.coordinateSpace(name: "SCROLL_INDICATOR")
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
let totalHeight = CGFloat(20 * 136) - UIScreen.main.bounds.height
scrollProgress = min(max(-value / totalHeight, 0), 1)
}
Tips and Pitfalls
- Total height calculation: Each card is 120pt + 16pt spacing = 136pt. Multiply by the item count and subtract the screen height to get the scrollable range.
- Clamp the progress: Use
min(max(..., 0), 1)to keep the progress bar between 0% and 100%, especially during bounce scrolling. - Perspective parameter: A value of 0.5 gives a natural perspective. Lower values create more dramatic distortion.
- Named coordinate spaces: Always use a unique string for
.coordinateSpace(name:)to avoid conflicts with other geometry readers. - Performance with LazyVStack: LazyVStack only creates views as they scroll into view. Never use a regular VStack for 20+ items.
iOS Version: iOS 15+