Video #295: Cross-Platform Swift: New Features
Episode: Video #295 Date: Sep 16, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep295-cross-platform-swift-new-features

Description
We’ve already covered a lot of ground and could have ended the series last week, but let’s do a few more things to show just how powerful cross-platform domain modeling can be by adding a new feature to our cross-platform application and see just how easy it is to integrate with SwiftUI, UIKit, and WebAssembly.
Video
Cloudflare Stream video ID: d1f1477e493f013593b107105e00829b Local file: video_295_cross-platform-swift-new-features.mp4 *(download with --video 295)*
Transcript
— 0:05
This is truly some exciting stuff. We have now recreated nearly everything we covered in our “Modern UIKit” series, but this time for the web. We have the ability to observe changes in a Swift model to update the UI in the browser, we can leverage bindings to drive navigation from our model, and we can leverage bindings to control HTML input elements and their focus. And it’s all thanks to a tiny set of fundamental tools that we built into our SwiftNavigation library that is completely cross platform. Brandon
— 0:31
A lot of people were surprised when they saw we were devoting any time at all to UIKit, and many thought we were wasting our time. But this is why. UIKit gave us a fresh sandbox to play around with observation and navigation concepts from scratch, without the baggage of SwiftUI, and that led us to build truly powerful tools. Stephen
— 1:10
And we could end the series right here, but we have one last thing we want to show off. We’ve done a good job of showing how to take a model that was originally built to power SwiftUI and UIKit apps and apply it to a web app too. But we haven’t built any new features in our model. Brandon
— 1:26
Let’s see what it takes to add a few new features to our model, and then update our various apps to take advantage of those new features. Timer
— 1:41
The first feature we are going to add to our model is that of a timer. Timers are a great way to explore how to deal with long living effects, which is a source of great complexity in real world apps. We will have it so that we can turn on the timer, and for each tick we will increment the feature’s count. And we will also give the user the ability to stop the timer.
— 2:01
We are going to start from a purely domain modeling perspective by working in our CounterModel . This code is fully cross platform and currently compiles for both Apple’s platforms and Wasm. So, we never want to introduce view- or platform-specific code to this model, and we want to make its public API very clear so that when we integrate the model into a platform’s view it is clear what we need to do.
— 2:32
We can start by adding a method on the model that can be called from the view for toggling the timer: func toggleTimerButtonTapped() { }
— 2:48
And we need to make sure it’s public since this CounterModel class is in its own module now: public func toggleTimerButtonTapped() { }
— 2:59
In this method we will want to spin up an unstructured Task so that we can start the timer in parallel to the rest of the logic running in the model: func toggleTimerButtonTapped() { Task { // start timer } }
— 3:07
But we will also need a handle on this task so that we can cancel it at a later time. So, let’s add a property to the model, it can even be private: private var timerTask: Task<Void, Error>?
— 3:18
And we can add a computed property to the model that let’s the outside know when the timer is running or not, based on the state of timerTask : public var isTimerRunning: Bool { timerTask != nil }
— 3:44
And now we can beef up the logic in toggleTimerButtonTapped by first checking if the timer is running, and if so cancel, and otherwise start a new task: func toggleTimerButtonTapped() { timerTask?.cancel() if isTimerRunning { timerTask = nil } else { timerTask = Task { // start timer } } }
— 4:42
And then the question is how do we want to handle the timer? We could of course do the simplest thing by looping forever with a sleep inside: while true { try await Task.sleep(for: .seconds(1)) count += 1 }
— 5:01
That certainly gets the job done, but there are a few things to not like about it.
— 5:05
First, this form of timer is very imprecise. When we perform a Task.sleep it does not sleep for exactly 1 second. It can sleep for a little bit more or less. The difference is probably only a few milliseconds or less, but over time that can add up to the timer being off my many seconds. So ideally we would account for this drift so that we have an accurate timer.
— 5:30
And second, performing sleeps like this is inserting side effects directly into our domain. That makes testing this feature more difficult, both from the perspective of unit testing and running the feature in a preview or on a device.
— 5:45
Luckily we have just the tool for this. Our Dependencies library comes with a controllable clock right out of the box, and we can add it to our model like so: @PerceptionIgnored @Dependency(\.continuousClock) var clock
— 6:05
And everything here is pure Swift code and so it is immediately cross-platform friendly. The @Dependency property wrapper is cross-platform, Swift’s Clock protocol is cross-platform, and our custom conformances to the Clock protocol are all cross-platform. It’s very powerful to build your features in just pure Swift and not get mired in the details of SwiftUI, UIKit, or what have you.
— 6:45
And now, rather than using an infinite loop with a Task.sleep we can use the timer method on the clock: timerTask = Task { for await _ in clock.timer(interval: .seconds(1)) { count += 1 } }
— 7:21
That’s all it takes to implement our feature from the perspective of the cross-platform, pure Swift model. We could write tests for this in the abstract and make sure everything works as we expect. We won’t do that, but we highly encourage our viewers to do it, and we have lots of past episodes covering these kinds of topics.
— 7:59
Next we will jump to the view. But we actually have 4 completely different view-paradigms that are currently using this model. We have a SwiftUI view, an UIKit view controller, an Epoxy view, which is a declarative view library from Airbnb, and we of course have our Wasm web app.
— 8:11
Let’s just take these one at a time and see how it goes. Wasm
— 8:14
Let’s start with our Wasm app since it’s the most recent version we have built. First we will create a new DOM element to represent the button for toggling the timer: var toggleTimerButton = document.createElement("button")
— 8:47
When the button is tapped we will invoke the method on the model: toggleTimerButton.onclick = .object( JSClosure { _ in model.toggleTimerButtonTapped() return .undefined } )
— 9:07
And then we will add the button to the document’s body: _ = document.body.appendChild(toggleTimerButton)
— 9:25
This nearly all it takes, but if we run the app in the browser we will see that there is no label on the button we created. The label of the button is dynamic. When the timer is running it should say “Stop timer”, and otherwise it should say “Start timer”. In order to perform this logic we need to observe state in the model, and so we do this inside the observe closure: observe { toggleTimerButton.innerText = model.isTimerRunning ? "Stop timer" : "Start timer" … }
— 10:11
If we take it for a spin we will sadly see that the timer does not work. If we open the console up we see some error messages related to an AnyClock , which comes from our use of clock.timer from our dependencies library. When developing multi-platform Swift code, one often encounters discrepancies between platforms, and in this case there is an issue with Wasm that isn’t encountered in other platforms. But we’ve also actually worked around this issue in a more recent version of our Clocks library, so we should be able to do a package update and be good to go…
— 12:48
And that is all it takes and the web app now functions as we would expect! We can start and stop the timer whenever we want, and the label on the button updates automatically! Timers in SwiftUI, UIKit, and Epoxy
— 13:57
Well that wasn’t so bad. We have now implemented a whole new features in our core model, which is built in pure Swift and can be run on any platform that Swift supports. And then with a little bit more work updated our Wasm app to make use of this new feature by adding a toggle timer button, and everything just worked right away. Stephen
— 14:20
Let’s move onto the iOS app, where we actually have this feature built in 3 different view paradigms: SwiftUI, UIKit and using a 3rd party library called Epoxy. We will update all 3 apps to add this new timer feature to show just how easy it is.
— 14:35
However, those apps are not going to be in compiling order because we have made a few changes to the CounterModel since we first built these views, and those changes were breaking. So, let’s get the iOS app target in compiling order, and then we will implement the timer feature in SwiftUI, UIKit and Epoxy. Fixing the build
— 14:54
The biggest change we made is that we moved the CounterModel out of the app target and into its own package, so we now need to depend on that external library.
— 15:01
So, let’s go into the app target settings and add the Counter module as a dependency… With that done we can now import the Counter module in all of our features that need access to it: import Counter
— 15:22
And now we are getting some compilation errors because all of these views were displaying the fact in a sheet, and that was driven off some optional fact state: .sheet(item: $model.fact) { fact in Text(fact.value) }
— 15:32
However, in the Wasm app we refactored this to be an alert and powered by the AlertState type that ships with our Swift Navigation library, and provides a simple data representation of alerts that is appropriate to store in an observable model and is testable.
— 15:59
Luckily it’s quite easy to update our views to use this new state. Our SwiftUI Navigation library comes with a tool that can integrate SwiftUI’s alert view modifier with our AlertState type, and the syntax is super short: .alert($model.alert)
— 16:25
Nothing else needs to be specified because the AlertState type encapsulates all of the information in the alert. It has the title, message and buttons, and so this alert view modifier can use all of that information in order to present an alert.
— 16:41
The UIKit version of the feature can be fixed similarly, except in this case we have a special initializer on UIAlertController that takes AlertState as an argument: present(item: $model.alert) { alert in UIAlertController(state: alert) }
— 17:36
And we have to do the same in the Epoxy version of the feature…
— 18:30
Now everything is compiling, but just to make sure everything is working, let’s run the UIKit feature in the simulator. We can count up a few times, ask for a fact, and then… well, nothing. No fact was loaded.
— 18:56
Luckily we have a helpful purple warning in Xcode letting us know what happened: @Dependency(FactClient.self) has no live implementation, but was accessed from a live context.
— 19:03
This is the exact warning we encountered in our Wasm app when we separated the interface from the implementation of our FactClient dependency. It is letting us know that we are running the app in a live context, yet only the test version of the dependency is being used.
— 19:10
The warning even helpfully tells us how to fix the problem: To fix you can do one of two things: • Conform ‘FactClient’ to the ‘DependencyKey’ protocol by providing a live implementation of your dependency, and make sure that the conformance is linked with this current application. This is exactly what we need to do. We need to link the live implementation of the FactClient interface with the app target so that it is available when running the app.
— 19:31
But, before doing that we need to expose the FactClientLive module as a public library in the Package.swift so that it is accessible outside of the package: .library( name: "FactClientLive", targets: ["FactClientLive"] ),
— 19:40
Now we can go back to app target’s project settings and link the FactClientLive module…
— 19:47
And with that change we can run the app again and see that when we try fetching a fact it… crashes?! func _decode_string( _: UnsafePointer<UInt8>!, _: Int32 ) -> JavaScriptObjectRef { fatalError() } Thread 11: Fatal error
— 19:58
Somehow we are getting caught on a fatalError inside JavaScriptKit. But why is any code in JavaScriptKit even running? We are building for the iOS app, and that should mean we skip any of the JavaScriptKit logic and go straight for URLSession .
— 20:12
We even took the extra steps to prevent JavaScriptKit from compiling when we are building on non-WASI platforms: .product( name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi]) ), .product( name: "JavaScriptEventLoop", package: "JavaScriptKit", condition: .when(platforms: [.wasi]) ),
— 20:28
What is going on?
— 20:29
Well, sadly this a bug in Xcode and yet another example of how tricky it can be to work on cross-platform apps in Swift. While SPM does support the idea of conditional dependencies, where you get to completely prevent building certain dependencies when target certain platforms, it seems that Xcode does not respect this setting.
— 20:47
Xcode is deciding to build JavaScriptKit and JavaScriptEventLoop even though we have asked it not to. And that means when we try to check if those frameworks are available: #if canImport(JavaScriptKit) && canImport(JavaScriptEventLoop) …so that we can decide how to perform the network request, we are getting into this #if and executing JavaScriptKit code in our iOS app.
— 21:18
And if we look at the top of the file where the fatalError happens we will see a comment that explains what is going on: /// Note: /// Define all runtime function stubs which are imported from JavaScript /// environment. SwiftPM doesn't support WebAssembly target yet, so we /// need to define them to avoid link failure. When running with /// JavaScript runtime library, they are ignored completely.
— 21:21
JavaScriptKit provides a stub implementation of the various functions that ultimately call out to JavaScript functions in the browser. When running in a Wasm environment these functions will correctly work, but outside of a Wasm environment they just crash.
— 21:34
This is honestly a huge bummer. What this means is that there is no way to prevent compilation of Wasm-specific libraries when building for iOS. We unfortunately are going to incur the compile-time costs as well as binary size costs, and there doesn’t seem to be a way around this. We have filed a bug with Apple, but we are not sure when or if it will ever be fixed.
— 21:53
However, even though we can’t prevent this Wasm code from compiling in our app, we can at least prevent it from running . Instead of checking if certain frameworks are available to import, we will just check the operating system being run. We will do that for the import: #if os(WASI) @preconcurrency import JavaScriptKit import JavaScriptEventLoop #else import Foundation #endif And the network requests: #if os(WASI) let response = try await JSPromise( JSObject.global.fetch!("http://www.numberapi.com/\(number)") .object! )!.value return try await JSPromise(response.text().object!)!.value.string! #else return try await String( decoding: URLSession.shared .data( from: URL(string: "http://www.numberapi.com/\(number)")! ).0, as: UTF8.self ) #endif
— 22:20
With that done the app does now work like it did before. We can fetch a fact for any number, and it does not crash. SwiftUI
— 22:38
OK, our project is back in working order after all of those refactors we made previously, and so let’s now add the parts of the view that interact with the timer functionality of the model. It actually doesn’t take much at all. We can just add a button for toggling the timer and it can call the toggleTimerButtonTapped method on the model: Button(model.isTimerRunning ? "Stop timer" : "Start timer") { model.toggleTimerButtonTapped() }
— 23:08
And now we have a fully functional timer in our counter feature. Because all of the logic and behavior of the timer was extracted out of the view and put into the observable model, there really wasn’t that much work to do in the view. And this is why it can be powerful to keep logic out of the view, as well as keep view-related concepts out of the model layer. UIKit
— 23:42
And with that done let’s move onto UIKit. This is also going to be very straightforward. In the viewDidLoad of our controller we will create a button that invokes toggleTimerButtonTapped when it is pressed: let toggleTimerButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in self?.model.toggleTimerButtonTapped() } )
— 24:10
And we will add the button to the UIStackView : let counterStack = UIStackView(arrangedSubviews: [ … toggleTimerButton, ])
— 24:21
And finally we will update the observe closure so that it sets the title of the toggle button based on whether or not the timer is running: observe { [weak self] in … toggleTimerButton.setTitle( model.isTimerRunning ? "Stop timer" : "Start timer", for: .normal ) }
— 24:33
And just like that our UIKit version of this feature now has a functioning timer. Epoxy
— 25:01
The Epoxy version of this feature is also quite easy, and in fact it’s even a little easier than the UIKit version. First we add a new DataID case for the row that will hold the timer button: private enum DataID { … case toggleTimerButton }
— 25:16
And then in the items computed property we can add a ButtonRow for the timer button: ButtonRow.itemModel( dataID: DataID.toggleTimerButton, content: ButtonRow.Content( text: model.isTimerRunning ? "Stop timer" : "Start timer" ), behaviors: ButtonRow.Behaviors( didTap: { [weak self] in self?.model.toggleTimerButtonTapped() } ) )
— 25:44
And just like that our Epoxy feature is now fully functioning. Next time: cross-platform persistence Stephen
— 25:57
We have now added a brand new piece of functionality to our humble counter feature, that of a timer, and we approached this in a very interesting way. We first concentrated on the core domain of the feature, which is the observable model. We implemented the timer functionality in full isolation without a care in the world for the view. The timer functionality is already complicated enough on its own without having to worry about the view. It’s a long-living effect and we had to manage its lifecycle manually.
— 26:21
And then when it came time to update the views it was quite easy. We just had to add a button to the UI to invoke the appropriate method on the model. And our feature now works on two different platforms and many different view paradigms. Brandon
— 26:33
Let’s add one more feature to our little counter feature. Sometimes when I get a fact for a number I learn something very interesting about that number. Let’s make it so that we can save our favorite facts and display them in a list in our view. And further, we will make it so that we can delete any facts that we no longer like.
— 26:56
Let’s dig in…next time! Downloads Sample code 0295-cross-platform-pt6 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .