In our previous post, we introduced you to the foundational concepts of Stack-Based and Tabbed Navigation, which are central to iOS app navigation. As we dive further in, our attention turns to two critical elements: data flow and data sharing. This post will explore the inner workings of how data seamlessly traverses through your app's navigation framework and how it's shared among different components. In a nutshell, by the end of this post we will know how to pass data between screens as well as how to share data between them using SwiftUI.
If you haven’t already, I recommend you check out our previous post introducing the basics of iOS navigation using SwiftUI.
Data Flow vs Data Sharing
Data Flow and Data Sharing are related concepts in app development, but they serve different purposes. Before we explore each in more detail it would be helpful to get a clearer understanding of each.
Data flow refers to the movement of data within an application, often in a unidirectional manner. It focuses on how data is passed between different parts or components of an app, typically in a structured and organized way.
Examples:
Passing data from a parent view to a child view.
Managing the flow of user input through an app's logic.
Data sharing involves making data accessible to multiple parts of an application, often for collaboration or synchronization. Data sharing is particularly useful when multiple views or components need access to a common data source or when you want to maintain consistency across different parts of the app.
Examples:
Sharing a user's authentication status or profile data across various views.
Allowing different tabs of a tabbed interface to access a common dataset.
In summary, data flow primarily deals with how data is passed and updated within an app, focusing on the flow of data from one point to another. Data sharing, on the other hand, deals with making data accessible and consistent across various parts of the app, allowing multiple components to work with the same dataset. While they are related, they serve different aspects of data management and communication within an application.
Data flow in Stack-Based Navigation
Continuing from the previous section, data flow thrives on structure and organization. One way to bring structure into our data flow is by making it unidirectional, where information moves in a single, clear direction. This approach enhances the scalability and maintainability of our applications. With unidirectional data flow, it becomes more straightforward to reason about data interactions and ensure the long-term health of our app.
In our previous introductory post, we learned how the hierarchical nature of Stack-Based navigation inherently provides a sense of structure. Consequently, when working with SwiftUI's NavigationStack, data flow often takes center stage. To shed further light on this concept, let's explore two common data flow scenarios encountered when using a navigation stack:
Passing data from a parent view to a child view
Passing data from an ancestor view to a descendant view deep in the navigation stack
Passing data from parent view to child view
The first case is generally the most common scenario encountered when using stack-based navigation. We have a parent screen that presents a child screen using a NavigationStack, however, the child screen needs some data from the parent screen in order to display the correct information. We can typically pass data directly from the first screen to the second through the initializer as shown in the code below:
In our example code above we pass the Article data needed by the ArticleDetailView directly through the initializer. This approach helps maintain a clear contract between the parent and child views, making your code more robust and easier to understand. However, in more complex navigation structures, you may encounter scenarios where data needs to be passed from an ancestor view down to a descendant view.
Passing data from ancestor view to descendant view
This is the second case which you may encounter when you have several screens added to your navigation stack. This scenario often arises in apps with complex hierarchies or when information must be passed across multiple screens deep within your application. There are two practical options in SwiftUI:
Pass data down through the initializers of each screen (as we did above)
Use SwiftUI’s @Environment decorator
Although the initializers of views offer a straightforward means of passing data, this approach can quickly become unwieldy, particularly as your navigation stack grows. Moreover, it becomes less practical when dealing with multiple data types or when not all descendant views rely on the same data. To address these challenges and simplify the data flow process, SwiftUI provides us with the powerful @Environment decorator. With @Environment, we can ensure that the right data is accessible to the right views without the need for explicit initializer-based passing.
Imagine we're building a note-taking app that allows users to organize their notes into various categories such as “Work”, “Personal”, and “Hobbies”. Each category has its own unique color theme to provide a visual distinction. All views within a category should make use of the same color corresponding to their category. Additionally, when users navigate into the details of individual notes, we want to display the current category name.
Here's where @Environment proves invaluable. By utilizing @Environment, we can effortlessly propagate the selected category's name and color theme throughout the view hierarchy, ensuring a cohesive and user-friendly experience across all notes within the same category.
First, we have to define the @Environment values we need. We start by subclassing EnvironmentKey to define keys by which to access the values in our environment. Then we extend the EnvironmentValues struct with a new property for each new value. Following is the code used to define the categoryColor environment value we will use later to propagate our category colors (categoryName follows exactly the same process):
Once we have our environment keys and properties defined, we can utilize them to pass data down from our root screen (ancestor view) as shown below:
Set the color and name values corresponding to the selected category.
Set the environment values for category color and name in our NavigationStack using the environment(_:_:) view modifier.
At this point, we have successfully placed the categoryColor and categoryName values within our NavigationStack's environment, ensuring that any subsequently added screens can readily access and utilize them.
Accessing the required data from child and descendant screens becomes trivial:
As we can see, we were able to pass two data types down our navigation stack effortlessly without needing to pollute the initializers of any of our views. Our code remains clean and more easily maintainable, yet we are able to support a more complex data flow scenario.
Now that we’ve covered data flow within the context of a navigation stack, it is now time to explore a common challenge involving tabbed navigation.
Data Sharing in Tabbed Navigation
Within the domain of tabbed navigation, the frequent necessity to seamlessly share data across diverse sections of our app becomes readily apparent. This demand often stems from the importance of maintaining data synchronized across tabs, as is evident with user profile settings. When users make adjustments to their settings, they anticipate immediate reflection of these changes across all facets of the app. Another example are e-commerce applications, where the shopping cart's accessibility and editability across various tabs is indispensable for a fluid and intuitive shopping experience.
To help understand how we can tackle the task of data sharing when using a TabView in SwiftUI, let’s imagine we are developing a health app. In this app we have a workouts tab, nutrition tab, and settings tab. In the workouts tab users can track exercises like running, cycling, etc. The nutrition tab is used to input foods and beverages users consume. Both tabs use units of measurement like miles for running and cups for water consumed. Now, we want to allow users to set their preferred measurement system like imperial vs metric through the settings tab. This setting should be reflected by the units used in the workouts and nutrition tabs.
As in the previous section above, we could use initializers to pass the shared data to each of our app’s tabs. But as we discussed, this method can quickly become unwieldy as the complexity of the amount and type of data we need to share increases. Luckily, we can use the cousin of @Environment, which is @EnvironmentObject.
@EnvironmentObject is a decorator similar to @Environment except that it holds objects that conform to ObservableObject. This is important for two reasons. First, it holds a reference type, meaning changes made in one tab can be seen by any other tab holding a reference to that object and vice versa. Second, any view with a reference to the ObservableObject will be invalidated when it changes. All this means is that any view with a reference to the EnvironmentObject can make changes to it and be certain that those updates will be reflected immediately wherever it is used. This leads to a consistent and responsive user experience across all sections of the app, making it easier for users to track their fitness goals.
The code below demonstrates how the root screen would set the environment object for UserProfileSettings in our TabView.
Initialize the UserProfileSettings object.
Supply the UserProfileSettings object to the view hierarchy using the environmentObject(_:) view modifier.
As with @Environment, the code above has now made the UserProfileSettings observable object available to all the views in the TabView via the @EnvironmentObject decorator.
The app's tabs now have easy access to the UserProfileSettings object and can read or edit it.
Remember, any view reading the UserProfileSettings object will be updated when ever the measurement system is updated from the settings tab. As soon as the measurement system is updated by users, the workouts tab and nutrition tab will reflect the change.
Conclusion
As we conclude the second leg in our journey through iOS navigation using SwiftUI, we have covered the basics by examining how to use a navigation stack and a tabbed interface in our first post, and in this post we learned how to manage data flow and data sharing through out our navigation framework in a clean and maintainable fashion. Still there is more to explore in the realm of iOS navigation with SwiftUI, as we will see in the subsequent posts of this series. If you would like to be among the first to know when the next blog post is available, please make sure to subscribe to our mailing list. Thank you for joining along in this exciting journey and hope to see you in the next one!