ScrollViews Part 2

In this code-along we will continue to extend our app we created in “ScrollViews Part 1”. We will create the parallax effect similar to the presentation at the WWDC24 (Create custom visual effects with SwiftUI)) and will also add our own spin to it.

At the end it will look the following:

Let’s get started!

Step 1: Open your previous project

If not yet open, open your project we created in the previous code-along “ScrollViews Part 1”.

Step 2: Create a parallax effect

Create a new SwiftUI file. Go to File - New - File from Template... or simply press cmd + N, choose iOS Template and here SwiftUI View, click Next, choose ParallaxView as the name of the new view, ensure that DifferentScrollViews is selected under Group, check the tick box for Targets: DifferentScrollViews, click Create.

You are already familiar how to define the animals – please insert before the body:

Swift
let animals = Animal.all

Next, we want to add a title. Therefore, In body we create a VStack with:

Swift
VStack {
	Text("Parallax Effect")
		.font(.title.bold())
}

Below this Text-View we add our well-known ScrollView, LazyHStack and ForEach-Loop which creates a regular scroll view:

Swift
ScrollView(.horizontal) {
	LazyHStack(spacing: 18) {
		ForEach(animals) { animal in
			VStack {
				AnimalPhoto(animal: animal)

				Text(animal.imageName.capitalized)
					.font(.title2)
			}
		}
	}
}
.contentMargins(36)
.scrollTargetBehavior(.paging)
.frame(height: 350)

This code we discussed in the previous code-along. The new .contentMargins(36) modifier adds padding around the content inside the ScrollView. It applies a margin (spacing) of 36 points around the entire content inside the ScrollView. It affects the spacing between the edges of the ScrollView and its internal content (LazyHStack). It prevents images from being too close to the screen edges.

Then add the .scrollTranistion modifier to the AnimalPhoto:

Swift
AnimalPhoto(animal: animal)
		.scrollTransition(axis: .horizontal) { content, phase in
			content
				.offset(x: phase.isIdentity ? 0 : phase.value * -200)
		}

.scrollTransition(axis: .horizontal) modifies the position of the image dynamically based on the scroll progress. .scrollTransition() moves the images along the X-axis. The phase object contains phase.isIdentity which is true when the item is centered on the screen, and a phase.value which is between -1.0 and 1.0, depending on how far the item is from the center.

The .offset(x: phase.isIdentity ? 0 : phase.value * -200) creates an offset if the image is not in the center:

  • When the item is centered (phase.isIdentity == true): The offset is 0, i.e. there is no shift of the image.
  • When the item moves left or right (phase.value ≠ 0): The offset is calculated using phase.value * -200: Moves left when scrolling right (phase.value > 0), moves right when scrolling left (phase.value < 0). The -200 multiplier makes the offset stronger. This makes images move in the opposite direction of the scroll, creating a parallax effect.

To make the parallax effect work, we need to add two modifiers: containerRelativeFrame() and .clipShape():

Swift
AnimalPhoto(animal: animal)
	.scrollTransition(axis: .horizontal) { content, phase in
		content
			.offset(x: phase.isIdentity ? 0 : phase.value * -200)
	}
	.containerRelativeFrame(.horizontal)
	.clipShape(RoundedRectangle(cornerRadius: 12))
	.padding(.bottom, 12)

.containerRelativeFrame(.horizontal) ensures that the item takes the full horizontal space inside the scroll view. It ensures that each AnimalPhoto view spans exactly the width of its container’s scroll space — like a carousel “page”.

The .clipShape() modifier clips the view to a specific shape, in this case, a rounded rectangle with a corner radius of 12. It applies a rounded rectangle mask and forces the view to only display inside this rounded rectangle. Any parts outside the shape are cut off (clipped). Without .clipShape()but with .containerRelativeFrame, the .scrollTransition offset moves the content beyond its frame — it literally slides part of it out of bounds. So you need .clipShape(...) to chop off those parts, or else the “sliding” part will visually overlap the neighboring item.The .clipShape()modifier ensures that only the visible part of each image remains within bounds.

Finally, we add the .scrollTransition() to the Text view as well so that it creates the parallax effect in sync with the image.

To double-check, the ScrollView should look like the following:

Swift
	ScrollView(.horizontal) {
		LazyHStack(spacing: 18) {
			ForEach(animals) { animal in
				VStack {
					AnimalPhoto(animal: animal)
						.scrollTransition(axis: .horizontal) { content, phase in
							content
								.offset(x: phase.isIdentity ? 0 : phase.value * -200)
					}
					.containerRelativeFrame(.horizontal)
					.clipShape(RoundedRectangle(cornerRadius: 12))
					.padding(.bottom, 12)

					Text(animal.imageName.capitalized)
						.font(.title2)
						.scrollTransition(axis: .horizontal) { content, phase in
							content
								.opacity(phase.isIdentity ? 1 : 0)
								.offset(x: phase.value * 100)
						}
				}
			}
		}
	}
	.contentMargins(36)
	.scrollTargetBehavior(.paging)
	.frame(height: 350)

We can make the parallax effect more pronounced so that it looks like the images are lying directly over each other:

Swift
GeometryReader { geometry in

	let screenWidth = geometry.size.width

	VStack {
		ScrollView(.horizontal, showsIndicators: false) {
			LazyHStack(spacing: 0) {
				ForEach(animals) { animal in
					GeometryReader { proxy in
						let scrollOffset = proxy.frame(in: .global).minX
						VStack(spacing: 8) {
							AnimalPhoto(animal: animal)
								.frame(width: screenWidth)
								.offset(x: -scrollOffset)
								.containerRelativeFrame(.horizontal)
								.clipShape(Rectangle())
								
							Text(animal.imageName.capitalized)
								.font(.title2)
								.scrollTransition(axis: .horizontal) { content, phase in
									content
										.opacity(phase.isIdentity ? 1 : 0)
										.offset(x: -scrollOffset)
								}
						}
					}
					.frame(width: screenWidth)
				}
			}
			.scrollTargetLayout()
		}
		.scrollTargetBehavior(.paging)
	}
}

If you wrap the above ScrollView and the GeometryReader in a VStack, then you can directly compare both views.

Step 3: Including the ParallaxView in our TabView

You have already seen in the first part of the ScrollView code-along how a TabViewworks.

In ContentView insert in the TabView closure below the SimpleTabView()closure:

Swift
ParallaxView()
	.tabItem {
		Image(systemName: "photo")
		Text("Parallax")
	}

You can of course use any other SF Symbol image. We have chosen the photo Image.

In our app, we got 3 tabs with all the views we created earlier.

Step 4: Run your app

Now that your app layout is polished, it’s time to see it in action!

You see already a preview of your app on the right-hand side in the canvas. To run your app in the simulator, please select a device at the very top-middle of the XCode window – you can also add your own physical device – and press the Play button.

Congratulations!

You’ve successfully built 3 different ScrollViews! 🎉

What you have learned

In this code-along, you’ve learned how to

  • create a horizontal ScrollView using LazyHStack and ForEach.
  • apply a parallax effect using the .scrollTransition modifier and phase.value.
  • use .containerRelativeFrame(.horizontal) to align each item with the scroll container’s bounds.
  • use .clipShape() to mask overflow caused by scroll transitions.

That’s a wrap!

Keep learning, keep building, and let your curiosity guide you. Happy coding! ✨

“Stay hungry. Stay foolish.” — Steve Jobs


Download the full project on GitHub: https://github.com/swiftandcurious/DifferentScrollViews

Watch the full code-along

You can watch the full code-along (part 1 and 2) on YouTube: https://youtu.be/VJLLLYyluxE