iosanimationswiftuimobile-applicationgeometryreader

Swiping screens inside a TabView with a GeometryReader doesn't apply the slide animation


I'm a beginner to SwiftUI and I'm experimenting with animations. The animation styling for my bottom tabs work as expected when I press on each icon as well as if I swipe on the screen to switch between the tabs. But when I try something similar for my top tabs, the slide animation for transitioning the selected background color doesn't work when I swipe between the nested screens, it only works when I press on the tab name (button).

I've found a post that's also having animation issues using GeometryReader but it doesn't seem to match my use case.

The only difference between my top and bottom tabs is that the top tabs use a GeometryReader, is this a common issues? Are there any workarounds for this problem? Any guidance on how to solve this is greatly appreciated.

demo

BottomTabs.swift

import SwiftUI;

struct BottomTabs: View {
    
    @Binding var tab: Int;
    
    var body: some View {
        
        HStack {
            
                
            Button(action: {

                withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {

                    tab = 0;

                }

            }) {
                
                VStack {
                    
                    Image(systemName: "baseball.fill").resizable()
                        .scaledToFit()
                        .frame(width: 16, height: 16)
                        .foregroundStyle(

                            LinearGradient(colors: [(tab == 0) ? .green : .white, (tab == 0) ? .purple : .white],
                                           startPoint: .top, endPoint: .bottom)
                            
                        ).scaleEffect((tab == 0) ? 1.2 : 1)
                         .animation(.easeInOut(duration: 0.3), value: tab);
                    
                    Circle()
                        .fill(.yellow)
                        .frame(width: (tab == 0) ? 5 : 0, height: (tab == 0) ? 5 : 0)
                        .padding(.top, (tab == 0) ? 2 : 0)
                        .opacity((tab == 0) ? 1 : 0)
                        .animation(.easeInOut(duration: 0.3), value: tab);
                    
                }

            }.padding(.horizontal, 10)
                            
            Button(action: {
                
                withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                    
                    tab = 1;
                    
                }
                
            }) {
                
                VStack {
                    
                    Image(systemName: "heart.fill").resizable()
                        .scaledToFit()
                        .frame(width: 16, height: 16)
                        .foregroundStyle(
                            
                            LinearGradient(colors: [(tab == 1) ? .green : .white, (tab == 1) ? .purple : .white],
                                           startPoint: .top, endPoint: .bottom)
                            
                        )
                        .scaleEffect((tab == 1) ? 1.2 : 1)
                        .animation(.easeInOut(duration: 0.3), value: tab);
                    
                    Circle().fill(.yellow)
                        .frame(width: (tab == 1) ? 5 : 0, height: (tab == 1) ? 5 : 0)
                        .opacity((tab == 1) ? 1 : 0)
                        .padding(.top, (tab == 1) ? 2 : 0)
                        .animation(.easeInOut(duration: 0.3), value: tab);
                    
                }
                
            }.padding(.horizontal, 10)
                            
            Button(action: {
                
                withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                    
                    tab = 2;
                    
                }
                
            }) {
                
                VStack {
                    
                    Image(systemName: "basketball.fill").resizable()
                        .scaledToFit()
                        .frame(width: 16, height: 16)
                        .foregroundStyle(
                            
                            LinearGradient(colors: [(tab == 2) ? .green : .white, (tab == 2) ? .purple : .white],
                                           startPoint: .top, endPoint: .bottom)
                            
                        )
                        .scaleEffect((tab == 2) ? 1.2 : 1)
                        .animation(.easeInOut(duration: 0.3), value: tab);
                    
                    Circle().fill(.yellow)
                        .frame(width: (tab == 2) ? 5 : 0, height: (tab == 2) ? 5 : 0)
                        .opacity((tab == 2) ? 1 : 0)
                        .padding(.top, (tab == 2) ? 2 : 0)
                        .animation(.easeInOut(duration: 0.3), value: tab);
                    
                }
                    
            }.padding(.horizontal, 10)

        }.frame(height: 30)
            .padding(.vertical, 20)
            .padding(.horizontal, 30)
         .background(
            
            RoundedRectangle(cornerRadius: 20).fill(.black)
            
         ).padding(.bottom, 10);
        
    }
    
}

ContentView.swift

import SwiftUI;

struct ContentView: View {

    @State private var tab: Int = 0;
    @State private var tag: Int = 0;

