ScrollView
Definition
ScrollView is a scrollable container in SwiftUI that allows users to scroll through content that exceeds the available screen space. It supports both vertical and horizontal scrolling with various customization options.
Basic Syntax
ScrollView {
// Content that can be scrolled
VStack {
ForEach(0..<50, id: \.self) { index in
Text("Item \(index)")
.padding()
}
}
}
Scroll Directions
// Vertical scrolling (default)
ScrollView(.vertical) {
VStack {
ForEach(0..<20, id: \.self) { index in
Rectangle()
.fill(Color.blue.opacity(0.7))
.frame(height: 100)
.overlay(Text("Row \(index)"))
}
}
}
// Horizontal scrolling
ScrollView(.horizontal) {
HStack {
ForEach(0..<20, id: \.self) { index in
Rectangle()
.fill(Color.green.opacity(0.7))
.frame(width: 150, height: 100)
.overlay(Text("Col \(index)"))
}
}
}
// Both directions
ScrollView([.vertical, .horizontal]) {
LazyVStack {
ForEach(0..<10, id: \.self) { row in
LazyHStack {
ForEach(0..<10, id: \.self) { col in
Rectangle()
.fill(Color.purple.opacity(0.7))
.frame(width: 100, height: 100)
.overlay(Text("\(row),\(col)"))
}
}
}
}
}
Show/Hide Scroll Indicators
// Hide scroll indicators
ScrollView {
LazyVStack {
ForEach(0..<100, id: \.self) { index in
Text("Hidden indicators \(index)")
.padding()
}
}
}
.scrollIndicators(.hidden)
// Show only vertical indicators
ScrollView([.vertical, .horizontal]) {
// Content
}
.scrollIndicators(.visible, axes: .vertical)
Scroll Position Control (iOS 17+)
struct ScrollPositionExample: View {
@State private var scrollPosition: Int? = nil
var body: some View {
VStack {
ScrollView {
LazyVStack {
ForEach(0..<100, id: \.self) { index in
Text("Item \(index)")
.font(.title2)
.frame(maxWidth: .infinity, minHeight: 50)
.background(Color.blue.opacity(0.1))
.id(index)
}
}
}
.scrollPosition(id: $scrollPosition)
HStack {
Button("Scroll to Top") {
withAnimation {
scrollPosition = 0
}
}
Button("Scroll to Middle") {
withAnimation {
scrollPosition = 50
}
}
Button("Scroll to Bottom") {
withAnimation {
scrollPosition = 99
}
}
}
.padding()
}
}
}
Lazy Loading
// LazyVStack for vertical content
ScrollView {
LazyVStack(spacing: 10) {
ForEach(0..<1000, id: \.self) { index in
HStack {
AsyncImage(url: URL(string: "https://picsum.photos/60/60?random=\(index)")) { image in
image.resizable()
} placeholder: {
Color.gray
}
.frame(width: 60, height: 60)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("Item \(index)")
.font(.headline)
Text("Description for item \(index)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal)
.onAppear {
print("Item \(index) appeared")
}
}
}
}
// LazyHStack for horizontal content
ScrollView(.horizontal) {
LazyHStack(spacing: 10) {
ForEach(0..<100, id: \.self) { index in
VStack {
Circle()
.fill(Color.orange)
.frame(width: 80, height: 80)
.overlay(Text("\(index)"))
Text("Item \(index)")
.font(.caption)
}
.onAppear {
print("Horizontal item \(index) loaded")
}
}
}
.padding(.horizontal)
}
Custom Scroll Behavior
struct CustomScrollView: View {
@State private var scrollOffset: CGFloat = 0
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<50, id: \.self) { index in
Rectangle()
.fill(Color.blue.opacity(0.7))
.frame(height: 100)
.overlay(
Text("Item \(index)")
.foregroundColor(.white)
)
.scaleEffect(1.0 - abs(scrollOffset) * 0.001)
}
}
.background(
GeometryReader { geometry in
Color.clear
.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named("scroll")).origin.y
)
}
)
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
scrollOffset = value
}
}
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
Paging ScrollView
struct PagingScrollView: View {
let colors: [Color] = [.red, .blue, .green, .orange, .purple]
var body: some View {
GeometryReader { geometry in
ScrollView(.horizontal) {
HStack(spacing: 0) {
ForEach(0..<colors.count, id: \.self) { index in
Rectangle()
.fill(colors[index])
.frame(width: geometry.size.width, height: geometry.size.height)
.overlay(
Text("Page \(index + 1)")
.font(.largeTitle)
.foregroundColor(.white)
)
}
}
}
.scrollTargetBehavior(.paging) // iOS 17+
}
}
}
Pull to Refresh
struct RefreshableScrollView: View {
@State private var items = Array(0..<20)
@State private var isLoading = false
var body: some View {
NavigationView {
ScrollView {
LazyVStack {
ForEach(items, id: \.self) { item in
HStack {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
Text("Item \(item)")
Spacer()
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
.padding(.horizontal)
}
}
}
.refreshable {
await refreshData()
}
.navigationTitle("Refreshable List")
}
}
private func refreshData() async {
isLoading = true
// Simulate network call
try? await Task.sleep(nanoseconds: 2_000_000_000)
items = Array(0..<Int.random(in: 15...25))
isLoading = false
}
}
Nested ScrollViews
struct NestedScrollExample: View {
var body: some View {
ScrollView {
VStack(spacing: 20) {
Text("Main Content")
.font(.title)
.padding()
// Horizontal scroll section
VStack(alignment: .leading) {
Text("Categories")
.font(.headline)
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 15) {
ForEach(["Food", "Travel", "Tech", "Sports", "Music"], id: \.self) { category in
Text(category)
.padding()
.background(Color.blue.opacity(0.2))
.cornerRadius(20)
}
}
.padding(.horizontal)
}
}
// More main content
ForEach(0..<10, id: \.self) { index in
VStack(alignment: .leading) {
Text("Section \(index)")
.font(.headline)
Text("Content for section \(index). This is some longer text that describes the content of this particular section.")
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
.padding(.horizontal)
}
}
}
}
}
ScrollView with Sticky Headers
struct StickyHeaderScrollView: View {
var body: some View {
ScrollView {
LazyVStack(pinnedViews: [.sectionHeaders]) {
ForEach(0..<5, id: \.self) { section in
Section {
ForEach(0..<10, id: \.self) { item in
HStack {
Text("Item \(item)")
Spacer()
Text("Section \(section)")
.foregroundColor(.secondary)
}
.padding()
.background(Color.white)
}
} header: {
Text("Section \(section)")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.gray.opacity(0.9))
}
}
}
}
}
}
Performance Optimization
struct OptimizedScrollView: View {
let items = Array(0..<10000)
var body: some View {
ScrollView {
// Use LazyVStack for better performance with large datasets
LazyVStack(spacing: 10) {
ForEach(items, id: \.self) { item in
OptimizedRow(item: item)
.onAppear {
// Load data when item appears
loadDataIfNeeded(for: item)
}
.onDisappear {
// Clean up resources when item disappears
cleanupIfNeeded(for: item)
}
}
}
}
}
private func loadDataIfNeeded(for item: Int) {
// Implement lazy loading logic
}
private func cleanupIfNeeded(for item: Int) {
// Implement cleanup logic
}
}
struct OptimizedRow: View {
let item: Int
var body: some View {
HStack {
Circle()
.fill(Color.blue)
.frame(width: 40, height: 40)
Text("Item \(item)")
Spacer()
}
.padding(.horizontal)
}
}
Custom Scroll Effects
struct ParallaxScrollView: View {
var body: some View {
ScrollView {
VStack(spacing: 0) {
// Hero section with parallax effect
GeometryReader { geometry in
Image(systemName: "mountain.2.fill")
.font(.system(size: 100))
.foregroundColor(.green)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
LinearGradient(
gradient: Gradient(colors: [.blue, .green]),
startPoint: .top,
endPoint: .bottom
)
)
.offset(y: geometry.frame(in: .global).minY * 0.5)
}
.frame(height: 300)
// Content
LazyVStack {
ForEach(0..<50, id: \.self) { index in
Text("Content item \(index)")
.frame(maxWidth: .infinity, minHeight: 80)
.background(Color.white)
}
}
}
}
.ignoresSafeArea(edges: .top)
}
}
Best Practices
- Use LazyVStack/LazyHStack: For large datasets to improve performance
- Avoid nested ScrollViews: Can cause scroll conflicts and poor UX
- Consider List: For simple data display, List might be more appropriate
- Handle safe areas: Use proper padding and safe area handling
- Test performance: Monitor performance with large datasets
- Use appropriate spacing: Maintain consistent spacing throughout
- Provide loading states: Show progress for async content loading
Common Use Cases
- Content feeds and timelines
- Image galleries and carousels
- Long form content and articles
- Product catalogs and grids
- Settings and form screens
- Custom layouts requiring scrolling
- Onboarding and tutorial flows