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.
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.
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:
- Part that holds the navigation path and drives the navigation stack and presentation.
- 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.
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.
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.
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.
Navigation
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.
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.