Slide 1

Slide 1 text

Pierluigi Cifani, TheLeftBit, February 2024 SwiftUI in an Async World

Slide 2

Slide 2 text

Let ’ s start with hard truths…

Slide 3

Slide 3 text

UIKit is not going anywhere • UIKit is one of the most successful UI framework in the history of software. • Incredibly versatile, scaling from the iPhone to the Mac. • Is the backbone of iOS and will be for many years to come.

Slide 4

Slide 4 text

UIKit can be great!

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

No content

Slide 7

Slide 7 text

❤ UIKit

Slide 8

Slide 8 text

• We have a huge investment in UIKit infrastructure • This is what has allowed us to build complex apps with a relatively small team. • A rewrite is unrealistic. UIKit is not going anywhere

Slide 9

Slide 9 text

VideoAsk Naturitas

Slide 10

Slide 10 text

So why SwiftUI?

Slide 11

Slide 11 text

SwiftUI Transition goals • Better interoperability with Swift Concurrency. • Better developer experience. • Better user experience. • Do not reinvent the wheel!

Slide 12

Slide 12 text

• Most of “Data” or “State” is remote. • It's behind some kind of HTTP call • This should be first class citizen. • No unnecessary optionals! SwiftUI Transition goals 􀽑 􀟜

Slide 13

Slide 13 text

Let ’ s code!

Slide 14

Slide 14 text

Let ’ s build something! • A View that displays a List of Books. • The data is in a remote web server. • Loading, Error and List mode should be available.

Slide 15

Slide 15 text

struct Book: Identifiable, Decodable { let id: UUID let title: String let author: String }

Slide 16

Slide 16 text

@MainActor class DataModel: ObservableObject { @Published var isLoading: Bool @Published var error: Swift.Error? @Published var books: [Book] init(isLoading: Bool = false, error: Error? = nil, books: [Book] = []) { } }

Slide 17

Slide 17 text

struct BookList: View { @ObservedObject var dataModel: DataModel var body: some View { if dataModel.isLoading { ProgressView() } else if let error = dataModel.error { ErrorView(error: error) } else { List(dataModel.books, id: \.id) { book in VStack { Text(book.title) .foregroundStyle(.primary) Text(book.author) .foregroundStyle(.secondary) } } } } }

Slide 18

Slide 18 text

How do we populate the data?

Slide 19

Slide 19 text

extension DataModel { func populateData() async { self.isLoading = true let request: URLRequest = ... do { let response = try await URLSession.shared.data(for: request).0 let books = try JSONDecoder().decode([Book].self, from: response) self.books = books } catch { self.error = error } self.isLoading = false } }

Slide 20

Slide 20 text

struct BookList: View { var body: some View { { ... } .task { await dataModel.populateData() } } }

Slide 21

Slide 21 text

This works… right? 🤔

Slide 22

Slide 22 text

No content

Slide 23

Slide 23 text

Let ’ s look back Several code smells • The if {} else if {} else {} branch in the view's body will sure become more and more complex as we add features to this view. • Cyclomatic Complexity will spiral out of control • Could be improved with an enum but the core issue remains. • Previews will hit the actual network. • These should be treated as Unit Tests: should work regardless of the network status.

Slide 24

Slide 24 text

How can we improve this?

Slide 25

Slide 25 text

Async Initialization The first breakthrough • The first breakthrough in the way we architecture SwiftUI apps was that we could add two initializers to our DataModel objects: • An async initalizer for when the view is run regularly. • A sync initializer when the view is Previewed

Slide 26

Slide 26 text

extension DataModel { init(urlSession: URLSession) async throws { self.books = try await urlSession.fetchBooks() } init(books: [Book]) { self.books = books } }

Slide 27

Slide 27 text

Async Initialization What have we achieved with this? • We can remove those pesky properties: • var isLoading: Bool • var error: Error? • We are instead using the Swift way: async throws. • SwiftUI Previews work now by using the sync initialiser. • If Previews don ’ t work, it ’ s not SwiftUI!

Slide 28

Slide 28 text

Async Initialization What have we achieved with this? • But how do we use this new initialiser from production code? • We can ’ t set the @ObservedObject of a view asynchronously • Enter AsyncView

Slide 29

Slide 29 text

