Skip to content

Instantly share code, notes, and snippets.

@flaksp
Last active March 10, 2024 12:49
Show Gist options
  • Save flaksp/4aa4422f76a740f84f652c908d202f9c to your computer and use it in GitHub Desktop.
Save flaksp/4aa4422f76a740f84f652c908d202f9c to your computer and use it in GitHub Desktop.
Hero Image Slider in SwiftUI with parallax effect

Hero Image Slider in SwiftUI with parallax effect

Hero image slider in SwiftUI that fills navigation bar (toolbar) and has parallax effect. This is a demo for education and references purposes. Video is below.

Screenshot 2024-03-08 at 15 11 53

The component consists of 2 layers:

  1. A background image. It expands beneath toolbar and "safe area". Its height is coverHeight + toolbar and safe area height. On overscrolling image zooms and maintains its aspect ratio producing parallax effect.
  2. A foreground image. Its container height is coverHeight.

Foreground images wrapped into paging TabView, and background images crossfade into each other when tab number changes.

Screen.Recording.2024-03-08.at.14.53.05-2.mov
import SwiftUI
struct HeroImageSlider: View {
public let coverHeight: CGFloat
public let imageUrls: [URL]
@State private var pageIndex = 0
private let screenWidth: CGFloat = UIScreen.main.bounds.width
private let cornerRadius: CGFloat = 12
private let shadowRadius: CGFloat = 8
private let frontImageAppearanceAnimationDuration: TimeInterval = 0.5
private let backgroundImageAppearanceAnimationDuration: TimeInterval = 0.8
var body: some View {
GeometryReader { geometry in
let navigationBarHeight = geometry.safeAreaInsets.top
let distanceToScreenTop = geometry.frame(in: .global).minY
let scrollDistance = distanceToScreenTop - navigationBarHeight
let backgroundImageHeight = (coverHeight + navigationBarHeight + max(0, scrollDistance))
let backgroundImageWidth = screenWidth
let backgroundImageOffset = min(-navigationBarHeight, (navigationBarHeight + scrollDistance) * -1)
let backgroundImagePadding = backgroundImageOffset
ZStack {
ForEach(imageUrls.indices, id: \.self) { index in
AsyncImage(
url: imageUrls[index],
transaction: .init(animation: .easeInOut(duration: backgroundImageAppearanceAnimationDuration)),
content: { phase in
switch phase {
case .empty:
EmptyView()
case let .success(image):
image
.resizable()
.scaledToFill()
case .failure:
EmptyView()
@unknown default:
EmptyView()
}
}
)
.visible(index == pageIndex)
.animation(.easeInOut(duration: backgroundImageAppearanceAnimationDuration), value: pageIndex)
}
.frame(
width: backgroundImageWidth,
height: backgroundImageHeight
)
}
.overlay(.thinMaterial)
.clipped()
.offset(y: backgroundImageOffset)
.padding(.bottom, backgroundImagePadding)
TabView(selection: $pageIndex) {
ForEach(imageUrls.indices, id: \.self) { index in
AsyncImage(
url: imageUrls[index],
transaction: .init(animation: .easeInOut(duration: frontImageAppearanceAnimationDuration)),
content: { phase in
switch phase {
case .empty:
ProgressView()
case let .success(image):
image
.resizable()
.scaledToFit()
.cornerRadius(cornerRadius)
.clipped()
.shadow(radius: shadowRadius)
.padding(.top, 46)
.padding(.leading, 46)
.padding(.trailing, 46)
.padding(.bottom, 60)
case .failure:
VStack {
Image(systemName: "photo")
.foregroundColor(.darkGray)
}
.frame(width: 150, height: 200)
.background(.ultraThinMaterial)
.cornerRadius(cornerRadius)
.clipped()
.shadow(radius: shadowRadius)
@unknown default:
EmptyView()
}
}
)
}
}
.tabViewStyle(.page)
.indexViewStyle(.page(backgroundDisplayMode: .always))
}
.frame(height: coverHeight)
}
}
#Preview {
NavigationStack {
ScrollView {
HeroImageSlider(coverHeight: 400, imageUrls: [
URL(string: "https://cdn.myanimelist.net/images/anime/1015/138006l.jpg")!,
URL(string: "https://cdn.myanimelist.net/images/anime/1197/139034l.jpg")!,
URL(string: "https://cdn.myanimelist.net/images/anime/1675/127908l.jpg")!,
URL(string: "https://cdn.myanimelist.net/images/anime/1531/139077l.jpg")!,
URL(string: "https://cdn.myanimelist.net/images/anime/1531/xxxx.jpg")!,
])
Text("Your content goes here")
}
.navigationTitle("Sousou no Frieren")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ShareLink(item: URL(string: "https://cdn.myanimelist.net/images/anime/1015/138006l.jpg")!) {
Label("Share", systemImage: "square.and.arrow.up")
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment