Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
Sajjon opened this issue Apr 19, 2022 · 4 comments

Comments

@Sajjon
Copy link

Sajjon commented Apr 19, 2022

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:

Screen.Recording.2022-04-19.at.18.12.57.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
Copy link
Author

Sajjon commented Apr 19, 2022

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
Copy link
Owner

johnpatrickmorgan commented Apr 19, 2022

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
Copy link
Author

Sajjon commented Apr 22, 2022

@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
Copy link

This works well for replacing with one screen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants