Video #294: Cross-Platform Swift: UI Controls
Episode: Video #294 Date: Sep 9, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep294-cross-platform-swift-ui-controls

Description
We will introduce UI controls and focus logic to our SwiftWasm application by leveraging a binding type inspired by SwiftUI, and we will see how similar even our view logic can look across many platforms.
Video
Cloudflare Stream video ID: a238c1eb41639439493600185f312fb9 Local file: video_294_cross-platform-swift-ui-controls.mp4 *(download with --video 294)*
Transcript
— 0:05
This is absolutely incredible. We are now powering a pretty complex web-based feature from our Swift code base. We have been able to share a ton of logic and behavior between different view paradigms and deployment platforms, and it’s all thanks to our dedicated effort to prioritize domain modeling over view-related concerns.
— 0:22
We have been very strict with ourselves to extract as much of the logic and behavior of our features into a dedicated observable model, rather than sprinkling that code throughout the view. And it is now paying off. Brandon
— 0:32
But there is one last feature in our CounterModel that we explored for UIKit that we have not yet recreated in our web app. Back when dealing with UIKit we wanted to show how one can bind properties of an observable model to various UI controls, such as steppers and text fields. And we further wanted to show how even focus of text fields could be controlled via state.
— 0:53
Let’s explore what this looks like from the perspective of the web and Wasm. Control bindings
— 0:59
It turns out that HTML has a native stepper component just like we explored in both UIKit and SwiftUI in our previous series of episodes. We can hop over to the browser console to explore it.
— 1:13
You start by creating an input element: let counter = document.createElement("input")
— 1:24
And then you set the type of the input to be a "number" input: counter.type = "number"
— 1:29
And then finally we can add it to the document’s body to see what it looks like: document.body.appendChild(counter)
— 1:54
This looks very similar to how SwiftUI natively renders a stepper.
— 1:59
So, what does it take for us to use one of these inputs to encapsulate the idea of an increment and decrement button, along with a visualization of the current value? Let’s give it a shot.
— 1:11
Luckily the JavaScript we just sketched out directly corresponds to Swift code we can write. We will replace the countLabel , decrementButton and incrementButton for just a single number input: var counter = document.createElement("input") counter.type = "number" _ = document.body.appendChild(counter)
— 2:57
And already we can see the stepper input in the browser.
— 3:05
But currently its value is completely disconnected from the value in our model. We can verify this by counting up and then asking for a fact. We unfortunately get a fact about 0 instead of the number being displayed.
— 3:26
Just as we saw with UIKit, there is a two-step process to keep our model in sync with this UI input. We need to observe changes in the model to play them back to the input, and we need to observe changes to the input to play them back to the model.
— 3:42
The first step is easy enough. We will just use the observe tool again to observe changes to the model’s count property, and then play that change to the input’s value : observe { _ in counter.value = .number(Double(model.count)) } .store(in: &tokens)
— 4:26
We can even verify that works by changing the model.count to a random number after a second delay: Task { try await Task.sleep(for: .seconds(1)) model.count = .random(in: 1...1_000_000) }
— 4:42
Running this in the browser shows that the stepper does indeed update when the count changes.
— 4:49
So that takes care of one side of the synchronization story. The other side is listening for changes in the stepper and playing it back to the model. To do that we need to tap into the onchange of the count input, which will be a JSClosure : counter.onchange = .object( JSClosure { _ in return .undefined } )
— 5:36
And now we need to get the value of the input in this closure so that we can update the model. We can get leverage the argument passed to JSClosure , which represents the arguments of the closure in an array: counter.onchange = .object( JSClosure { arguments in return .undefined } )
— 6:12
This value is very similar to what UIKit does too. When you add a UIAction to a UI control, the action is invoked with a sender , and that gives you all the information you need to update the model.
— 6:25
We can do something similar here, where we go through the first event: let event = arguments[0]
— 6:32
Then we access the target of the event: event.target
— 6:35
This is a reference to the input that has changed. We can grab its value : event.target.value
— 6:39
…which we expect to be a string since the value property of an input in JavaScript returns a string. event.target.value.string
— 6:44
And then finally we can convert the string to an integer so that we can assign it to the model: model.count = event.target.value.string.flatMap(Int.init) ?? 0
— 7:00
And we will now see that our web app is back to working as we expect. We can count up to a number, request a fact, and we receive a fact for that number rather than 0.
— 7:21
So this is looking pretty promising, but of course the code we wrote is not what we would want to have to do every time we want to bind our model to a UI input element. It’s a lot of code and there’s a lot of subtly to get wrong. It’d be a lot better if we had a reusable helper that could bind any property of an observable model to any property of an input element.
— 7:44
Let’s theorize what such a tool would look like. What if there was bind method on HTML elements: counter.bind
— 7:57
The first argument could be the property on the model we want to bind to, which we would want to express as a UIBinding : counter.bind($model.count
— 8:21
Further we would need to describe the property on the HTML element that we want to bind to: counter.bind($model.count, to: \.value
— 8:28
And finally we also need to describe the event that is published on the input element when its value changes so that we can play that change back to the model: counter.bind($model.count, to: \.value, event: \.onchange)
— 8:42
This would be pretty great, so let’s see what it takes to make a reality.
— 8:46
Let’s first get a signature of the bind method in place, but what type should it be defined on? If we option-click on count variable we will see its type: var count: JSValue So that’s the type we will extend: extension JSValue { func bind() { } }
— 9:06
The first argument of this method is a binding of some value that comes from our model: extension JSValue { func bind<Value>( _ binding: UIBinding<Value> ) { } }
— 9:21
Next we need to take a key path defined on the HTML component so that we can update it: extension JSValue { func bind<Value>( _ binding: UIBinding<Value>, to keyPath: WritableKeyPath<???, ???> ) { } }
— 9:35
…and this is where things get tricky.
— 9:36
The JSValue type is an enum that describes all of the different data types in JavaScript: @dynamicMemberLookup public enum JSValue: Equatable { case boolean(Bool) case string(JSString) case number(Double) case object(JSObject) case null case undefined case function(JSFunction) case symbol(JSSymbol) case bigInt(JSBigInt) … }
— 9:43
Since JavaScript is a dynamically typed language, every value can technically be one of these types. However, in the case of DOM elements, and in particular the input controls we are dealing with, they are only objects.
— 10:02
And JSObject is a class: @dynamicMemberLookup public class JSObject: Equatable { … }
— 10:04
So, our key path needs to be able to read and write to a property on a JSObject , and the property will be another JSValue : to keyPath: WritableKeyPath<JSObject, JSValue>
— 10:18
For example, in the case of the counter input we would use the key path: \JSObject.value
— 10:29
And because JSObject is a class we can even use a ReferenceWritableKeyPath : to keyPath: ReferenceWritableKeyPath<JSObject, JSValue>
— 10:34
…that way we do not need to make the bind method mutating and we can perform mutations in an escaping closure.
— 10:42
The third and final argument of this method is the event that we need to listen for so that we can update the model when the input changes. It will also be a ReferenceWritableKeyPath since we will be using it to set a property of the element: event: ReferenceWritableKeyPath<JSObject, JSValue>
— 11:00
Now let’s see what it takes to implement this method.
— 11:11
First of all, this bind method can only work if our JSValue is actually an object, so we could check that right at the beginning: guard let object else { return }
— 11:35
Right now this will just silently exit if the JSValue is not an object, but that’s not a very friendly developer experience.
— 11:41
It would be better to report this as an issue in a more public manner, but also in a way that is not too obtrusive. And luckily we have just the tool for this, and we’ve already used this tool once before in this series.
— 12:28
We can use our Issue Reporting library to let the user know that they have used this API incorrectly: guard let object else { reportIssue("'bind' only works on objects") return }
— 12:49
Now if we did something non-sensical, like try binding to a JSValue.string : JSValue.string("").bind($model.text, to: \.value, event: \.foo)
— 13:13
…we will see a message appear in the console letting us know that something isn’t quite right. WasmApp/Navigation.swift:137 - ‘bind’ only works on objects
— 13:21
We can also improve this message quite a bit. First of all, we can thread the file and line information through the function so that the console warning lets us know where the function was called: func bind<Value>( _ binding: UIBinding<Value>, to keyPath: ReferenceWritableKeyPath<JSObject, JSValue>, event: ReferenceWritableKeyPath<JSObject, JSValue>, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) { … guard let object else { reportIssue( "'bind' only works on objects", fileID: fileID, filePath: filePath, line: line, column: column ) return } }
— 14:11
And now the warning message is a lot more helpful: WasmApp/App.swift:49 – ‘bind’ only works on objects
— 14:27
We again want to remark how it’s pretty incredible that we are using all of the same patterns and techniques that we like to employ in our iOS apps, such as reporting issues early and often, but this is all being applied to a web app.
— 14:59
OK, so we now have access to the JSObject that needs to be connected to the binding handed to this method. We can start by observing changes to the binding: observe { binding.wrappedValue }
— 15:16
When the binding changes we want to play that change back to the input. We have a writable key path for the underlying object of the input, so we can subscript in and assign with the binding’s value: object[keyPath: keyPath] = binding.wrappedValue
— 15:40
However, this does not work because binding.wrappedValue is a generic Value , and the key path goes into a JSValue . We need someway to convert from the generic Value world, which is in the domain of our Swift code, to the nebulous JavaScript world of JSValue s.
— 15:54
Luckily the JavaScriptKit library comes with such a tool. There is a protocol called ConvertibleToJSValue that expresses the concept of being able to convert a type in the Swift to the JavaScript world. If we constrain Value to that protocol: func bind<Value: ConvertibleToJSValue>(
— 16:14
…then we immediately get the ability to convert our value to a JSValue : object[keyPath: keyPath] = binding.wrappedValue.jsValue
— 16:18
And now this is compiling, though we need to isolate the helper to avoid some sendability warnings: @MainActor
— 17:02
And we have the observation token to worry about. Again we will just pass the buck to the caller of this method by having it return the observation token: func bind<Value: ConvertibleToJSValue>( _ binding: UIBinding<Value>, to keyPath: ReferenceWritableKeyPath<JSObject, JSValue>, event: String ) -> ObservationToken { … } And then we can return the token returned from observe : return observe { … }
— 17:21
We also have to return a token from the guard , and that can just be a freshly created token: guard let object else { reportIssue( "'bind' only works on objects", fileID: fileID, filePath: filePath, line: line, column: column ) return ObservationToken() }
— 17:32
OK, we have now implemented half of the responsibilities for this method, that of observing changes in the binding to play them back to the control. We now need to go in the opposite direction. We need to listen for changes in the control so that we can play them back to the binding.
— 17:49
We’ve done this already a few times, albeit in a more ad hoc fashion. For example, previously we set the onchange property on our count input in order to execute some logic when the input changed: count.onchange = .object( JSClosure { events in … return .undefined } )
— 18:03
We need to do this exact thing, but more generally for an arbitrary event. To do this we can just subscript into the object: object[keyPath: event] = .object( JSClosure { _ in return .undefined } )
— 18:27
That allows the caller of the API to provide any event that want for the binding logic. Then, inside this JSClosure we will play back the newest value held in the HTML element back to the binding. This will take a few steps to get right.
— 18:37
First of all we need to extract the value from the element. We have a key path that can do the extraction, and we even already have the object that we want to extract from, so we can key path into this object to get a JSValue : let jsValue = object[keyPath: keyPath]
— 18:46
So we now have a JSValue , but that isn’t something we can directly assign to the binding: binding.wrappedValue = jsValue
— 18:54
We need to somehow convert this JSValue to the generic Value type.
— 19:04
We did the opposite direction a moment ago, that of converting a Value to a JSValue , by using the ConvertibleToJSValue protocol. There is another protocol for going the other direction, and it is called ConstructibleFromJSValue . So, let’s constrain the Value generic to that protocol too: func bind<Value: ConvertibleToJSValue & ConstructibleFromJSValue>(
— 19:24
And there’s actually 3rd protocol that is the composition of these two protocols called JSValueCompatible , so we can shorten this to: func bind<Value: JSValueCompatible>(
— 19:36
This protocol gives us the ability to construct a Value from the JSValue : guard let object else { return .undefined } let jsValue = object[keyPath: keyPath] guard let value = Value.construct(from: jsValue) else { return .undefined }
— 20:09
If we get past this guard then we finally have a value we can write to the binding: binding.wrappedValue = value return .undefined
— 20:12
That is the basics of this method, let’s give it a spin. The theoretical syntax we sketched a moment ago is now compiling: counter.bind($model.count, to: \.value, event: \.onchange)
— 20:21
But we do have a warning about the value returned from bind . We need to make sure to store that observation token: counter.bind($model.count, to: \.value, event: \.onchange) .store(in: &tokens)
— 20:28
Now this compiles without warnings, and hopefully it just works. However, we will find that if we count up and request a fact, we get back a fact about 0. Not the number that is being displayed.
— 20:38
So, it seems that the changes made to the input element are not being played back to the model. The reason this is happening is because $model.count is a binding of an integer , but the value property on JSValue is a string.
— 20:52
This is causing us to get caught in the guard of our bind method because the Value.construct(from:) function fails. And this can happen anytime you accidentally have a mismatch in types between your model and the HTML element you are binding to.
— 21:13
So, rather than silently ignoring that problem in the bind method, it would be a better idea to report an issue: guard let jsValue = events.first?.target.object?[keyPath: keyPath], let value = Value.construct(from: jsValue) else { reportIssue( "Could not convert value.", fileID: fileID, filePath: filePath, line: line, column: column ) return .undefined }
— 21:35
We can even greatly improve the error message to make it very clear what exactly went wrong. Let’s paste in the following helper for getting a good description of a JSValue : func jsValueDescription(_ value: JSValue) -> String { switch value { case .boolean(let value): return "JSValue.boolean(\(value))" case .string(let value): return "JSValue.string(\"\(value)\")" case .number(let value): return "JSValue.number(\(value))" case .object(let value): return "JSValue.object(\(value))" case .null: return "JSValue.null" case .undefined: return "JSValue.undefined" case .function(let value): return "JSValue.function(\(value))" case .symbol(let value): return "JSValue.symbol(\(value))" case .bigInt(let value): return "JSValue.bigInt(\(value))" } }
— 21:54
And then update the issue message like so: "Could not convert \(jsValueDescription(jsValue)) to \(Value.self)",
— 22:15
And now when we run the demo in the browser we can clearly see that something is wrong when we try to increment the count: WasmApp/App.swift:47 – Could not convert JSValue.string(“1”) to Int We see that we the value we are extracting from the HTML element when it changes is a string of “1”, and that does not immediately convert to an integer. We need to do a little bit of extra work to get our binding into the proper shape to use with this input element.
— 22:53
To fix this we need to transform our UIBinding of an integer into a UIBinding of a string, and we can do that by leveraging dynamic member lookup. We can define a get / set computed property on Int for converting to a String : extension Int { fileprivate var toString: String { get { String(self) } set { self = Int(newValue) ?? 0 } } }
— 23:22
And now we can transform our binding with this property: counter.bind($model.count.toString, to: \.value, event: \.onchange) .store(in: &tokens)
— 23:32
And now our demo is back to working. With just one line of code we can now bind any field of an observable model to any field of an HTML input element.
— 23:41
Now that we have a fully featured and working binding helper, let’s make use of it a bit more. Our CounterModel has a string property that we could bind to a text field. We can first create a new input field: var textField = document.createElement("input")
— 24:14
And then we can set its type to “text” to turn it into a text field: textField.type = "text"
— 24:18
Then we can add it to the document’s body: _ = document.body.appendChild(textField)
— 24:32
And finally we can bind the model’s text property to the text field: textField.bind($model.text, to: \.value, event: \.onkeyup) .store(in: &tokens)
— 25:06
And now each key stroke in the text field will be played to the model. Focus
— 26:47
We now have the ability to bind HTML input controls directly to our observable model so that a change in one will immediately be played back to the other. We’ve done this with a stepper and a text field, but it would work with any kind of UI control. Stephen
— 27:02
There is another kind of binding that is very common for HTML controls, like text fields, and that’s focus. This is something that SwiftUI did a very good job with. Their tools for focus allow you to drive the focus of UI components via state, and we were even able to recreate that for UIKit. And amazingly, we can do the same for web apps.
— 27:19
Let’s take a look.
— 27:22
Recall that the way we previously explored focus in our UIKit app was to add some state that determines if the text field is focused: var isTextFocused = false
— 27:28
And then we added some silly logic just to get the focus changing where we decided that whenever the count changes to a non-multiple of 3 the text field will be focused: var count = 0 { didSet { isTextFocused = !count.isMultiple(of: 3) } }
— 27:37
This is of course a very silly thing to do, but it does drive home the fact that we can programmatically control focus in our app.
— 27:44
So, what does it take to drive focus in our web app? Well, the APIs for focusing and unfocusing an HTML element are quite simple. There is a focus method that will focus the element, and there is a blur method that will unfocus.
— 27:54
So, sounds like we need to start up a new observation to observe changes in the isTextFocused state: observe { if model.isTextFocused { } else { } } .store(in: &tokens)
— 28:24
And when the text field is focused we can invoke the focus method on the textField element, and otherwise we can invoke blur : observe { _ in if model.isTextFocused { _ = textField.focus() } else { _ = textField.blur() } } .store(in: &tokens)
— 28:42
And with just that little bit of code we can already see the basics of state-driven navigation work. When we run the app in the browser and count up with the stepper, we will see that when the count is a non-multiple of 3, like 1, 2, 4, 5, 7, 8, etc., then the text field focuses. But once the count is a multiple of 3, like 3, 6, 9, the text field is unfocused.
— 28:54
It’s pretty amazing how quickly we were able to get the basics into place, but it’s also not quite right yet. We have only captured half of what it means to drive focus with state. We further need to listen for focus changes in the text field so that we can update the model accordingly. Because right now if we click into the text field and then out of the text field manually, our model is not updating. And that means if we have logic in the model that uses the isTextFocused boolean it could be using the wrong value.
— 29:21
So, we need to listen for these focusing and unfocusing events so that we can update the model accordingly. The way one listens for focus and unfocus events is by setting the onfocus and onblur event handlers on the HTML element: textField.onfocus = .object( JSClosure { _ in return .undefined } ) textField.onblur = .object( JSClosure { _ in return .undefined } )
— 29:55
And then in each of these closures we can update the model by setting the isTextFocused state to true or false : textField.onfocus = .object( JSClosure { _ in model.isTextFocused = true return .undefined } ) textField.onblur = .object( JSClosure { _ in model.isTextFocused = false return .undefined } )
— 30:07
And that’s all it takes. To see that this works we can print out the isTextFocused state whenever it changes: var isTextFocused = false { didSet { print("isTextFocused", isTextFocused) } }
— 30:25
And now when we run the app in the browser we will see that clicking into the text field and out of the text field prints the focus to the console: isTextFocused true isTextFocused false isTextFocused true isTextFocused false
— 30:33
So this is pretty great, but of course we wouldn’t want to have to write all of this code each time we need to control focus of an HTML element. Wouldn’t it be better if we just had a simple method we could invoke on an HTML element that binds its focus to a UIBinding<Bool> that could be derived from our model: textField.bind(focus: $model.isTextFocused) .store(in: &tokens)
— 31:05
And then all the details of observation and event handling would be hidden inside the bind helper.
— 31:09
Well, this is absolutely possible, so let’s head over to Navigation.swift and give it a shot. We can start by sketching the signature of the method that we want to implement: extension JSValue { func bind( focus binding: UIBinding<Bool>, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) -> ObservationToken { } }
— 31:44
And just as with our other bind helper, the first thing we can do is make sure that this JSValue is actually a JSObject , and if not we can report an issue: guard let object else { reportIssue( "'bind' only works on objects", fileID: fileID, filePath: filePath, line: line, column: column ) return ObservationToken() }
— 31:54
Next we can set the onfocus and onblur event handlers on this object, and when they are invoked we will set the binding’s value to either true or false : object.onfocus = .object( JSClosure { _ in binding.wrappedValue = true return .undefined } ) object.onblur = .object( JSClosure { _ in binding.wrappedValue = false return .undefined } )
— 32:25
And then finally we can observe changes to the binding in order to figure out when to focus or blur the object: return observe { if binding.wrappedValue { _ = object.focus?() } else { _ = object.blur?() } }
— 33:13
And that is all it takes. Our theoretical syntax is now compiling, and everything works just as it did before. And it even looks a lot like the UIKit Navigation version of this API: let textField = UITextField(frame: .zero, text: $model.text) textField.bind(focus: $model.isTextFocused)
— 33:58
It is even possible to beef up this API so that focus is driven not just by a boolean, but any equatable value, just as one does in SwiftUI. For example, we could have an enum that describes each HTML element that can be focused: enum Focus { case counter case textField }
— 34:15
And then we would hold onto an optional value in our model: var focus: Focus?
— 34:19
And with that we could associate one of these values to each HTML element that can be focused: textField.bind(focus: $model.focus, equals: .textField) counter.bind(focus: $model.focus, equals: .counter)
— 34:33
It doesn’t take much to implement such a method, and we highly recommend our viewers to give it a shot as an exercise. Next time: One more thing
— 34:39
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 Swift Navigation library that is completely cross platform. Brandon
— 35:05
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
— 35:44
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
— 36:00
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…next time! Downloads Sample code 0294-cross-platform-pt5 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 .