TCA+SwiftUI+Javascript, step 2

mein
3 min readOct 16, 2023

Embed a javascript library into SwiftUI View, and use TCA to send and receive javascript events. Step 2, embed Javascript files.

There are 3 steps in this series of articles,

Step 2 is based on Step 1. The mainly change is the JavascriptWKCoordinatorWebView which would load javascript files.

public struct JavascriptWKCoordinatorWebView: ViewRepresentable {
unowned private var coordinator: BasicHTMLWKCoordinator
let eventNames : [String]
let htmlFileUrl : URL
let jsFiles : [URL]
public init(coordinator: BasicHTMLWKCoordinator,
eventNames: [String] = WKMessageHandlersEvent.allCases.map({$0.rawValue}) ,
htmlFileUrl: URL = Bundle.main.url(forResource: "index", withExtension: "html")!,
jsFiles: [URL] = []
) {
self.coordinator = coordinator
self.eventNames = eventNames
self.htmlFileUrl = htmlFileUrl
self.jsFiles = jsFiles
}
public func makeNSView(context: Context) -> WKWebView {
return makeUIView(context: context)
}
public func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
for jsFile in jsFiles {
let jsString = try! String(contentsOf: jsFile)
let wkUserScript = WKUserScript(source: jsString, injectionTime: .atDocumentStart, forMainFrameOnly: true)
config.userContentController.addUserScript(wkUserScript)
}
for eventName in eventNames {
config.userContentController.add(coordinator, name: eventName)
}
let _wkwebview = WKWebView(frame: .zero, configuration: config)
_wkwebview.navigationDelegate = coordinator
if #available(macOS 13.3, iOS 16.4, tvOS 16.4, *) {
_wkwebview.isInspectable = true
//https://webkit.org/blog/13936/enabling-the-inspection-of-web-content-in-apps/
}
return _wkwebview
}
public func updateNSView(_ nsView: WKWebView, context: Context) {
updateUIView(nsView, context: context)
}
public func updateUIView(_ uiView: WKWebView, context: Context) {
uiView.loadFileURL(htmlFileUrl,allowingReadAccessTo: htmlFileUrl)
}
}
  • event1.js

In event1.js, we will listen to DOMContentLoaded event and call webkit.messageHandlers.DOMContentLoaded accordingly.

//event1.js
document.addEventListener('DOMContentLoaded', function () {
webkit.messageHandlers.DOMContentLoaded.postMessage('DOMContentLoaded');
});

Thus we need to register the event name in the WKMessageHandlersEvent so that JavascriptWKCoordinatorWebView would recognize it.

public enum WKMessageHandlersEvent: String, CaseIterable{
case test
case DOMContentLoaded
}

In the SwiftUI view, every time the @State variable changes, it will cause the JavascriptWKCoordinatorWebView to reload the html file. If we move the @State variable to a sub view then the problem could be solved.

struct SubView: View {
@State var receiveMessage : String = ""
var body: some View {
Text(receiveMessage)
.onReceive(NotificationCenter.default.publisher(for: .WKCoordinatorNotification)) {
receiveMessage = ( ($0.object as? WKScriptMessage)?.body as? String ) ?? ""
}
}
}
struct JavascriptWKCoordinatorSwiftUIView: View {
@Environment(\.wkCoordinator) private var coordinator: BasicHTMLWKCoordinator
var wkView : JavascriptWKCoordinatorWebView {
return .init(coordinator: coordinator
, eventNames: WKMessageHandlersEvent.allCases.map({$0.rawValue})
, htmlFileUrl: Bundle.main.url(forResource: "index", withExtension: "html")!
, jsFiles: [
Bundle.main.url(forResource: "event1", withExtension: "js")!
]
)
}
var body: some View {
VStack{
SubView()
wkView
}
}
}
  • event2.js

We change the index.html so it would call the function in event2.js

<!-- index.html -->
<div id="click_me" style="font-size: 120;" onclick="getTime();">get time</div>
//event2.js
function getTime(){
window.webkit.messageHandlers.test.postMessage(Date());
}

And load event1.js and event2.js together.

struct SubView: View {
@State var receiveMessage : String = ""
var body: some View {
Text(receiveMessage)
.onReceive(NotificationCenter.default.publisher(for: .WKCoordinatorNotification)) {
receiveMessage = ( ($0.object as? WKScriptMessage)?.body as? String ) ?? ""
}
}
}
struct JavascriptWKCoordinatorSwiftUIView: View {
@Environment(\.wkCoordinator) private var coordinator: BasicHTMLWKCoordinator
var wkView : JavascriptWKCoordinatorWebView {
return .init(coordinator: coordinator
, eventNames: WKMessageHandlersEvent.allCases.map({$0.rawValue})
, htmlFileUrl: Bundle.main.url(forResource: "index", withExtension: "html")!
, jsFiles: [
Bundle.main.url(forResource: "event1", withExtension: "js")!,
Bundle.main.url(forResource: "event2", withExtension: "js")!
]
)
}
var body: some View {
VStack{
SubView()
wkView
}
}
}

With Web Inspector, we see the two javascript files are loaded as user-scripts. There is no need to write <script>...</script> in the index.html

The complete code is here.

--

--