abcdefg
abcdefg

Reputation: 109

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))
    }
}

Upvotes: 0

Views: 73

Answers (1)

Benzy Neez
Benzy Neez

Reputation: 21730

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.

Upvotes: 1

Related Questions