Search code examples
swiftuiswiftui-animationswiftui-matchedgeometryeffect

SwiftUI Animation in Tabbar


I am wanting to animate switching between tabs but the animation are very hard. I tried using matched geometry but the animation was wacky. Could be lack of understanding on matched geometry.

The expectation:

Tapping on a tab will show the image icon and the title of the tab behind a capsule. The untapped tab will not show the title or have the capsule behind it. Once a new tab is selected the capsule will navigate to the selected tab. and the title of the previously selected tab will hide.

import SwiftUI

struct Tabbar: View {
    @Namespace private var animation
    @State var selectedTab: RootTab = .home
    var body: some View {
        ZStack {
            HStack {
                ForEach(RootTab.allCases, id: \.self) { tab in
                    TabButton(tab: tab)
                }
            }
            .padding(.horizontal, 30)
            .padding(.vertical, 10)
            .background(Color.blue.opacity(0.4))
            .cornerRadius(30, corners: .allCorners)
        }
        .frame(maxWidth: .infinity)
        .background(Color(uiColor: .systemBackground))
    }
    
    private func TabButton(tab: RootTab) -> some View {
        HStack {
            Image(systemName: tab.systemName)
                .font(.title2)
                .foregroundStyle(.white)
                .bold()
            
            if selectedTab == tab {
                Text(tab.title)
                    .font(.callout)
                    .bold()
                    .foregroundColor(.white)
                    .animation(.default, value: selectedTab)
            }
        }
        .onTapGesture {
            withAnimation {
                selectedTab = tab
            }
        }
        .if(selectedTab == tab) { view in
            view
                .padding(.vertical, 8)
                .padding(.horizontal, 8)
                .background { Color.blue }
                .cornerRadius(15, corners: .allCorners)
                .animation(.default, value: selectedTab)
        }
    }
}

enum RootTab: String, CaseIterable {
    case home
    case profile
    
    var systemName: String {
        switch self {
            case .home: return "house"
            case .profile: return "person.crop.circle"
        }
    }
    
    var title: String {
        switch self {
            case .home: return "Home"
            case .profile: return "Profile"
        }
    }
}

extension View {
    @ViewBuilder
    func `if`(_ condition: @autoclosure () -> Bool, transform: (Self) -> some View) -> some View {
        if condition() {
            transform(self)
        } else {
            self
        }
    }

    func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
        clipShape(RoundedCorner(radius: radius, corners: corners))
    }
}


Solution

  • As you were suggesting, .matchedGeometryEffect can be used to achieve this animation:

    • The moving background should be shown as background to the HStack, before applying padding and a colored background. This way, it does not impact the size of the ZStack. In fact, the ZStack is not actually needed.

    • The position and size of the moving background is matched to the selected tab button using .matchedGeometryEffect with isSource: false. The id of the selected tab is used as the id to match to.

    • Since the size of the background marker is determined by the selected button, the padding should be applied to the buttons themselves.

    • The view extension .if is not needed.

    Other suggested changes:

    • The tab buttons are performing the change using withAnimation, so there is no need for any .animation modifiers.

    • The modifier .cornerRadius is deprecated, use .clipShape instead. An easy way to get a shape with fully rounded ends is to use a Capsule.

    • The modifier .foregroundColor is also deprecated, use .foregroundStyle instead.

    • If you want to apply the system background as background color, there is no need to create a Color from a UIColor, just use ShapeStyle.background instead.

    struct Tabbar: View {
        @Namespace private var animation
        @State var selectedTab: RootTab = .home
    
        var body: some View {
            HStack {
                ForEach(RootTab.allCases, id: \.self) { tab in
                    TabButton(tab: tab)
                        .padding(.vertical, 8)
                        .padding(.horizontal, 8)
                        .matchedGeometryEffect(id: tab, in: animation)
                }
            }
            .background {
                RoundedRectangle(cornerRadius: 15)
                    .fill(.blue)
                    .matchedGeometryEffect(id: selectedTab, in: animation, isSource: false)
            }
            .padding(.horizontal, 30)
            .padding(.vertical, 10)
            .background(.blue.opacity(0.4))
            .clipShape(Capsule())
            .frame(maxWidth: .infinity)
            .background(.background)
        }
    
        private func TabButton(tab: RootTab) -> some View {
            HStack {
                Image(systemName: tab.systemName)
                    .font(.title2)
                    .foregroundStyle(.white)
                    .bold()
    
                if selectedTab == tab {
                    Text(tab.title)
                        .font(.callout)
                        .bold()
                        .foregroundStyle(.white)
                }
            }
            .onTapGesture {
                withAnimation {
                    selectedTab = tab
                }
            }
        }
    }
    

    Animation

    You will notice that the width of the tab bar changes a little when the selection changes, because the two labels have different widths. This also causes the left-most icon to move a little.

    To prevent this, the footprint for the largest label can be found by using a hidden ZStack that contains all of the labels shown on top of each other. The visible label can then be shown as an overlay over the footprint:

    // function TabButton
    
    if selectedTab == tab {
        ZStack {
            ForEach(RootTab.allCases, id: \.self) { tab in
                Text(tab.title)
            }
        }
        .hidden()
        .overlay(alignment: .leading) {
            Text(tab.title)
        }
        .font(.callout)
        .bold()
        .foregroundStyle(.white)
    }
    

    Animation

    If in fact you know which label is always the longest (in all languages) then you don't need the ZStack, you could just use a hidden version of this wide label instead.