Closed NeverwinterMoon closed 4 years ago
Hey @NeverwinterMoon ,
Adding the transition animation is fairly straightforward. All you need to do is to wrap the container that alters between those two screens in .transition(...)
modifier and wrap the change of the navigation state in withAnimation { /* toggle the navigation state */ }
. Here is an example of the view that applies "scale down; slide in" transition to the fullscreen views:
struct ContentView: View {
@State var toggle = false
var body: some View {
ZStack {
Group {
if toggle {
Color.red
} else {
Color.green
}
}
.transition(.moveAndFade)
Button(action: {
withAnimation {
self.toggle.toggle()
}
}, label: { Text("Tap me!") })
}
.edgesIgnoringSafeArea(.all)
}
}
extension AnyTransition {
static var moveAndFade: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
}
It does toggle the local state variable, but it's just the same for an external navigation state variable
Using the example you provided, let's say, we transition from A to B: A is scaled down and B slides in from the right side. All good. Now, when we close B, the expected animation would be to reverse the original animations: B slides to the side it came from and A scales up. With the given example, it looks super-weird: when going back from B to A, A slides in from the right side. So wrapping the container does not seem to be the way to go, unless I am missing something. I guess, it then makes sense to have the transition
set for each container view individually.
In this case, you can inject a bool parameter and configure the transition as needed:
extension AnyTransition {
static func moveAndFade(inverted: Bool) -> AnyTransition {
let insertion = AnyTransition.move(edge: inverted ? .trailing : .leading)
let removal = AnyTransition.scale
return .asymmetric(insertion: inverted ? insertion : removal,
removal: inverted ? insertion : removal)
}
}
// usage
.transition(.moveAndFade(inverted: toggle))
Alternatively, as you suggested, transitions can be configured individually for each view:
Group {
if toggle {
Color.red
.transition(.scale)
} else {
Color.green
.transition(.move(edge: toggle ? .leading : .trailing))
}
}
This is a bit unrelated, but I've noticed it when playing with transition animations. When using AnyView
, onAppear
has an unexpected (for me) behaviour:
struct NavigationView: View {
@State var states: States = .notRequested
var body: some View {
ZStack { content }
}
private var content: some View {
viewWithStates
}
private var viewWithStates: AnyView {
switch states {
case .loaded:
return AnyView(loadedView)
case .notRequested:
return AnyView(Text("BLA").onAppear {
print(".notRequested: BLA VIEW")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.states = .loading }
}
)
case .loading:
return AnyView(loadingView.onAppear {
print(".loading: LOADING VIEW")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.states = .error
}
})
case .error:
return AnyView(errorView)
}
}
private var loadingView: some View {
Text("LOADING")
}
private var loadedView: some View {
Text("LOADED")
}
private var errorView: some View {
Text("RELOAD").onTapGesture {
self.states = .notRequested
}
}
}
What happens:
.notRequested
branch appears.notRequested: BLA VIEW
printed from onAppear
attached to Text
view in .notRequested
branchloadingView
appears but its onAppear
is ignoredI am a bit baffled by this, as in other cases, where I don't use AnyView
erasure and use conditional statements to switch the views around, onAppear
is called every time the view appears and re-appears.
I think the problem might be that you're using async dispatch for the state mutation. This is unsafe for SwiftUI - you can read the section Schrödinger’s @State
in this article.
For SwiftUI, such a mutation is unexpected, so it cannot properly handle it. A proper way to make an async state mutation is by using Combine's publishers with .onReceive
modifier in the view.
For example, you can declare a let asyncStateChanger = PassthroughSubject<States, Never>()
in your view, subscribe to the changes this way: .onReceive(asyncStateChanger) { self.states = $0 }
, and then push the state changes after a delay:
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.asyncStateChanger.send(.loading)
}
In my actual project, I was using the approach you described and was having the exactly same issue.
What I've noticed is this though:
loadingView
.onAppear { self.callSomeAction() }
.onDisappear { print("something") }
In the above, callSomeAction
changes, say, userData state, I then subscribe to change in .onReceive
and update the local state of the view, this one is used to switch views.
onDisappear
will never be called, even when this view is replaced by another one. Thus when this view visually re-appears, onAppear
is also not called
This, on the other hand, works as I expect it:
loadingView
.onAppear { DispatchQueue.main.async { self.callSomeAction() } }
.onDisappear { print("something" }
In the above, onDisappear
is called, and onAppear
is called properly again when this view re-appears
It goes something like:
var body: some View {
rootContent
.onReceive(loadablePropertyUpdate) { self.loadableProperty = $0 }
}
var rootContent: some View {
// if loadableProperty is loading -> loadingView as seen above
// some other views
}
var loadablePropertyUpdate: AnyPublisher<Loadable<SomeModel>, Never> {
injected.appState.updates(for: \.userData.loadableProperty)
}
func callSomeAction() {
appState[\.userData.loadableProperty] = .isLoading(last: nil)
// something with combine that when loads sets the result on appState[\.userData.loadableProperty]
}
In fact, if you remove DispatchQueue.main.asyncAfter(deadline: .now() + 1.0)
from my original example, it will behave exactly the same way as with it. On the other hand, if I get rid of AnyView
in the same example, it works as expected with and without DispatchQueue.main.asyncAfter(deadline: .now() + 1.0)
:
private var viewWithStates: some View {
Group {
states.value.map { _ in
loadedView
}
if states.isError {
errorView
}
if states.isLoading {
loadingView.onAppear {
print(".loading: LOADING VIEW")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.states = .error }
}
}
if states.isNotRequested {
Text("BLA").onAppear {
print(".notRequested: BLA VIEW")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.states = .loading }
}
}
}
}
Hm... If async dispatch fixes the issue when onDisappear
is not being called, I would keep it until SwiftUI matures. It seems like a bug.
@NeverwinterMoon have you tried running this on iOS 13.4? I'm seeing that the issue with navigation was fixed, but I'm holding the commit until Travis supports iOS 13.4
@nalexn Sorry for late reply. I've read this and then didn't have time to test right away and forgotten about this. I've just tested with 13.4 and it seems that I can't reproduce the problem any longer.
Ok, closing this one.
Would you consider adding an example of full-screen view transition animations based on Routing to this project? Something where both - the new view and the previous view - would have different transition animations. Say, previous view scales down and the new view slides in.