Search code examples
iosswiftswiftuitabsuikit

Is it possible to add a custom transition to TabView children when page changes?


I have a controlled Tab view like this

    TabView(selection: $activeTab) {
      IntroductionView().tag(1)
      HomeView().tag(2)
      SettingsView().tag(3)
      ProfileView().tag(4)
    }

I want to add transition to each of the views when activeTab changes, say simple cross fade of opacity?

Tried something along the lines of

    TabView(selection: $activeTab) {
      IntroductionView().tag(1).transition(.opacity)
      HomeView().tag(2).transition(.opacity)
      SettingsView().tag(3).transition(.opacity)
      ProfileView().tag(4).transition(.opacity)
    }
    .animation(.smooth, value: activeTab)

But this seems to have no effect. There are some answers like this one that add some kind of scrollable / slide animation via .tabViewStyle(.page(indexDisplayMode: .never)) modifier, but that's not exactly what I'm after.

Perhaps there is a way to utilize / extend UIKit to add a cross fade transition?


Solution

  • If you want to be able to specify a Transition for each of the tabs using a view modifier, like you did in the example code, it is best to write your own TabView.

    Here is an adapted version of the custom "tab view" I wrote here, that makes the tabs look more like tabs. I have also added a tabTransition modifier to allow you to choose a Transition.

    struct CustomTabView<Content: View, Selection: Hashable>: View {
        
        @Binding var selectedTab: Selection
        
        @ViewBuilder let content: () -> Content
        
        var body: some View {
            Extract(content) { views in
                ForEach(views) { view in
                    if view.id(as: Selection.self) == selectedTab {
                        view
                            .frame(maxWidth: .infinity, maxHeight: .infinity)
                            .transition(view[TabTransitionTrait.self])
                    }
                }
            }
            .safeAreaInset(edge: .bottom) {
                HStack {
                    Spacer()
                    ExtractMulti(content) { views in
                        ForEach(views) { view in
                            Group {
                                if let label = view[CustomTabItemTrait.self] {
                                    label
                                } else {
                                    Text("Unnamed")
                                }
                            }
                            .onTapGesture {
                                if let selection = view.id(as: Selection.self) {
                                    selectedTab = selection
                                }
                            }
                            .foregroundStyle(
                                view.id(as: Selection.self) == selectedTab ?
                                    AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.opacity(1))
                            )
                            Spacer()
                        }
                    }
                }
            }
        }
    }
    
    extension View {
        func customTabItem<Content: View>(@ViewBuilder content: () -> Content) -> some View {
            _trait(CustomTabItemTrait.self, AnyView(content()))
        }
    }
    
    struct CustomTabItemTrait: _ViewTraitKey {
        static let defaultValue: AnyView? = nil
    }
    
    extension View {
        func tabTransition<T: Transition>(_ transition: T) -> some View {
            _trait(TabTransitionTrait.self, AnyTransition(transition))
        }
    }
    
    struct TabTransitionTrait: _ViewTraitKey {
        static let defaultValue: AnyTransition = .identity
    }
    
    // MARK: View Extractor - https://github.com/GeorgeElsham/ViewExtractor
    
    public struct Extract<Content: View, ViewsContent: View>: View {
        let content: () -> Content
        let views: (Views) -> ViewsContent
    
        public init(_ content: Content, @ViewBuilder views: @escaping (Views) -> ViewsContent) {
            self.content = { content }
            self.views = views
        }
    
        public init(@ViewBuilder _ content: @escaping () -> Content, @ViewBuilder views: @escaping (Views) -> ViewsContent) {
            self.content = content
            self.views = views
        }
    
        public var body: some View {
            _VariadicView.Tree(
                UnaryViewRoot(views: views),
                content: content
            )
        }
    }
    
    fileprivate struct UnaryViewRoot<Content: View>: _VariadicView_UnaryViewRoot {
        let views: (Views) -> Content
    
        func body(children: Views) -> some View {
            views(children)
        }
    }
    
    public struct ExtractMulti<Content: View, ViewsContent: View>: View {
        let content: () -> Content
        let views: (Views) -> ViewsContent
    
        public init(_ content: Content, @ViewBuilder views: @escaping (Views) -> ViewsContent) {
            self.content = { content }
            self.views = views
        }
    
        public init(@ViewBuilder _ content: @escaping () -> Content, @ViewBuilder views: @escaping (Views) -> ViewsContent) {
            self.content = content
            self.views = views
        }
    
        public var body: some View {
            _VariadicView.Tree(
                MultiViewRoot(views: views),
                content: content
            )
        }
    }
    
    fileprivate struct MultiViewRoot<Content: View>: _VariadicView_MultiViewRoot {
        let views: (Views) -> Content
    
        func body(children: Views) -> some View {
            views(children)
        }
    }
    
    public typealias Views = _VariadicView.Children
    

    Example usage:

    struct ContentView: View {
        @State var selectedTab = 0
        var body: some View {
            CustomTabView(selectedTab: $selectedTab.animation()) {
                Color.blue
                    .id(0) // you must use .id instead of .tag to specify the selection value
                    .customTabItem {
                        Label("Baz", systemImage: "circle")
                    }
                    .tabTransition(.push(from: .leading))
                
                Color.yellow
                    .id(1)
                    .customTabItem {
                        Label("Foo", systemImage: "globe")
                    }
                    .tabTransition(.push(from: .bottom))
                
                Color.green
                    .id(2)
                    .customTabItem {
                        Label("Bar", systemImage: "rectangle")
                    }
                    .tabTransition(.opacity)
            }
        }
    }
    

    This does not preserve the states of each tab, like scroll position. If this is undesirable, you cannot use the transition modifier (and hence the built-in Transitions). You would need to do your own animation for the transitions. For example, an opacity animation would be implemented with .opacity:

    // in CustomTabView.body
    ZStack {
        ExtractMulti(content) { views in
            ForEach(views) { view in
                view
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .opacity(view.id(as: Selection.self) == selectedTab ? 1 : 0)
            }
        }
    }
    .safeAreaInset(edge: .bottom) {
        // the rest of the tab bar goes here...
    }
    

    For a moving transition, you might give each tab an .offset depending on whether it is selected (view.id(as: Selection.self) == selectedTab) or not, and so on.