You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI自定义转场动画:如何复刻iOS标准导航切换效果?

状态驱动UI中动态导航转场动画的问题与解决方案

我采用状态驱动模式实现UI(通过切换状态而非导航栈完成视图跳转),尝试复刻Apple的导航滑入/滑出动画时遇到问题:动画方向需要根据前进或后退的导航动态变化。目前已能通过Combine跟踪导航方向,但转场动画无法自适应方向变化。

复现Demo代码

// Controls which view is active
enum ViewState: Int {
    case initial = 1
    case login
    case details
}

// Declares navigation direction
enum NavigationDirection {
    case forward
    case back
}

// Root view model  
class ContentViewModel: ObservableObject {
    @Published var viewState: ViewState = .initial
    @Published var fromView: ViewState = .initial
    @Published var navigationDirection: NavigationDirection = .forward
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        setupNavigationDirection()
    }
    
    func setState(_ state: ViewState) {
        viewState = state
    }
    
    private func setupNavigationDirection() {
        $viewState
            .receive(on: RunLoop.main)
            .sink { [weak self] state in
                guard let self = self else { return }
                if self.fromView.rawValue > state.rawValue {
                    self.navigationDirection = .back
                } else {
                    self.navigationDirection = .forward
                }
                print(self.navigationDirection)
            }
            .store(in: &cancellables)
    }
}

// Main root view
struct ContentView: View {
    @StateObject var viewModel: ContentViewModel
    
    var body: some View {
        VStack {
            Text("Title that remains in place")
            
            Spacer()
            
            switch viewModel.viewState {
            case .initial:
                initialView
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .animation(.default)
                    .transition(.move(edge: viewModel.navigationDirection == .forward ? .leading : .trailing))
                    .onAppear {
                        viewModel.fromView = .initial
                    }
            case .login:
                LoginView(viewModel: viewModel)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .animation(.default)
                    .transition(.move(edge: viewModel.navigationDirection == .forward ? .trailing : .leading))
                    .onAppear {
                        viewModel.fromView = .login
                    }
            case .details:
                DetailsView(viewModel: viewModel)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .animation(.default)
                    .transition(.move(edge: viewModel.navigationDirection == .forward ? .trailing : .leading))
                    .onAppear {
                        viewModel.fromView = .details
                    }
            }
        }

    }
    
    private var initialView: some View {
        VStack(spacing: 10) {
            Text("First View")
            
            ButtonView(contentViewModel: viewModel,
                       viewModel: .init(buttonType: .forward, stateChange: .login))
        }
    }
}

// Login view
struct LoginView: View {
    @StateObject var viewModel: ContentViewModel
    
    var body: some View {
        VStack(spacing: 10) {
            Text("Login View")
            
            ButtonView(contentViewModel: viewModel,
                       viewModel: .init(buttonType: .forward, stateChange: .details))
            
            ButtonView(contentViewModel: viewModel,
                       viewModel: .init(buttonType: .back, stateChange: .initial))
        }
    }
}

// Details View
struct DetailsView: View {
    @StateObject var viewModel: ContentViewModel
    
    var body: some View {
        VStack(spacing: 10) {
            Text("Details View")
 
            ButtonView(contentViewModel: viewModel,
                       viewModel: .init(buttonType: .back, stateChange: .login))
        }
    }
}

// Button type enum
enum ButtonType {
    case back
    case forward
    
    var backgroundColor: Color {
        switch self {
        case .back:
            return .red
        case .forward:
            return .blue
        }
    }
    
    var label: String {
        switch self {
        case .back:
            return "Back"
        case .forward:
            return "Forward"
        }
    }
}

// View model for ButtonView
class ButtonViewModel: ObservableObject {
    let buttonType: ButtonType
    let stateChange: ViewState
    
    init(buttonType: ButtonType, stateChange: ViewState) {
        self.buttonType = buttonType
        self.stateChange = stateChange
    }
}

// ButtonView
struct ButtonView: View {
    @ObservedObject var contentViewModel: ContentViewModel
    @StateObject var viewModel: ButtonViewModel
    
    var body: some View {
        Button {
            contentViewModel.setState(viewModel.stateChange)
        } label: {
            Text(viewModel.buttonType.label)
        }
        .padding()
        .background(viewModel.buttonType.backgroundColor)
        .foregroundColor(.white)
        .cornerRadius(8)
    }
}

期望的动画效果

  • 从initialView点击Forward:initialView向左移出,loginView从右侧滑入
  • 从LoginView点击Back:LoginView向右移出,initialView从左侧滑入
  • 从LoginView点击Forward:LoginView向左移出,DetailsView从右侧滑入
  • 从DetailsView点击Back:DetailsView向右移出,LoginView从左侧滑入

问题分析

当前方案中,视图的transition参数依赖navigationDirection,但转场配置是在视图初始渲染时确定的,后续navigationDirection变化后,已渲染的视图不会自动更新转场属性。同时onAppear设置fromView的时机滞后,导致方向判断可能出现偏差。

优化解决方案

1. 修复ViewModel的状态跟踪逻辑

先记录当前状态再更新目标状态,确保方向判断基于准确的前后状态对比:

func setState(_ state: ViewState) {
    fromView = viewState // 先保存当前状态,再切换目标状态
    viewState = state
}

简化导航方向判断逻辑,移除冗余的onAppear设置:

private func setupNavigationDirection() {
    $viewState
        .receive(on: RunLoop.main)
        .sink { [weak self] newState in
            guard let self = self else { return }
            self.navigationDirection = self.fromView.rawValue < newState.rawValue ? .forward : .back
        }
        .store(in: &cancellables)
}

2. 封装状态驱动的导航容器

使用自定义容器统一管理转场动画,确保方向变化时自动更新转场配置:

// 给ViewState添加全枚举支持
extension ViewState: CaseIterable {
    static var allCases: [ViewState] {
        [.initial, .login, .details]
    }
}

struct StateDrivenNavigationContainer<Content: View>: View {
    @Binding var currentState: ViewState
    @Binding var direction: NavigationDirection
    let content: (ViewState) -> Content
    
    var body: some View {
        ZStack {
            ForEach(ViewState.allCases, id: \.self) { state in
                if state == currentState {
                    content(state)
                        .transition(transitionForDirection())
                }
            }
        }
        .animation(.easeInOut(duration: 0.3), value: currentState)
    }
    
    private func transitionForDirection() -> AnyTransition {
        switch direction {
        case .forward:
            return .asymmetric(
                insertion: .move(edge: .trailing),
                removal: .move(edge: .leading)
            )
        case .back:
            return .asymmetric(
                insertion: .move(edge: .leading),
                removal: .move(edge: .trailing)
            )
        }
    }
}

3. 重构ContentView使用新容器

替换原来的switch结构,用统一容器管理视图切换:

struct ContentView: View {
    @StateObject var viewModel: ContentViewModel
    
    var body: some View {
        VStack {
            Text("Title that remains in place")
            
            Spacer()
            
            StateDrivenNavigationContainer(
                currentState: $viewModel.viewState,
                direction: $viewModel.navigationDirection
            ) { state in
                switch state {
                case .initial: initialView
                case .login: LoginView(viewModel: viewModel)
                case .details: DetailsView(viewModel: viewModel)
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
    
    private var initialView: some View {
        VStack(spacing: 10) {
            Text("First View")
            ButtonView(contentViewModel: viewModel, viewModel: .init(buttonType: .forward, stateChange: .login))
        }
    }
}

4. 移除冗余动画配置

删除每个视图单独设置的.animation(.default),统一在容器层级添加动画,避免动画冲突。

效果验证

修改后,每次点击按钮时:

  • fromView会提前记录当前状态,确保导航方向判断完全准确
  • 转场动画会根据最新的navigationDirection自动切换插入/移除方向
  • 完全实现Apple风格的导航滑入/滑出效果

内容的提问来源于stack exchange,提问作者DevB1

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.14 19:20:17