Slide 1

Slide 1 text

Felipe Espinoza. 08/04/2025 SwiftUI at SATS Lessons from our app evolution with SwiftUI

Slide 2

Slide 2 text

2021 2025

Slide 3

Slide 3 text

Using SwiftUI since 2020

Slide 4

Slide 4 text

Users in the nordic countries per month ~550000

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

🇳🇴 🇸🇪 🇩🇰 🇫🇮 4 countries 2 Brands

Slide 7

Slide 7 text

iPhone 􀟜 􀟠 iPad 􀟤 Apple Watch Available on 🇳🇴 🇸🇪 🇩🇰 🇫🇮 4 countries 2 Brands

Slide 8

Slide 8 text

iPhone 􀟜 􀟠 iPad 􀟤 Apple Watch Available on 🇳🇴 🇸🇪 🇩🇰 🇫🇮 4 countries 2 Brands

Slide 9

Slide 9 text

No content

Slide 10

Slide 10 text

Android

Slide 11

Slide 11 text

iOS

Slide 12

Slide 12 text

PM

Slide 13

Slide 13 text

Design

Slide 14

Slide 14 text

Backend

Slide 15

Slide 15 text

Android iOS PM Backend Design

Slide 16

Slide 16 text

Android iOS PM Backend Design

Slide 17

Slide 17 text

whoami 🇨🇱🇳🇴 7 years developing for apple platforms Tech lead for the iOS app at SATS Youtuber

Slide 18

Slide 18 text

No content

Slide 19

Slide 19 text

No content

Slide 20

Slide 20 text

No content

Slide 21

Slide 21 text

Learnings from the evolution of the SATS app with SwiftUI

Slide 22

Slide 22 text

JSON app

Slide 23

Slide 23 text

No content

Slide 24

Slide 24 text

Agenda Snapshot Testing Basic Architecture SPM modularization

Slide 25

Slide 25 text

Agenda Snapshot Testing Basic Architecture SPM modularization

Slide 26

Slide 26 text

No content

Slide 27

Slide 27 text

import Foundation import SATSSnapshots import SwiftUI import Testing import SATSCore @testable import Booking @MainActor struct BookingLandingViewTests { @Test( "Danish New Member with no GX, only PT trial", arguments: SnapshotVariant.fixedHeightVariants( height: 900, navigationTitle: "Danish New Member with no GX, only PT trial", backgroundColor: .backgroundSecondaryDefault ) ) func danishNewMemberNoGxOnlyPt(_ variant: SnapshotVariant) async throws { let view = BookingLandingView(viewData: .sampleDanishNewMemberWithoutGxOnlyPtTrial()) expectSnapshot(of: view, on: variant) } }

Slide 28

Slide 28 text

No content

Slide 29

Slide 29 text

How it helps? We can test UI in multiple settings easily

Slide 30

Slide 30 text

Device iPhone, iPad (+ Model) Locale En, Nb, … Dynamic Type Large, Extra Large, … Region Norway, Chile, … Interface Style Light, Dark Orientation Portrait, Landscape, … Split screen Full screen, half screen, … Device Con fi guration View states Loading Error Empty Content Normal Content Content Situation #1 Content Situation #2 Content Situation #3

Slide 31

Slide 31 text

Combination Count

Slide 32

Slide 32 text

How it helps? We can test UI in multiple settings easily Alerts unintentional changes in the UI, useful with design systems We can register and see the full content of scrollable content

Slide 33

Slide 33 text

import Foundation import SATSSnapshots import SwiftUI import Testing import SATSCore @testable import Booking @MainActor struct BookingLandingViewTests { @Test( "Danish New Member with no GX, only PT trial", arguments: SnapshotVariant.fixedHeightVariants( height: 900, navigationTitle: "Danish New Member with no GX, only PT trial", backgroundColor: .backgroundSecondaryDefault ) ) func danishNewMemberNoGxOnlyPt(_ variant: SnapshotVariant) async throws { let view = BookingLandingView(viewData: .sampleDanishNewMemberWithoutGxOnlyPtTrial()) expectSnapshot(of: view, on: variant) } }

Slide 34

Slide 34 text

