Skip to main content

SwiftUI standards

This section will cover DECODE determined SwiftUI standard. It covers:

Please note that all examples here are for illustrative purposes, simplified in order to focus on the point of the particular subject. All further implementation details and requirements are defined in the Coding guidelines section.

Folder organization

To have an unified experience across all our code-base, use folder structure as follows.

Global items are defined at root level, e.g.

📁 Project
📁 Features
📁 Resources
📁 Routers
📁 Services
📁 Shared

Majority of code will likely live in the Features folder. Feature consists of all code related to a screen (or group of screens). This includes Views, View Models, Protocol definitions, Routers, Models and anything related to that particular feature. Rule of thumb is that if you need to remove a feature or move it e.g. to external module, deleting or moving the folder should suffice. If not, this indicates there is some accidental tight coupling. Also, please note the above does not define exclusive list of items needed - just a visual representation on how to organize.

An example of a simple feature folder structure might be:

📁 Features
📁 LoginFeature
📁 Router
📄 LoginRouter.swift
📁 ViewModel
📄 LoginViewModel.swift
📁 View
📄 LoginView.swift

If needed, one feature can embed sub-features, e.g. TabView for logged in state of the app containing sub-feature for each tab:

📁 Features
📁 LoginFeature
📁 MainFeature
📁 FirstTabFeature
📁 SecondTabFeature
...

Also, please note regarding files, they are for visualization purposes only, e.g. you may choose to separate protocol definition and implementation for a view model from a single file.

All feature-related code should be somewhere in the feature folder hierarchy. Root folders should contain only shared items and items that are not bound to a single feature.

Dependency injection

All dependencies should be defined via protocols and injected via initializers - so use dependency inversion. In the 3rd party dependencies there are some DI frameworks suggested, however in general we use the simple approach of constructor injection. It is simple and effective, without depending on 3rd party code.

If possible, injection should be done via generic constraints

protocol AnalyticsServiceProtocol { }

final class AnalyticsService: AnalyticsServiceProtocol { }

final class LoginViewModel<Analytics: AnalyticsServiceProtocol> {
private let analyticsService: Analytics

init(analyticsService: Analytics) {
self.analyticsService = analyticsService
}
}

This guarantees static dispatch, hence better performance at runtime.

Same goes for injecting View models into Views, e.g.


struct MainView<VM: MainViewModelProtocol>: View {
@StateObject private var viewModel: VM

init(vm: VM) {
_vm = StateObject(wrappedValue: vm)
}

...
}

This is because protocol existentials (e.g., any MainViewModelProtocol) cannot be used with @StateObject, since SwiftUI requires a concrete class type that fully conforms to ObservableObject (and can't determine concrete type from existential).

If needed (e.g. when we may need to switch implementation dynamically), it is OK to fall back to existential types, e.g.

protocol AnalyticsServiceProtocol { }

final class AnalyticsService: AnalyticsServiceProtocol { }

final class LoginViewModel {
private let analyticsService: any AnalyticsServiceProtocol

init(analyticsService: any AnalyticsServiceProtocol) {
self.analyticsService = analyticsService
}
}

having in mind this will always use dynamic dispatch i.e. be less performant as compiler can't determine concrete implementation that will be used at compile time.

However, based on project and requirements, it may be acceptable to fallback to a simpler solution, e.g. View Models being a concrete type instead of defined (and injected) via protocols. This still allows View Model testability as its dependencies should still be protocol-based, so mocks can be utilized in UTs. For testing Views, as View Models dependencies are still protocol-based and mocks can (should) be used, we can still control test conditions. Again, this is acceptable but should be used only when determined per other requirements - being business requirements or e.g. using DI framework that can easily be made to resolve to another concrete type for mocking in previews/tests.

Architecture

Our architecture of choice is MVVM with Router for navigation. This is mostly inline with our UIKit architecture so transition should be relatively straightforward.

Each view should have its associated View Model. No Views should share the same View Model. View Model itself should be defined via a protocol and injected to View as such. This means that we can have different View Model implementations for the same View (but not vice-versa!). Please see methods of injection section for dependencies details.

View Models should be either ObservableObject or utilize the @Observable macro, depeding on )iOS) target (<17 or 17+>) or other project requirements. _View Model can have further dependencies (Services, repositories, use cases...) which are also to be defined via protocol and injected via initializer.

danger

Service should notify View Model of changes via protocols, closures, async streams, Rx streams or any other manner, but they should never be ObservableObject themselves, practice that seems convenient but leads to common mistakes. @Observable macro can be an exception as it should probably introduce no issues, but still try to stick to common practices.

note

Please note that while this is our general standard, it may be acceptable to use View Models via concrete types directly, or even fall back to now commonly used MV pattern - but in any case do not confuse simple, but correct implementation with well separated concerns with shortcuts and bad practices.

Routing

As with the Coordinator pattern that we use in MVVM-C, in SwiftUI we use Routers to drive navigation (both stack based NavitationStack or sheet/fullScreenCover presentations).

Router is tasked only with routing and holding navigation state, and can also provide destinations (just as Coordinator does) to unclutter the Views.

A more complex app should have more that one router - break them up to a sensible hierarchy, e.g.

📄 AppRouter.swift
📄 LoginRouter.swift
📄 MainRouter.swift
📄 FirstFeatureRouter.swift
...
📄 SettingsRouter.swift

In this example, AppRouter will handle the authentication state and manage the base UI stack, LoginRouter should handle all login-based navigation, MainRouter might be the logged in TabView router containing various feature (Tab) routers. Each top-level router should have child routers as properties (equivalent to MVVM-C child coordinators). As in MVVM-C it is up to developer to determine the scope of single router responsibilities - it can drive from one screen only or a broader related flow (e.g. full login stack - log in, register, forgot password... screens).

As defined earlier in this document, routers are also defined via protocols and injected into required components. Most generally we'll want to separate two things:

  1. Part that holds the navigation path and drives the navigation stack and presentation.
  2. Part that defined user-triggered actions, like navigating to a particular screen or going back

First is injected into a View in order to be passed as path to NavigationStack or bind to e.g. .sheet modifier. This way, View will have access only to those properties and nothing else, e.g. we can define

protocol PathRoutable: ObservableObject {
var path: [Router.Path] { get set }
var sheet: Router.SheetState? { get set }
}

Router.Path and Router.SheetState are types driving the navigation for a particular router so it makes sense they are objects defined in the Router's scope, e.g.

final class Router: PathRoutable {
enum Path: Hashable {
case secondScreen(String)
}

enum SheetState: Hashable, Identifiable {
case about(String)

var id: SheetState {
self
}
}

Also, please note that stack navigation object must conform to Hashable while presentation one must be Hashable and Identifiable. For that reason, it is suggested to pass any parameters required as primitive types that will describe the intent (e.g. passing model or identifier for further navigation), and now e.g. view models as both hashability and identifiability of reference types may be questionable and also we use protocol-defined view models.

note

Stack navigation driver (enum in our case) must be Hashable, and presentation one both Hashable and Identifiable. Associated type for driver enum should be a plain model object, or just something uniquely identifying the destination, and not View Model as it is likely to be a class and classes as reference types have quiestionable hashability and identifiability, while for value types they are intrinsic.

For the second part - user triggered navigation, we can define the following protocol

protocol Routable {
func navigateToSecondScreen()
func popToRoot()
}

This protocol should be injected to View Model, which will trigger navigation via Router based on user interaction or other triggers. This will ensure View Model will access only generic navigation intents on router and will have no access to router internals.

Example implementation of Router instance may be:

final class Router: PathRoutable {
enum Path: Hashable { ... }
enum SheetState: Hashable, Identifiable { ... }

@Published var path: [Path] = []
@Published var sheet: SheetState?

for the PathRoutable part. We conform in class definition as we require stored properties.

To drive User or business logic driven intents, we conform to Routable protocol in extension, e.g.

note

Names PathRouteable and Routable are used for descriptive purposes; real implementation can use any name that is sensible, however the reasoning behind each protocol is well defined and should be respected.

extension Router: Routable {
func navigateToSecondScreen() {
path.append(.secondScreen("id")
}

func popToRoot() {
path = []
}
}

So now for example View Model

protocol ContentVMProtocol: ObservableObject {
func buttonPressed()
}

final class ContentVM<Router: Routable>: ContentVMProtocol {
private let router: Router

init(router: Router) {
self.router = router
}

func buttonPressed() {
router.navigateToSecondScreen()
}
}

it will be able to access only navigation intents on the Router, and never the internals.

On the other hand, the example view gets only the PathRoutable part to access the Bindings that drive the state, while all navigation intents are triggered view the View Model:

struct ContentView<
PathRouter: PathRoutable,
VM: ContentVMProtocol
>: View {
@StateObject private var vm: VM
@StateObject private var pathRouter: PathRouter

init(
pathRouter: PathRouter,
vm: VM
) {
_pathRouter = StateObject(wrappedValue: pathRouter)
_vm = StateObject(wrappedValue: vm)
}

var body: some View {
NavigationStack(path: $pathRouter.path) {
VStack {
Button {
vm.buttonPressed()
} label: {
Text("Go to second screen")
}
}
.sheet(item: $pathRouter.sheet, content: { ... })
.navigationDestination(for: Router.Path.self, destination: { ... })
.padding()
}
}
}

View can only bind to PathRouter's bindings to drive the NavigationStack and Presentation. Destinations can be also proxied to the router that will generate Destination views, or can be defined inline in view. The choice will often be driven by next screen's dependencies, i.e. if Router has enough information to generate the next View Model (although dependencies can also be injected via function parameters). It is up to developer to determine the best and cleanest solution, always trying to separate concerns and unclutter the views.

Modularization

Most projects will be monolithic, so all code will be in one target. However, it is possible to modularize projects either via Xcode targets or Swift Package Manager, to completely separate all services, features, common code etc. Furthermore, it is very useful to define protocols in a separate target - e.g. have one Swift package for Service and another parallel target for ServiceProtocol with definitions. This way you can just import abstraction to a concrete implementation, as well as have feature depend on abstraction only. In some cases this might significantly influence compile time (e.g. when implementation depends on a "heavy" 3rd party dependency), but is in general a good practice.

It has proven useful to have each feature defined in a separate module. This way you are certain not to introduce unwanted tight couplings, features can be easier to move, reuse in multiple places or just to remove. Common requirements for multiple features can be extracted to another common module.

Using Xcode 16 buildable folders we're less likely to end up with merge conflicts un the .pbxproject file, however if we modularize using SPM modules that scenario is not possible by definition.

tip

Only when using full modularization will you determine there were cases of tight coupling that got under the radar. In cases where you have those kinds of problems, of even circular dependencies - this is an excellent indicator that you should introduce another level of abstraction.


3rd party dependencies

Although this guide suggests we don't rely on any 3rd party code without good reasons, this is not an anti-pattern and not frowned-upon. This section will cover some suggested libraries and frameworks to be used. The list is not exclusive, so please feel free to investigate and suggest others.

Dependency injection

swift-dependencies

swift-dependencies: Commonly used dependency manager, well maintained by respected authors. Commonly used and part of The Composable Architecture. It provides dependencies propagation and very nicely implements overrides for Previews and Unit Tests (even gives warnings when "live" dependencies are used in UTs). The only downside is making overrides for once defined dependency in app code as it uses TaskLocals, so dependencies are tied to Task scope.

Factory

Factory is excellent alternative to Swift Dependencies. It is very powerful and flexible, making dependency injection safe, robust and still flexible. It also makes easy using shared, cached, singleton and scoped dependencies and providing easy overrides. Factory even has a very nice way of using optional dependencies - ones that cannot be instantiated at app start, but can be overridden later.

swiftui-navigation

swiftui-navigation Another Pointfree's contribution, which is basically a wrapper around SwiftUI stock navigation, but with a beautiful and more convenient API. It enables navigation based on observing bindings, suggests using "destinations" to make navigation always well defined with no nonsensical states and more. It is commonly used and well maintained. Tip: Unfortunately SwiftUI native navigation did have some issues especially in earlier SwiftUI versions, but some issues are still present.

tip

As swiftui-navigation is just a wrapper around Apple's implementation making a better API, most of the issues are really SwiftUI navigation issues, so please be aware of that. This means to consult posted github issues and test scenarios in test projects using SwiftUI navigation to determine if this is a pointfree or apple bug.

Architecture

The Composable Architecture

Although we use MVVM with Routers, and can fall back to a simpler solutions when needed, another great approach is using The Composable Architecture. This Swift native, well defined, opinionated compile-time safe and exhaustively Unit testable pointfree's code is native implementation of Redux with integrated side-effects. The only downside is learning curve for developers not familiar. Apart from great docs and many community tutorials, there is a great number of videos on developer's video series web pointfree.co.