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
相关产品推荐
相关产品推荐

