iosswiftswiftuiuikituinavigationbar

Animating navigation bar elements on scroll in SwiftUI


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:


Solution

  • 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!