Categories
Combine iOS Swift SwiftUI

Animating view transitions in SwiftUI

One building block for navigating from one view to another is NavigationView which is a representation of UINavigationController in UIKit. This time, let’s take a look on how to transition from one SwiftUI view to another one without NavigationView.

AppFlowCoordinator managing choosing the view

The idea is to have a root SwiftUI view with only responsibility of presenting the active view. State is stored in AppFlowCoordinator which can be accessed from other views and therefore other views can trigger navigation. Example case we’ll build, is animating transitions from login view to main view and back. As said, AppFlowCoordinator stores the information about which view should be on-screen at a given moment. All the views are represented with an enum and based on the value in enum, views are created. This coordinator is ObservableObject what makes it easy to bind to a SwiftUI view – whenever activeFlow changes, SwiftUI view is updated. The term flow is used because views can consist of stack of other views and therefore creating a flow of views.

import SwiftUI
final class AppFlowCoordinator: ObservableObject {
@Published var activeFlow: Flow = .login
func showLoginView() {
withAnimation {
activeFlow = .login
}
}
func showMainView() {
withAnimation {
activeFlow = .main
}
}
}
extension AppFlowCoordinator {
enum Flow {
case login, main
}
}
Triggering navigation using flow controller.

RootView displaying active flow

RootView selects which view is currently visible. It accesses coordinator through environment. SwiftUI requires EnvironmentObjects to be ObservableObjects, therefore this view is automatically refreshed when activeFlow changes in the AppFlowCoordinator. RootView’s body is annotated with @ViewBuilder which will enable the view to return a body with type depending on the current state (HStack is also a ViewBuilder). Other options are wrapping the views with AnyView or using Group. In our case the view types are LoginView and ContentView. Both views also define the transition animation what is used when view refresh is triggered in withAnimation closure in AppFlowCoordinator. Asymmetric enables defining different transitions when view is added and removed from the view hierarchy.

let appFlowCoordinator = AppFlowCoordinator()
let rootView = RootView().environmentObject(appFlowCoordinator)
window.rootViewController = UIHostingController(rootView: rootView)
Inserting AppFlowCoordinator to environment
struct RootView: View {
@EnvironmentObject var appFlowCoordinator: AppFlowCoordinator
@ViewBuilder
var body: some View {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
if appFlowCoordinator.activeFlow == .main {
ContentView().transition(.asymmetric(insertion: .scale, removal: .opacity))
}
else if appFlowCoordinator.activeFlow == .login {
LoginView().transition(.asymmetric(insertion: .slide, removal: .opacity))
}
else {
EmptyView()
}
}
}
}
view raw RootView.swift hosted with ❤ by GitHub
Updating currently visible flow with transition animations

Triggering navigation from SwiftUI view

Last piece we need to take a look at is how to trigger transition. As AppFlowCoordinator is in environment, any view can access the coordinator and call any of the navigation methods. When login finishes, LoginView can tell the coordinator to show the main content view.

struct LoginView: View {
@EnvironmentObject var appFlowCoordinator: AppFlowCoordinator
var body: some View {
ZStack {
Button(action: appFlowCoordinator.showMainView) {
Text("Login")
}
}
}
}
view raw LoginView.swift hosted with ❤ by GitHub
Navigating to main view from login view

Summary

We took a look on how to navigate from one SwiftUI view to another by using a coordinator object. Coordinator stored the information about which view we should currently display on screen. We saw how easy it is to trigger navigation from any of the currently visible views.

transition animation
Low FPS GIF representing the transition animation

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example

RootViewTransitions Xcode 11.2.1, Swift 5.1

Categories
macOS Swift SwiftUI

Injecting dependencies using environment values and keys in SwiftUI

Instead of initializing SwiftUI views with dependencies, SwiftUI also offers other ways for injecting dependencies. This time let’s take a look on EnvironmentKey which defines a key for inserting objects to environment and vise-versa. We need to create a EnvironmentKey, adding an object to environment and then getting the object in SwiftUI view.

Creating EnvironmentKey and extending EnvironmentValues

Example object we use is DependencyManager what in real app can contains loads of dependencies. EnvironmentKey is a protocol in SwiftUI what requires to define associated type and default value. Default value is used when we have not explicitly inserted an instance of DependencyManager to the environment, more about it a little bit later. EnvironmentValues is a struct containing a collection of environment objects. We’ll add a property to EnvironmentValues which later will be used by @Environment property wrapper and also when setting an instance of the object to the environment.

import Foundation
import SwiftUI
struct DependencyManager {
let identifier: String
let urlSession = URLSession.shared
}
struct DependencyManagerKey: EnvironmentKey {
typealias Value = DependencyManager
static var defaultValue = DependencyManager(identifier: "Default created by environment")
}
extension EnvironmentValues {
var dependencyManager: DependencyManager {
get {
return self[DependencyManagerKey.self]
}
set {
self[DependencyManagerKey.self] = newValue
}
}
}
Custom EnvironmentKey and EnvironmentValues extension for accessing dependencies

Inserting objects to environment

If we would like to insert a DependencyManager to the Environment, we can use func environment(_ keyPath: WritableKeyPath, _ value: V) -> some View using our DependencyManagerKey and an instance created by us. If we do not insert our own, SwiftUI will use the instance returned by defaultValue when the key is first time accessed.

// Setting non-default instance of DependencyManager, otherwise default instance is used created in DependencyManagerKey
let dependencyManager = DependencyManager(identifier: "Scene delegate created")
let contentView = ContentView().environment(\.dependencyManager, dependencyManager)
Setting instance of DependencyManager to SwiftUI environment

Getting objects from environment

Objects can be read from the environment using @Environment property wrapper and specifing the EnvironmentKey.

import SwiftUI
struct ContentView: View {
@Environment(\.dependencyManager) var dependencyManager: DependencyManager
var body: some View {
Text(dependencyManager.identifier)
}
}
Accessing the instance of DependencyManager in environment

Summary

We created an environment key and inserted an object into environment. We looked into how SwiftUI handles default values for environment objects and used @Environment property wrapper for getting the instance from the environment using the created key.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
Swift SwiftUI

NavigationLink and presentationMode environment value property for dismissing a view in SwiftUI

How to navigate to a new view in SwiftUI and then dismissing it? Let’s set up a main view in NavigationView and NavigationLink for opening detail view. NavigationLink is a button triggering a navigation to a specified view. Detail view will contain a button for navigating back to the first view. But how do we dismiss presented detail view?

Reading environment and dismissing the detail view

SwiftUI provides a dynamic view property named Environment. It can be used for reading values from the view’s environment. Presentation mode is one of those values we can read. This is how we get access to PresentationMode structure what has isPresented property and function for dismissing the view. Therefore, we need to add dynamic view property to our detail view and then calling dismiss() when the button is tapped

import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
VStack(spacing: 8) {
Text("Hello World")
NavigationLink("Go to Detail View", destination: DetailView())
}.navigationBarTitle(Text("View 1"))
}
}
}
Content shown in navigation view
import SwiftUI
struct DetailView: View {
@Environment(\.presentationMode) var presentationMode
var body: some View {
VStack(spacing: 8) {
Text("Detail view")
Button("Go Back", action: {
self.presentationMode.wrappedValue.dismiss()
})
}
}
}
Detail view with button for navigation back to the content view

Summary

SwiftUI has a dynamic view property Environment what we can use for getting more knowledge about the environment view is presented (locale, displayScale etc). One of the environment values is PresentationMode what we can use for dismissing the detail view.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.