johnpatrickmorgan / FlowStacks

FlowStacks allows you to hoist SwiftUI navigation and presentation state into a Coordinator
MIT License
783 stars 56 forks source link

[Feature Request] `replaceStack(with routes: [Route<Screen>], animated: Bool = true)` #21

Open Sajjon opened 2 years ago

Sajjon commented 2 years ago

Hey! FlowStacks is great! I use it indirectly through [TCACoordinators[(https://github.com/johnpatrickmorgan/TCACoordinators) (so this Feature Request might spill over to TCACoordinators, depending on implementation?), however, it seems that one fundamental navigation primitive is missing!

When we completely replace the current navigation tree (navigation stack) with a new one, e.g. when the user completes some onboarding and ends with a signed in state, we want to replace the root (currently holding the root of the onboarding flow) with the root of main flow. We can do this using FlowStacks today simply by replacing the routes array with a new one, containing the root of main flow.:

routes = [.root(.main(.init()), embedInNavigationView: true)]

However, this is not animated! In UIKit we can animate this kind of replacement of navigation stack using:

// UIKit stuff
 guard animated, let coordinator = transitionCoordinator else {
            DispatchQueue.main.async { completion() }
            return
        }
        coordinator.animate(alongsideTransition: nil) { _ in completion() }

I've done this is in this UIKit app

This creates a pretty nice animation! It would be great if we could achieve something similar using FlowStacks (if possible in SwiftUI)

EDIT: It would be nice with a "flipping card" animation, like this:

https://user-images.githubusercontent.com/864410/164049382-d3216b02-ddf2-45dc-948e-929be0be97f0.mov

Here is the complete source code for the demo movie above.


final class AuthState: ObservableObject {
    @Published var isAuthenticated = false
    public init() {}
    static let shared = AuthState()
}

public struct CardView<FaceUp, FaceDown>: View where FaceUp: View, FaceDown: View {
    private var faceUp: FaceUp
    private var faceDown: FaceDown
    private var isFaceUp: Bool

    public enum Axis: Hashable, CaseIterable {
        case x, y, z
        fileprivate var value: (CGFloat, CGFloat, CGFloat) {
            switch self {
            case .x: return (1, 0, 0)
            case .y: return (0, 1, 0)
            case .z: return (0, 0, 1)
            }
        }
    }

    private var axis: Axis

    public init(
        isFaceUp: Bool = false,
        axis: Axis = .y,
        @ViewBuilder faceUp: () -> FaceUp,
        @ViewBuilder faceDown: () -> FaceDown
    ) {
        self.faceUp = faceUp()
        self.faceDown = faceDown()
        self.isFaceUp = isFaceUp
        self.axis = axis
    }

    @ViewBuilder
    private var content: some View {
        if isFaceUp {
            // Prevent rotation of faceUp by applying 180 rotation.
            faceUp
                .rotation3DEffect(
                    Angle.degrees(180),
                    axis: axis.value
                )
        } else {
            faceDown
        }
    }

    public var body: some View {
        content
        .rotation3DEffect(
            Angle.degrees(isFaceUp ? 180: 0),
            axis: axis.value
        )
    }

}

@main
struct ClipCardAnimationApp: App {
    @ObservedObject var authState = AuthState.shared
    @State var isFaceUp = false
    var body: some Scene {
        WindowGroup {
            CardView(
                isFaceUp: authState.isAuthenticated,
                faceUp: MainView.init,
                faceDown: WelcomeView.init
            )
            .animation(.easeOut(duration: 1), value: authState.isAuthenticated)

            .environmentObject(AuthState.shared)

            // Styling
            .foregroundColor(.white)
            .font(.largeTitle)
            .buttonStyle(.borderedProminent)
        }
    }
}

struct WelcomeView: View {
    @EnvironmentObject var auth: AuthState

    var body: some View {
        ForceFullScreen(backgroundColor: .yellow) {
            VStack(spacing: 40) {
                Text("Welcome View")

                Button("Login") {
                    auth.isAuthenticated = true
                }
            }
        }
    }
}

struct MainView: View {
    @EnvironmentObject var auth: AuthState
    var body: some View {
        ForceFullScreen(backgroundColor: .green) {
            VStack {
                Text("MainView")

                Button("Log out") {
                    auth.isAuthenticated = false
                }
            }
        }
    }
}

public struct ForceFullScreen<Content>: View where Content: View {

    private let content: Content
    private let backgroundColor: Color
    public init(
        backgroundColor: Color = .clear,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.backgroundColor = backgroundColor
        self.content = content()
    }

    public var body: some View {
        ZStack {
            backgroundColor
                .edgesIgnoringSafeArea(.all)

            content
                .padding()
        }
    }
}
Sajjon commented 2 years ago

Code example above can be slightly improved by use of modifiers and Group with _ConditionalContent:

@main
struct ClipCardAnimationApp: SwiftUI.App {
    @ObservedObject var auth = AuthState.shared
    var body: some Scene {
        WindowGroup {
            Group {
                if auth.isAuthenticated {
                    MainView()
                } else {
                    WelcomeView()
                }
            }
            .cardAnimation(isFaceUp: auth.isAuthenticated)

            // Styling
            .foregroundColor(.white)
            .font(.largeTitle)
            .buttonStyle(.borderedProminent)
        }
    }
}

struct CardAnimationModifier: ViewModifier {
    let isFaceUp: Bool
    let axis: Axis

    func body(content: Content) -> some View {
        return content
            .rotation3DEffect(
                Angle.degrees(isFaceUp ? 180 : 0),
                axis: axis.value
            )
            .rotation3DEffect(
                Angle.degrees(isFaceUp ? 180: 0),
                axis: axis.value
            )
            .animation(.easeOut(duration: 1), value: isFaceUp)
    }
}
public protocol ConditionalContentView {
    associatedtype FalseContent
    associatedtype TrueContent
}
extension _ConditionalContent: ConditionalContentView {}

extension Group where Content: ConditionalContentView & View, Content.FalseContent: View, Content.TrueContent: View {

    func cardAnimation(isFaceUp: Bool, axis: Axis = .y) -> some View {
        modifier(CardAnimationModifier(isFaceUp: isFaceUp, axis: axis))
    }
}
johnpatrickmorgan commented 2 years ago

Thanks for raising this issue @Sajjon! This is a feature I've really wanted to add but I've hit issues. I'll document the issues and my progress here.

Most updates to the route array (e.g. pushing and popping) will change its count, which will result in a push/pop animation. But sometimes, as you say, we might want to replace the top screen. Since the count stays the same there will be no navigation animation for these updates - it just cuts straight from screen A to screen B. I've always like UIKit's NavigationController.setViewControllers API, which works great for these cases.

In theory we should be able to use SwiftUI transitions to handle animations in these cases, but I haven't found them easy to work with. I've tried the following (if it worked well, I would provide a nice API for it in this library):

import SwiftUI
import FlowStacks

struct TransitionCoordinator: View {

  enum ExampleScreen {
    case one
    case two
  }

  @State var routes: Routes<ExampleScreen> = [.root(.one)]
  @State var transition: AnyTransition = .push

  var body: some View {
    NavigationView {
      Router($routes) { screen, _ in
        WithReplaceTransition(transition: transition) {
          switch screen {
          case .one:
            VStack {
              Text("1")
              Button("Go to 2", action: goToTwo)
            }
          case .two:
            VStack {
              Text("2")
              Button("Go to 1", action: goToOne)
            }
          }
        }
      }
    }
  }

  func goToTwo() {
    transition = .push
    withAnimation {
      self.routes = [.root(.two)]
    }
  }

  func goToOne() {
    transition = .pop
    withAnimation {
      self.routes = [.root(.one)]
    }
  }
}

struct WithReplaceTransition<C: View>: View {
  var transition: AnyTransition
  @ViewBuilder var content: () -> C

  var body: some View {
    // For some reason, transitions have no effect if we don't embed the content in a VStack.
    VStack {
      content()
        // Make the content fill the available space (like a screen)
        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
        .transition(transition)
    }
  }
}

public extension AnyTransition {
  static var pop: AnyTransition {
    return .asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
  }

  static var push: AnyTransition {
    return .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
  }
}

This works well when the screen you're replacing is the root of the navigation stack, but behaves strangely when replacing a screen that has itself been pushed, i.e. it works if the count is 1 but not when the count is more than one:, when it often fails to animate the update. I've tried reproducing in vanilla SwiftUI without FlowStacks and the issue goes away in that case, so there seems to be something about FlowStacks' setup that interferes with the transition. My next task is to figure out what.

In the meantime, you should be able to use transitions for your use case, as you're only swapping out the root view. It should also be possible to design your own custom transitions using AnyTransition.modifier, so you could get a nice 3D effect that way. 😄

Sajjon commented 2 years ago

@johnpatrickmorgan Thx for your response! Yeah that solution works, just would be sooooo nice if we could get it out of the box with FlowStacks!

I've tried reproducing in vanilla SwiftUI without FlowStacks and the issue goes away in that case, so there seems to be something about FlowStacks' setup that interferes with the transition. My next task is to figure out what.

I hope you find it! Please feel free to share any WIP branch and I might take a look :)

blyscuit commented 1 year ago

This works well for replacing with one screen.