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
:
let animals = Animal.all
Next, we want to add a title. Therefore, In body
we create a VStack
with:
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:
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
:
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 is0
, i.e. there is no shift of the image. - When the item moves left or right (
phase.value ≠ 0
): The offset is calculated usingphase.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()
:
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:
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:
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 TabView
works.
In ContentView
insert in the TabView
closure below the SimpleTabView()
closure:
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
usingLazyHStack
andForEach
. - apply a parallax effect using the
.scrollTransition
modifier andphase.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