    var body: some View {

        ZStack(alignment: .bottom) {

            TabView(selection: $tab) {

                NavigationStack {
                    
                    ZStack(alignment: .top) {

                        TabView(selection: $tag) {

                            ScrollView(showsIndicators: false) {
                                
                                LazyVStack {
                                    
                                    ForEach(0..<500, id: \.self) { i in
                                        
                                        Text("\(i + 1). TRENDING HOME SCREEN");
                                        
                                    }
                                    
                                }
                                
                            }.tag(0)
                                
                            ScrollView(showsIndicators: false) {
                                
                                LazyVStack {
                                    
                                    ForEach(0..<500, id: \.self) { i in
                                        
                                        Text("\(i + 1). FOLLOWING HOME SCREEN");
                                        
                                    }
                                    
                                }
                                
                            }.tag(1)
                                
                                
                            ScrollView(showsIndicators: false) {
                                
                                LazyVStack {
                                    
                                    ForEach(0..<500, id: \.self) { i in
                                        
                                        Text("\(i + 1). EVENTS HOME SCREEN");
                                        
                                    }
                                    
                                }
                                
                            }.tag(2)
                                                            
                    }.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))

                        .toolbar {
                            
                            ToolbarItemGroup(placement: .navigationBarLeading) {
                                
                                Button(action: {
                                    
                                    
                                    
                                }) {
                                    
                                    Image(systemName: "basketball.fill")
                                        .resizable()
                                        .scaledToFit()
                                        .frame(width: 50, height: 50)
                                        .foregroundColor(.black);
                                    
                                }
                                .padding(.top, 50)
                                .padding(.leading, 20);
                                
                            }
                            
                            ToolbarItemGroup(placement: .navigationBarTrailing) {
                                
                                Button(action: {
                                    
                                    
                                }) {
                                    
                                    Image(systemName: "baseball.fill")
                                        .resizable()
                                        .scaledToFit()
                                        .frame(width: 50, height: 50)
                                        .foregroundColor(.black);
                                    
                                }.padding(.top, 50)
                                    .padding(.trailing, 20);
                                
                            }
                            
                        }
                        .toolbarBackground(.hidden, for: .navigationBar)
                        
                        TopTabs(tab: $tag).padding(.top, 60)
                        
                    }
                    .ignoresSafeArea()

                }
                .tag(0)

                ScrollView(showsIndicators: false) {
                    
                    LazyVStack {
                        
                        ForEach(0..<1000, id: \.self) { i in
                            
                            Text("\(i + 1). LIKES SCREEN");
                            
                        }
                        
                    }
                    
                }.tag(1)
                
                ScrollView(showsIndicators: false) {
                    
                    LazyVStack {
                        
                        ForEach(0..<2000, id: \.self) { i in
                            
                            Text("\(i + 1). SPORTS SCREEN");
                            
                        }
                        
                    }
                    
                }.tag(2)
                
            }.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never));
            
            BottomTabs(tab: $tab);
            
        }
        .ignoresSafeArea()
        
    }
    
}

#Preview {
    
    ContentView();
    
}

TopTabs.swift

import SwiftUI

struct TopTabs: View {
    
    @Binding var tab: Int
    @Namespace private var animation
    
    var body: some View {
        GeometryReader { geometry in
            
            let containerWidth = geometry.size.width * 0.9
            let tabWidth = containerWidth / 3
            
            ZStack(alignment: .leading) {
                
                RoundedRectangle(cornerRadius: 50)
                    .fill(Color.yellow)
                    .frame(width: tabWidth, height: 40)
                    .matchedGeometryEffect(id: "tabBackground", in: animation)
                    .offset(x: CGFloat(tab) * tabWidth)
                
                HStack(spacing: 0) {
                    
                    Button(action: {
                        
                        withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                            
                            tab = 0
                            
                        }
                        
                    }) {
                        
                        Text("Trending")
                            .font(Font.custom("Gilroy-Medium", size: 14))
                            .foregroundColor(tab == 0 ? .black : .white)
                        
                    }
                    .frame(width: tabWidth, height: 40)
                    
                    Button(action: {
                        
                        withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                            
                            tab = 1
                            
                        }
                        
                    }) {
                        
                        Text("Following")
                            .font(Font.custom("Gilroy-Medium", size: 14))
                            .foregroundColor(tab == 1 ? .black : .white)
                        
                    }
                    .frame(width: tabWidth, height: 40)
                    
                    Button(action: {
                        
                        withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                            
                            tab = 2
                            
                        }
                        
                    }) {
                        
                        Text("Events")
                            .font(Font.custom("Gilroy-Medium", size: 14))
                            .foregroundColor(tab == 2 ? .black : .white)
                        
                    }
                    .frame(width: tabWidth, height: 40)
                    
                }
            }
            .frame(width: containerWidth, height: 40)
            .background(
                
                RoundedRectangle(cornerRadius: 50)
                    .fill(Color.gray)
                
            )
            .mask(RoundedRectangle(cornerRadius: 50))
            .padding(.top, 20)
            .frame(maxWidth: .infinity)
            
        }
        .transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.2)))
        
    }
    
}

