iosswiftanimationswiftui

Image and background animation not synchronized when toggling state


I'm working on a SwiftUI view that has an expanding/collapsing button in the top-right corner of the screen. The button is an Image with a rounded background. When I toggle the state with .onTapGesture, I want both the Image and its background to animate together smoothly.

However, the problem is that the icon changes before the background animates, making the transition feel inconsistent or "out of sync". This becomes especially noticeable when viewed in slow motion or as a screen recording.

Here's a simplified version of the code:

import SwiftUI

struct ContentView: View {
    @Environment(\.dismiss) var dismiss
    @State var isExpanded = false
    
    var body: some View {
        NavigationStack {
            Color.red
                .toolbar(isExpanded ? .visible : .hidden)
                .overlay(alignment: .topTrailing) {
                    Image(systemName: isExpanded ? "chevron.up.circle" : "chevron.down.circle")
                        .padding()
                        .background {
                            RoundedRectangle(cornerRadius: 20)
                                .fill(Color.gray.opacity(0.5))
                        }
                        .padding()
                        .onTapGesture {
                            isExpanded.toggle()
                        }
                        .animation(.default, value: isExpanded)
                }
                .toolbar {
                    ToolbarItem(placement: .navigation) {
                        Button {
                            dismiss()
                        } label: {
                            Image(systemName: "chevron.backward")
                        }
                    }
                }
        }
    }

}

#Preview {
    ContentView()
}

How can I make the icon (Image) and the background animate in sync, so that their transition feels smooth and unified?

enter image description here

enter image description here


Solution

  • First you have to get the navigation bar animated. You can use the following modifier, which is adapted from the solution in this question,

    public extension View {
        func navigationBar(visible: Bool) -> some View {
            modifier(NavigationBarVisibleViewModifier(visible: visible))
        }
    }
    
    private struct NavigationBarVisibleViewModifier: ViewModifier {
        let visible: Bool
        @State private var isAnimatingNavigationBar = false
        @State private var visibility: Visibility = .visible
    
        func body(
            content: Content
        ) -> some View {
            content
                .toolbarVisibility(visibility, for: .navigationBar)
                .onChange(of: visible) { _, newValue in
                    isAnimatingNavigationBar = true
                    visibility = newValue ? .visible : .hidden
                }
                .transaction { transaction in
                    if isAnimatingNavigationBar {
                        transaction.animation = .default
                        transaction.disablesAnimations = true
                        
                        DispatchQueue.main.async {
                            isAnimatingNavigationBar = false
                        }
                    }
                }
                .onAppear {
                    self.visibility = visible ? .visible : .hidden
                }
        }
    }
    

    Put this modifier after the .animation modifier, then add a .geometryGroup to the Image + .background.

    .overlay(alignment: .topTrailing) {
        Image(systemName: isExpanded ? "chevron.up.circle" : "chevron.down.circle")
            .padding()
            .background {
                RoundedRectangle(cornerRadius: 20)
                    .fill(Color.gray.opacity(0.5))
            }
            .padding()
            .onTapGesture {
                isExpanded.toggle()
            }
            .geometryGroup() // <------
    }
    .animation(.default, value: isExpanded)
    .navigationBar(visible: isExpanded) // <------
    

    Note that the animation used in the NavigationBarVisibleViewModifier should match the one used in the .animation modifier (both are .default in this case).