VOOZH about

URL: https://dzone.com/articles/swiftdata-dependency-injection-in-swiftui-applicat

⇱ SwiftData Dependency Injection in SwiftUI Application


Related

  1. DZone
  2. Software Design and Architecture
  3. Security
  4. SwiftData Dependency Injection in SwiftUI Application

SwiftData Dependency Injection in SwiftUI Application

Refactoring the default SwiftData + SwiftUI project example. Removing SwiftData from SwiftUI and replacing Environment with a vanilla Dependency Injection.

By Sep. 27, 23 · Tutorial
Likes
Comment
Save
6.5K Views

Join the DZone community and get the full member experience.

Join For Free

Most of the examples Apple provides to demonstrate Dependency Injection in SwiftUI use @Environment. When creating a new project with SwiftData in XCode, you'll notice that the template uses Environment for injecting the modelContext.

Swift
struct ContentView: View {
 @Environment(\.modelContext) private var modelContext // <-- 1
 @Query private var items: [Item]

 var body: some View {
 NavigationSplitView {
 List {
 ...
 }
 .toolbar {
 ToolbarItem {
 Button(action: addItem) {
 ...
 }
 }
 }
 }
 ...
 }

 private func addItem() {
 withAnimation {
 let newItem = Item(timestamp: Date())
 modelContext.insert(newItem) // <-- 2
 }
 }

 ...
}

#Preview {
 ContentView()
 .modelContainer(for: Item.self, inMemory: true) // <-- 3
}


But what exactly is wrong here? For simple applications, nothing may be amiss, and this approach should function perfectly. However, when working on a rapidly growing application with a large codebase and multiple developers involved, we may encounter some scaling issues. Let's take a closer look at our current setup:

  1. We use the @Environment property wrapper to gain access to the Environment context of the View. Considering Environment as a sort of Dependency Injection (DI) container for the entire View hierarchy, we can place any dependencies at a high level and retrieve them in any other view below (modelContext), making dependency injection significantly simpler in SwiftUI.
  2. We use modelContext, which we just retrieved from Environment, to insert a new item.
  3. We are setting up a modelContainer with the given type available in the scope of our View.

Let's try to challenge this approach:

  • I will start by addressing the main issue that we often see in Apple-provided examples: combining UI and Business Logic in the same function. In our addItem function, we are doing something that our View should not be aware of. We are violating the Single Responsibility Principle by having the View handle both UI and business logic when, ideally, the View should only be responsible for handling user input and notifying another object about it. The specific actions taken by the other object with this information should not be the responsibility of the View.
  • We are working at the implementation level, not at the interface level. The modelContext is an instance of ModelContext, a class and not a protocol, which means we have direct access to the object instance. As such, we can directly manipulate the object's properties and methods without any abstraction or indirection. For example, if we need to retrieve this data from the network or use another data provider in the future, we will need to update our View implementation accordingly.
  • We do not control the lifecycle of the modelContext.
  • We assume here that the data we store is the same data we want to present, which is why we have @Query private var items: [Item] that will automatically trigger a UI update when we add a new item to the list. However, this is not always the case. You may store additional information that you use to prepare a final result for the user, and your UI may not always be an exact reflection of your data schema.

Target Solution

Let's try to think about how we can refactor it in a way that solves all the issues listed above.

Here is our current diagram:

And let's take a look at the diagram we want to achieve:

Separate Data Layer

First of all, let's separate the data layer from the view and make it a bit more generic. I will add a protocol called DataProvidable and a DataProvider class that will sit behind this protocol. As we all know, naming is one of the most challenging aspects of software development, so perhaps DataProvidable is not the best name, but I've chosen it because it does exactly what its name suggests — provide data.

Swift
protocol DataProvidable: AnyObject {
 func getItems() throws -> [Item]
 func set(item: Item) throws
}


Let's also create a separate class for the model that we will store in persistent storage:

Swift
@Model
final class ItemModel {

 var timestamp: Date

 init(timestamp: Date) {
 self.timestamp = timestamp
 }
}


The model that we will use in SwiftUI and our ViewModel is the following:

Swift
struct Item: Identifiable {
 let id: UUID = UUID()
 let timestamp: Date
}


Now, we have a good foundation to move forward with the solution, and the view and view model are decoupled from the way we are storing and providing data. Since we have a protocol for our data layer, let's implement something behind it:

Swift
class DataProvider: DataProvidable {

 private var context: ModelContext

 init(context: ModelContext) {
 self.context = context
 }

 func getItems() throws -> [Item] {
 let items = try context.fetch(FetchDescriptor<ItemModel>())
 return items.map { Item(timestamp: $0.timestamp) }
 }

 func set(item: Item) throws {
 context.insert(
 ItemModel(timestamp: item.timestamp)
 )
 try context.save()
 }
}


We get injected with a ModelContext, and we do not expose it to the outside world. So, the DataProvider only knows how we store the data and where we obtain it from. Another benefit is that it allows us to create a very simple MockDataProvider that can be used in SwiftUI previews:

Swift
class MockDataProvider: DataProvidable {

 func getItems() throws -> [Item] {
 return [
 Item(timestamp: Date())
 ]
 }

 func set(item: Item) throws { }
}


View and ViewModel

Since we have our data layer and have mentioned the ViewModel several times, let's finally create it:

Swift
@Observable
final class ViewModel {

 var items: [Item] = []
 private let dataProvider: DataProvidable

 init(dataProvider: DataProvidable) {
 self.dataProvider = dataProvider
 do {
 items = try dataProvider.getItems()
 } catch {
 // Error handling
 }
 }

 func onAdd() {
 let item = Item(timestamp: Date())
 do {
 try dataProvider.set(item: item)
 items.append(item)
 } catch {
 // Error handling
 }
 }
}


We do not use the exact DataProvider type here, but the protocol gives us the ability to inject anything that will be behind it and work on the interface level, not the implementation. For example, with such an implementation, we can easily cover this ViewModel with unit tests in a fully isolated environment.

Let's also update our view by utilizing everything we've previously created:

Swift
struct ContentView: View {

 private let viewModel: ViewModel

 init(viewModel: ViewModel) {
 self.viewModel = viewModel
 }

 var body: some View {
 NavigationSplitView {
 List {
 ...
 }
 .toolbar {
 ToolbarItem {
 Button(action: viewModel.onAdd) {
 ...
 }
 }
 }
 }
 ...
 }
}

#Preview {
 ContentView(
 viewModel: ViewModel(
 dataProvider: MockDataProvider()
 )
 )
}


As you can see, we no longer have a SwiftData dependency in the View. We are also using our MockDataProvider to make the preview work, and we can play with the data there the way we want to present any kind of data in the SwiftUI preview. There is no business logic in the View anymore. All we do is just call the onAdd function from the ViewModel.

How To Use It in the App Now

Currently, our implementation of View takes an instance of ViewModel injected. Our View + ViewModel is now a standalone, isolated component that can be used anywhere in the app. However, for the sake of completeness, let's explore one architecture option that we could use in the application.

First, let's create a Coordinator that will be responsible for providing a View that is currently being presented in the app:

Swift
@Observable
final class Coordinator {

 var rootView: AnyView = AnyView(EmptyView())

 private var modelContainer: ModelContainer?

 init() {
 Task { @MainActor in
 setUpView()
 }
 }

 @MainActor
 private func setUpView() {
 guard let modelContainer = try? ModelContainer(for: ItemModel.self) else {
 // Error handling
 return
 }
 self.modelContainer = modelContainer
 rootView = AnyView(
 ContentView(
 viewModel: ViewModel(
 dataProvider: DataProvider(
 context: modelContainer.mainContext
 )
 )
 )
 )
 }
}


To keep things simple, we will also prepare all the dependencies in the coordinator as well. In more complex applications, all the responsibilities can be decoupled into separate components like Router, ViewFactory, etc.

In the setUpView function, we are preparing the ContentView. Now, we need to create an additional high-level View that will serve as a container for our application:

Swift
struct AppView: View {

 private var coordinator: Coordinator

 init(coordinator: Coordinator) {
 self.coordinator = coordinator
 }

 var body: some View {
 coordinator.rootView
 }
}

#Preview {
 AppView(coordinator: .init())
}


This view gets Coordinator injected very similarly to the way we implemented it with a View and ViewModel.

The last step of our exercise will be to update the entry point to our application:

Swift
@main
struct SwiftDataTestApp: App {

 var body: some Scene {
 WindowGroup {
 AppView(coordinator: .init())
 }
 }
}


Conclusion

We went through a simple refactoring of the default SwiftData example from Apple. Here is a short summary of what we have achieved:

In conclusion, we have refactored the default SwiftData example from Apple to create a more modular and maintainable architecture for our application. Here is a brief overview of what we have accomplished:

  • Removed SwiftData from the View component
  • Separated the View and ViewModel components
  • Created a separate data layer that is hidden by the DataProvidable protocol
  • Added a DataProvider component with SwiftData logic
  • Used a pure dependency injection approach through the init method instead of using Environment

Such refactoring will make our code more flexible, testable, and more aligned with SOLID principles than we had before. Keep in mind that this is just one of many options for structuring our application architecture. If we have a very simple basic app that works alone, the approach suggested by Apple may work for us. However, if we are building an application with multiple developers working on the same codebase, covering it with tests, and planning for possible changes and updates in the future, we may need to consider alternative approaches, and the example presented above is one of the options.

Here, you can find a project: SwiftData and SwiftUI refactoring.

Dependency injection UI Dependency Injection Swift (programming language)

Opinions expressed by DZone contributors are their own.

Related

  • Why I Started Using Dependency Injection in Python
  • How to Make a Picture-in-Picture Feature in iOS App Using AVFoundation
  • Angular Best Practices For Developing Efficient and Reliable Web Applications
  • HLS Streaming With AVKit and Swift UI for iOS and tvOS

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

Let's be friends: