|
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") |
|
} |
|
} |
|
} |
|
} |
|
} |