nalexn / clean-architecture-swiftui

SwiftUI sample app using Clean Architecture. Examples of working with CoreData persistence, networking, dependency injection, unit testing, and more.
MIT License
5.83k stars 709 forks source link

Examples of custom full-screen transition animations based on Routing #14

Closed NeverwinterMoon closed 4 years ago

NeverwinterMoon commented 4 years ago

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.

nalexn commented 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

NeverwinterMoon commented 4 years ago

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.

nalexn commented 4 years ago

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))
nalexn commented 4 years ago

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))
    }
}
NeverwinterMoon commented 4 years ago

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:

I 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.

nalexn commented 4 years ago

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)
}
NeverwinterMoon commented 4 years ago

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 }
        }
      }
    }
  }
nalexn commented 4 years ago

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.

nalexn commented 4 years ago

@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

NeverwinterMoon commented 4 years ago

@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.

nalexn commented 4 years ago

Ok, closing this one.