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?
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).