struct BookListAsync: View { let urlSession: URLSession @State var loadingPhase = LoadingPhase.loading enum LoadingPhase { case loading case loaded(DataModel) case failed(Swift.Error) } var body: some View { contentView .task { self.loadingPhase = .loading do { let dataModel = try await DataModel(urlSession: urlSession) self.loadingPhase = .loaded(dataModel) } catch { self.loadingPhase = .failed(error) } } }

Slide 30

Slide 30 text

@MainActor @ViewBuilder private var contentView: some View { switch loadingPhase { case .loading: ProgressView() case .failed(let error): ErrorView(error: error) case .loaded(let dataModel): BookList(dataModel: dataModel) } }

Slide 31

Slide 31 text

struct BookList: View { @ObservedObject var dataModel: DataModel var body: some View { List(dataModel.books, id: \.id) { book in VStack(alignment: .leading) { Text(book.title) .foregroundStyle(.primary) Text(ListFormatter.localizedString(byJoining: book.authors)) .foregroundStyle(.secondary) } } } }

Slide 32

Slide 32 text

What have we gained? • The BookList is only created when there is actual data to show. • It made no semantical sense for it to be holding an empty array • Reduced complexity • For example, any error that occurs after BookList is on screen is handled (marking a book as read for example) is handled in a different context

Slide 33

Slide 33 text

What have we gained? • Automatic cancellation! • By using .task {}, if the user navigates away, the Task is automatically canceled

Slide 34

Slide 34 text

Better Interoperability with Swift Concurrency: ✅

Slide 35

Slide 35 text

How can we make it better? • BookListAsync is still tightly coupled to BookList and it ’ s DataModel. • Options to make it reusable are: • Use Swift Macros to auto-generate the code • Use Swift Generics to inject the View.

Slide 36

Slide 36 text

AsyncView struct Async: View { let urlSession: URLSession var body: some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } }

Slide 37

Slide 37 text

AsyncView struct Async: View { let urlSession: URLSession var body: some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } id: A String to the identify this view.

Slide 38

Slide 38 text

AsyncView struct Async: View { let urlSession: URLSession var body: some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } dataGenerator: The function that generates the data that is required for your HostedView

Slide 39

Slide 39 text

AsyncView struct Async: View { let urlSession: URLSession var body: some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } hostedViewGenerator: The function that creates the HostedView

Slide 40

Slide 40 text

AsyncView struct Async: View { let urlSession: URLSession var body: some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } errorViewGenerator: The function that creates the ErrorView

Slide 41

Slide 41 text

AsyncView struct Async: View { let urlSession: URLSession var body: some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } loadingViewGenerator: The function that creates the LoadingView

Slide 42

Slide 42 text

AsyncView struct Async: View { let urlSession: URLSession var body: some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } The actual dependencies of the View 👻

Slide 43

Slide 43 text

AsyncView struct Async: View { let urlSession: URLSession var body: some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } }

Slide 44

Slide 44 text

No content

Slide 45

Slide 45 text

No content

Slide 46

Slide 46 text

No content

Slide 47

Slide 47 text

Better DX: ✅

Slide 48

Slide 48 text

What else can this thing do?

Slide 49

Slide 49 text

But there ’ s more Let ’ s change the id parameter

Slide 50

Slide 50 text

But there ’ s more Let ’ s change the id parameter

Slide 51

Slide 51 text

But there ’ s more Default Error Screens

Slide 52

Slide 52 text

But there ’ s more Customizable Error Screens

Slide 53

Slide 53 text

But there ’ s more Measure server performance

Slide 54

Slide 54 text

But there ’ s more Measure server performance

Slide 55

Slide 55 text

But there ’ s more Skeleton loading /// A protocol that de fi nes types that return placeholder data to be used for Previews or loading states. public protocol PlaceholderDataProvider { associatedtype Data static func generatePlaceholderData() -> Data }

Slide 56

Slide 56 text

But there ’ s more Skeleton loading /// A protocol that de fi nes types that return placeholder data to be used for Previews or loading states. public protocol PlaceholderDataProvider { associatedtype Data static func generatePlaceholderData() -> Data } extension BookList: PlaceholderDataProvider { static func generatePlaceholderData() -> DataModel { .init(books: [Book(…), Book(…), …]) } }

Slide 57

Slide 57 text

But there ’ s more Skeleton loading

Slide 58

Slide 58 text

But there ’ s more Skeleton loading struct Async: View { let urlSession: URLSession var body: some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) } ) } }

Slide 59

Slide 59 text

No content

Slide 60

Slide 60 text

No content

Slide 61

Slide 61 text

Better UX: ✅

Slide 62

Slide 62 text

TheLeftBit/NSBarcelona-Feb24

Slide 63

Slide 63 text

What about async actions?

Slide 64

Slide 64 text

Async Actions • Actions that happen after the data is loaded can also be async • For example, marking a book as read or subscribing to a Reading Club

Slide 65

Slide 65 text

AsyncButton

Slide 66

Slide 66 text

TheLeftBit/BSWInterfaceKit

Slide 67

Slide 67 text

No content

Slide 68

Slide 68 text

No content

Slide 69

Slide 69 text

Where does this leave us?

Slide 70

Slide 70 text

Takeaways • These abstractions do not work against SwiftUI. • Do not reinvent the wheel! • Lightweight. • Dramatically improved Developer Experience • Deterministic Previews help a lot!

Slide 71

Slide 71 text

Takeaways • How to inject dependencies is up to the developer • @EnvironmentObject is fine • Interoperability with UIKit is done via UIHostingController. • Rewrites of specific screens have drastically reduced the codebase size. • You can always “drop down” to UIKit if SwiftUI doesn ’ t provide what you need.

Slide 72

Slide 72 text

Takeaways • As engineers, we should focus on delivering value to our customers. Always. • Make the right decision for you and your team after carefully assessing. • Keep iteration cycles short!

Slide 73

Slide 73 text

Don ’ t fight the Platform

Slide 74

Slide 74 text

Slide 75

Slide 75 text