I am trying to create a similar animation to the Apple TV app - specifically this animation
Here are just some screenshots of the different states of this transition
1 - No title, a back button, add button and share button in white color
2 - After a certain point of scrolling, we can see the color of the buttons in the navigation bar changing
3 - After scrolling some more, a title appears in the nav bar, the buttons change color and the nav bar itself becomes translucent
How can such an animation be achieved in SwiftUI ?
The only thing I can think of at the moment would be to use a UIScrollView within swiftUI so that we can make use of the delegates it offers. However, even beyond this, I am uncertain of how to achieve such animations in UIKit as well.
However, how can we apply these different navigation bar animations in SwiftUI of the:
I have a similar thing trying to replicate the Spotify album view where I have a Sticky Header that fades out after the user scrolls up. I have modified it a bit to give you an idea on how to reach the translucency effect. You can use the Material.ultraThinMaterial or similar materials from that struct. Here' some documentation from Apple:
extension ShapeStyle where Self == Material {
/// A material that's somewhat translucent.
public static var regularMaterial: Material { get }
/// A material that's more opaque than translucent.
public static var thickMaterial: Material { get }
/// A material that's more translucent than opaque.
public static var thinMaterial: Material { get }
/// A mostly translucent material.
public static var ultraThinMaterial: Material { get }
/// A mostly opaque material.
public static var ultraThickMaterial: Material { get }
}
And now to the fun part, the stikcy header. My code will work on iOS 16+. I also added some comment to indicate you were to modify header colors. As you can see the back arrow becomes blue after a certain point. This was just to give you an idea. Code:
struct Home: View {
// MARK: - PROPERTIES
var safeArea: EdgeInsets
var size: CGSize
var body: some View {
ScrollView(.vertical) {
VStack {
/// Album Pic
ArtWork()
GeometryReader { proxy in
/// Since we ignored top edge
let minY = proxy.frame(in: .named("SCROLL")).minY - safeArea.top
Button(action: {}, label: {
Text("SHUFFLE PLAY")
.font(.callout)
.fontWeight(.semibold)
.foregroundStyle(.white)
.padding(.horizontal, 45)
.padding(.vertical, 12)
.background {
Capsule()
.fill(.spotifyGreen.gradient)
}
}) //: BUTTON SHUFFLE PLAY
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: minY < 50 ? -(minY - 50) : 0)
} //: GEOMETRY
.frame(height: 50)
.padding(.top, -34)
.zIndex(1)
VStack {
Text("Popular")
.fontWeight(.heavy)
/// Album View
AlbumView()
} //: VSTACK
.padding(.top, 10)
.zIndex(0)
} //: VSTACK
.overlay(alignment: .top) {
HeaderView()
}
} //: SCROLL
.scrollIndicators(.hidden)
.coordinateSpace(name: "SCROLL")
}
// MARK: - Views
@ViewBuilder
private func ArtWork() -> some View {
let randomListens = Int.random(in: 5_000_000...30_000_000)
let height = size.height * 0.45
GeometryReader { proxy in
let size = proxy.size
let minY = proxy.frame(in: .named("SCROLL")).minY
let progress = minY / (height * (minY > 0 ? 0.5 : 0.8))
Image(.myloxyloto)
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height + (minY > 0 ? minY : 0))
.clipped()
.overlay {
ZStack {
/// Gradient Overlay
Rectangle()
.fill(
.linearGradient(colors: [
.black.opacity(0 - progress),
.black.opacity(0.1 - progress),
.black.opacity(0.3 - progress),
.black.opacity(0.5 - progress),
.black.opacity(0.8 - progress),
.black.opacity(1),
], startPoint: .top, endPoint: .bottom)
)
VStack(spacing: 0) {
Text("Mylo Xyloto")
.font(.system(size: 45))
.fontWeight(.bold)
.multilineTextAlignment(.center)
Text("Coldplay")
.font(.callout.bold())
.padding(.top, 15)
Text("\(randomListens) Monthly Listners")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.gray)
//.padding(.top, 15)
} //: VSTACK
.opacity(1 + (progress > 0 ? -progress : progress))
/// Moving with ScrollView
.padding(.bottom, 55)
.offset(y: minY < 0 ? minY : 0)
} //: ZSTACK
} //: Gradient Overlay
.offset(y: -minY)
} //: GEOMETRY
.frame(height: height + safeArea.top)
}
@ViewBuilder
private func AlbumView() -> some View {
VStack(spacing: 25) {
ForEach(songs.indices, id: \.self) { index in
HStack(spacing: 25) {
let randomListens = Int.random(in: 200_000...2_000_000)
Text("\(index + 1)")
.font(.callout)
.fontWeight(.semibold)
.foregroundStyle(.gray)
VStack(alignment: .leading, spacing: 8) {
Text(songs[index].songName)
.fontWeight(.semibold)
.foregroundStyle(.white)
Text("\(randomListens)")
.font(.caption)
.foregroundStyle(.gray)
} //: VSTACK
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "ellipsis")
.foregroundStyle(.gray)
}
} //: LOOP Songs
} //: VSTACK
.padding(15)
}
/// Header View
@ViewBuilder
func HeaderView() -> some View {
GeometryReader { proxy in
let minY = proxy.frame(in: .named("SCROLL")).minY
let height = size.height * 0.45
let progress = minY / (height * (minY > 0 ? 0.5 : 0.8))
let titleProgress = minY / height
HStack(spacing: 15) {
Button(action: {}, label: {
Image(systemName: "chevron.left")
.font(.title3)
/// You could use titleprogress to apply different styles based on the scroll position
.foregroundStyle(-titleProgress < 0.75 ? .white : .blue)
}) //: Back Button
Spacer(minLength: 0)
Button(action: {}, label: {
Text("FOLLOWING")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.overlay(
Capsule()
.stroke(style: .init(lineWidth: 1))
.foregroundStyle(.white)
)
}) //: Follow Button
.opacity(1 + progress)
Button(action: {}, label: {
Image(systemName: "ellipsis")
.font(.title3)
.foregroundStyle(.white)
}) //: Back Button
} //: HSTACK
.overlay {
Text("Coldplay")
.fontWeight(.semibold)
/// Choose where to display the title
.offset(y: -titleProgress > 0.75 ? 0 : 45)
.clipped()
.animation(.easeInOut(duration: 0.25),
value: -titleProgress > 0.75 ? 0 : 45)
}
.padding(.top, safeArea.top + 10)
.padding([.horizontal, .bottom], 15)
.background {
//Color.black
Rectangle()
/// Apply Material effects here for translucency or similar stuff
.fill(Material.ultraThinMaterial)
.opacity(-progress > 1 ? 1 : 0)
}
.offset(y: -minY)
} //: GEOMETRY
.frame(height: 35)
}
}
Apart from missing images and some color it should work out of the box. You can use it like this:
GeometryReader { proxy in
let safeArea = proxy.safeAreaInsets
let size = proxy.size
Home(safeArea: safeArea, size: size)
.ignoresSafeArea(.container, edges: [.top])
}
.preferredColorScheme(.dark)
Result:
Let me know if this can work for you!