No content

Slide 35

Slide 35 text

No content

Slide 36

Slide 36 text

No content

Slide 37

Slide 37 text

No content

Slide 38

Slide 38 text

How to maintain consistent results between tests runs? what about CI?

Slide 39

Slide 39 text

New booking landing page Multiple di ff erent modules Which modules appear depend on user, the same as the content of said modules Polymorphic data structures "Smartness" part of the backend, the clients just render that JSON

Slide 40

Slide 40 text

New booking landing page Multiple di ff erent modules Which modules appear depend on user, the same as the content of said modules Polymorphic data structures "Smartness" part of the backend, the clients just render that JSON

Slide 41

Slide 41 text

"type": "Recommendations", "content": { "title": "What are you looking for?", "tabs": [ { "name": "For you", "items": [ { "type": "GroupExerciseClass", "content": { "id": "1234", "durationInMinutes": 45, "startTime": { "dateTime": "2025-08-21T00:00:00+00:00", "timeZone": "Europe/Oslo" }, "club": { "id": "1234566", "name": "Nydalen", "fullName": "SATS Nydalen" }, "instructorName": "Sandshrew", "classType": { "id": "123", "name": "Indoor Running", "description": "This is a class where you box things", "imageUrl": "https://images.ctfassets.net/bton54gi9dnn/1RLKjJn5thgmg6Jri7Orvn/09962b5059f4c218d23a9f2b51378eb0/Yoga_General.jpg? "fitnessScore": [ { "name": "Strength", "score": 1 } ] }, "bookingInfo": { "capacity": 10, "bookedCount": 15, "waitingListCount": 5, "bookingState": { "type": "BookedOnWaitingList", "content": { "waitingListPosition": 3, "participationProbability": "Low" }

Slide 42

Slide 42 text

"id": "123", "name": "Indoor Running", "description": "This is a class where you box things", "imageUrl": "https://images.ctfassets.net/bton54gi9dnn/1RLKjJn5thgmg6Jri7Orvn/09962b5059f4c218d23a9f2b51378eb0/Yoga_General.jpg? "fitnessScore": [ { "name": "Strength", "score": 1 } ] }, "bookingInfo": { "capacity": 10, "bookedCount": 15, "waitingListCount": 5, "bookingState": { "type": "BookedOnWaitingList", "content": { "waitingListPosition": 3, "participationProbability": "Low" } } }, "friendBookings": [ { "member": { "id": "1234", "firstName": "Cezinando", "lastName": "Scicily", "imageUrl": "https://picsum.photos/512" }, "bookingState": { "type": "BookedOnWaitingList", "content": { "waitingListPosition": 3, "participationProbability": "Low" } } } ] } },

Slide 43

Slide 43 text

Simple Data tailored for the view ViewData

Slide 44

Slide 44 text

No content

Slide 45

Slide 45 text

struct RecommendationGxCardViewData: Identifiable { let id: GX.GroupExerciseID let name: String let startTime: String let clubName: String let durationInMinutes: String let image: ImageViewData? let friendsJoining: FriendJoiningViewData? let tag: TagViewData? let destination: Destination }

Slide 46

Slide 46 text

struct RecommendationGxCardViewData: Identifiable { let id: GX.GroupExerciseID let name: String let startTime: String let clubName: String let durationInMinutes: String let image: ImageViewData? let friendsJoining: FriendJoiningViewData? let tag: TagViewData? let destination: Destination } public enum ImageViewData: Equatable { case empty case remote(url: URL) case image(_ image: Image) }

Slide 47

Slide 47 text

extension RecommendationGxCardViewData { static func previewValue( name: String = "Indoor Running", startTime: String = "Tomorrow 08:00", clubName: String = "Nydalen", durationInMinutes: String = "45 min", image: ImageViewData? = .image(Image("gxClass1", bundle: .module)), friendsJoining: FriendJoiningViewData? = .previewValue(), tag: TagViewData? = nil ) -> Self { .init( id: .randomPreviewId(), name: name, startTime: startTime, clubName: clubName, durationInMinutes: durationInMinutes, image: image, friendsJoining: friendsJoining, tag: tag, destination: .booking ) } }

Slide 48

Slide 48 text

Making UI easy to test, makes it easy to preview

Slide 49

Slide 49 text

Then it will be tested in more configurations

Slide 50

Slide 50 text

No content

Slide 51

Slide 51 text

import Foundation import SATSSnapshots import SwiftUI import Testing import SATSCore @testable import Booking @MainActor struct BookingLandingViewTests { @Test( "Landing page with all components", arguments: SnapshotVariant.fixedHeightVariants( height: 2400, navigationTitle: "Booking", backgroundColor: .backgroundSecondaryDefault ) ) func landingPageWithAllComponents(_ variant: SnapshotVariant) { let view = BookingLandingView(viewData: .previewValue()) expectSnapshot(of: view, on: variant) } }

Slide 52

Slide 52 text

import Foundation import SATSSnapshots import SwiftUI import Testing import SATSCore @testable import Booking @MainActor struct BookingLandingViewTests { @Test( "Landing page with all components", arguments: SnapshotVariant.fixedHeightVariants( height: 2400, navigationTitle: "Booking", backgroundColor: .backgroundSecondaryDefault ) ) func landingPageWithAllComponents(_ variant: SnapshotVariant) { let view = BookingLandingView(viewData: .previewValue()) expectSnapshot(of: view, on: variant) } }

Slide 53

Slide 53 text

Agenda Snapshot Testing Basic Architecture SPM modularization

Slide 54

Slide 54 text

Data Loading States Idle Loading Data Loaded Error Input Same fl ow for load of any kind of data The request fi res Received data Encountered an error Reloading Retry

Slide 55

Slide 55 text

When the data is loaded… DTO ViewData Map to Highly testable function

Slide 56

Slide 56 text

FeatureScreen FeatureView Basic Architecture #0 FeatureViewData ActionsProtocol Stateful Stateless

Slide 57

Slide 57 text

FeatureScreen FeatureView Basic Architecture #1 FeatureViewModel FeatureViewData ActionsProtocol Handles Networking Business Logic Orchestrate Render

Slide 58

Slide 58 text

Basic Architecture #1 FeatureScreen FeatureView FeatureViewModel FeatureViewData ActionsProtocol Unidirectional data fl ow Split between stateful and stateless views Mutations will trigger a new view data value that will update the view We don’t use this pattern when it doesn’t fi t the problem

Slide 59

Slide 59 text

Agenda Snapshot Testing Basic Architecture SPM modularization

Slide 60

Slide 60 text

Modularizing with SPM packages We use local packages, then we don’t pay integration costs Help code reuse across targets Faster compilation/iteration Explicit boundaries in code, less global sharing

Slide 61

Slide 61 text

No content

Slide 62

Slide 62 text

Base Infrastructure Features Targets • Models • Design Constants • Tracking Events • Networking • Design System • Navigation • Booking • Friends • Member Pro fi le • iOS • watchOS • Widgets Low High Complexity

Slide 63

Slide 63 text

Base Infrastructure Features Targets • Models • Design Constants • Tracking Events • Networking • Design System • Navigation • Booking • Friends • Member Pro fi le • iOS • watchOS • Widgets Extracted from Figma Need dependency injection

Slide 64

Slide 64 text

Base Infrastructure Features Targets • Models • Design Constants • Tracking Events • Networking • Design System • Navigation • Booking • Friends • Member Pro fi le • iOS • watchOS • Widgets

Slide 65

Slide 65 text

Each package has* * Or can have Assets 􀏅 Localizations 􀆪 Tests 􁁛 Docs 􀦋

Slide 66

Slide 66 text

Conclusion SwiftUI is excellent to render content and make multiple components Snapshot testing drives you to build better UI SwiftUI is quite fl exible when it comes to the choice of architecture Dividing code in SPM packages has multiple bene fi ts

Slide 67

Slide 67 text

Building UI that is easy to Preview and Test with SwiftUI 17:30 Advanced Navigation for SwiftUI apps 12:24 Snapshot testing for iOS apps in Xcode Cloud 14:00 Recommended videos More details related to this talk

Slide 68

Slide 68 text

THANKS!