Animating navigation bar elements on scroll in SwiftUI

191 Views Asked by At

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:

  • Navigation Style from opaque to translucent
  • The color of the different buttons
  • The navigation bar title
1

There are 1 best solutions below

5
MatBuompy On BEST ANSWER

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:

Sticky Header with material effect

Let me know if this can work for you!