Solution

  • To animate the change of tab when this is triggered by a swipe, you just need to add an .animation modifier to the HStack containing the tab buttons.

    However, there is a fundamental issue with the code that also needs to be resolved. You currently have an outer TabView which contains a NavigationStack which contains an inner TabView. A TabView inside a NavigationStack is not a configuration that Apple supports and when animation is added to the tab buttons, they do not work properly.

    As far as I can tell, the only reason for the NavigationStack is so that you can add a toolbar. So as an alternative approach, the buttons that you are adding as toolbar buttons can simply be added as another ZStack layer instead. Then the NavigationStack can be removed and everything works.

    Other points and suggestions:

    Here is an updated version of ContentView and TopTabs to show it all working. BottomTabs is unchanged.

    struct TopTabs: View {
        @Binding var tab: Int
        @Namespace private var animation
    
        struct TabButtonStyle: ButtonStyle {
            let tabIndex: Int
            let selectedTab: Int
            func makeBody(configuration: Configuration) -> some View {
                configuration.label
                    .font(Font.custom("Gilroy-Medium", size: 14))
                    .foregroundStyle(tabIndex == selectedTab ? .black : .white)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .contentShape(Rectangle())
            }
        }
    
        private func tabButton(label: String, tabIndex: Int) -> some View {
            Button(LocalizedStringKey(label)) {
                withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                    tab = tabIndex
                }
            }
            .buttonStyle(TabButtonStyle(tabIndex: tabIndex, selectedTab: tab))
            .matchedGeometryEffect(id: tabIndex, in: animation)
        }
    
        var body: some View {
            HStack(spacing: 0) {
                tabButton(label: "Trending", tabIndex: 0)
                tabButton(label: "Following", tabIndex: 1)
                tabButton(label: "Events", tabIndex: 2)
            }
            .background {
                Capsule()
                    .fill(.yellow)
                    .matchedGeometryEffect(id: tab, in: animation, isSource: false)
            }
            .frame(height: 40)
            .background(.gray, in: .capsule)
            .animation(.spring(response: 0.4, dampingFraction: 0.6), value: tab)
            .transition(.opacity.animation(.easeInOut(duration: 0.2)))
        }
    }
    
    struct ContentView: View {
        @State private var tab: Int = 0
        @State private var tag: Int = 0
    
        private func dummyContent(label: String, nLines: Int) -> some View {
            ScrollView(showsIndicators: false) {
                LazyVStack {
                    ForEach(0..<nLines, id: \.self) { i in
                        Text("\(i + 1). \(label)")
                    }
                }
            }
            .frame(maxWidth: .infinity)
            .contentShape(Rectangle())
        }
    
        var body: some View {
            GeometryReader { geometry in
                let topInsets = geometry.safeAreaInsets.top
                ZStack(alignment: .bottom) {
                    TabView(selection: $tab) {
                        ZStack(alignment: .top) {
                            TabView(selection: $tag) {
                                dummyContent(label: "TRENDING HOME SCREEN", nLines: 500)
                                    .tag(0)
    
                                dummyContent(label: "FOLLOWING HOME SCREEN", nLines: 500)
                                    .tag(1)
    
                                dummyContent(label: "EVENTS HOME SCREEN", nLines: 500)
                                    .tag(2)
                            }
                            .tabViewStyle(.page(indexDisplayMode: .never))
    
                            HStack {
                                Button {} label: {
                                    Image(systemName: "basketball.fill")
                                        .resizable()
                                        .scaledToFit()
                                        .frame(width: 50, height: 50)
                                }
                                Spacer()
                                Button {} label: {
                                    Image(systemName: "baseball.fill")
                                        .resizable()
                                        .scaledToFit()
                                        .frame(width: 50, height: 50)
                                }
                            }
                            .foregroundStyle(.black)
                            .padding(.top, topInsets)
                            .padding(.horizontal, 20)
    
                            TopTabs(tab: $tag)
                                .frame(width: geometry.size.width * 0.9)
                                .padding(.top, topInsets + 60)
                        }
                        .ignoresSafeArea()
                        .tag(0)
    
                        dummyContent(label: "LIKES SCREEN", nLines: 1000)
                            .tag(1)
    
                        dummyContent(label: "SPORTS SCREEN", nLines: 2000)
                            .tag(2)
                    }
                    .tabViewStyle(.page(indexDisplayMode: .never))
                    .ignoresSafeArea()
    
                    BottomTabs(tab: $tab)
                }
            }
        }
    }
    

    Animation