Swift and JavaScript interaction
Gualtiero Frigerio
Posted on November 21, 2019
Sometimes you need to share code between iOS and Android, or you have business logic for a customer already written in JavaScript because it is used on the web.
If you need to run JavaScript code on your app loading a page into a WkWebView is one of your options and you can read my previous post about that here. If you're only interested in running JS you can add an hidden WebView to your app and execute code, that's what I've done in the past with UIWebView but since iOS 7 Apple introduced JavaScriptCore enabling deeper integration between Objective-C, and now Swift, with JavaScript.
Suppose you have an app to show a list of products and you can add them to an order. You can apply a discount to each product but you have some checks you need to perform in able to determine how much discount you're allowed to give to your customer. You may have a web service to handle that, or you may do it client side and you can share code between the app and the site since it is written in JavaScript. As usual you can find all the code on GitHub
The sample app is written in SwiftUI but there is nothing worth mentioning about the UI, we'll only focus on the interaction between Swift code and JavaScript.
JavaScriptCore
JavaScriptCore was introduced in iOS 7 back in 2013, you can find the documentation here and this is a link to a WWDC video talking about it. Swift wasn't public back then, so the examples are in Objective-C and may be difficult to follow, but there are some interesting information about memory management and threading.
Before we move on with our example here's a few concepts:
- JSContext: this is an execution environment, corresponding to a single global object, think about the window object in JavaScript.
- JSVirtualMachine: we won't interact with this class in our example, but it may be useful to know that you can use multiple virtual machines to execute JavaScript code concurrently. You can have multiple JSContext into a virtual machine and pass data between them, while context in different VM are isolated.
- JSValue: represents a JavaScript value that you can use in Swift after converting it, and it is tightly coupled to a JSContext.
- JSExport: a protocol to implement if you want to export Swift objects to Javascript
In our example we'll use a single JSContext to call JavaScript functions and get values back, and I'll show you how to export a Swift struct to JavaScript and how to call a Swift function inside JS code.
In my sample project JavaScriptCore interaction is implemented in JSCommunicationHandler, see the code here
private let context = JSContext()
init() {
context?.exceptionHandler = {context, exception in
if let exception = exception {
print(exception.toString()!)
}
}
}
As you can see we have a JSContext and for debug purposes I implemented the exceptionHandler, so every time something bad happens in our JS code I can print it on the console.
Just like web views JSContext can be debugged with Safari Web Inspector, so you can print stuff or set breakpoints while executing JS code within your application.
You can execute JS code by calling evaluateScript on the JSContext and pass a string to it, the call will return an optional JSValue if the code returns something.
Every time you call evaluateScript and the string contains a function or a variable it is save into the context, so if you have multiple source files you need for your project you can call evaluateScript for all of them and at the end start calling the functions you're interested into.
Call a JavaScript function from Swift
If you need to call a JavaScript function you likely need to pass it some parameters, maybe an object. For the sake of our example suppose we add a new product to the order and we want to calculate its total price in JavaScript.
// DataSource
private func getPriceAndDiscountOfProduct(_ product:Product) -> (Double, Double)? {
if let value = jsHandler.callFunction(functionName: "getPriceAndDiscountOfProduct", withData: product, type:Product.self) {
if value.isObject,
let dictionary = value.toObject() as? [String:Any] {
let price = dictionary["price"] as? Double ?? 0.0
let discount = dictionary["discount"] as? Double ?? 0.0
return (price, discount)
}
else {
print("error while getting price and discount for \(product.name)")
}
}
return nil
}
// JSConnectionHandler
func callFunction<T>(functionName:String, withData dataObject:Codable, type:T.Type) -> JSValue? where T:Codable {
var dataString = ""
if let string = getString(fromObject: dataObject, type:type) {
dataString = string
}
let functionString = functionName + "(\(dataString))"
let result = context?.evaluateScript(functionString)
return result
}
The function in JSConnectionHalder make use of Generics, if you're not familiar with them you can have a look at my post about Future and Promise. If you don't want to dive deep into the subject suffice to say the notation allow us to have parameters with different types, so I can pass different objects to callFunction as long as the struct or class conforms to Codable. I use JSONEncoder to convert the object to a string so I can pass it as a parameter to JavaScript, think of it like calling JSON.stringify on an object in JS.
// JavaScript code
function getPriceAndDiscountOfProduct(product) {
var discount = getDiscountForProduct(product);
var price = discountedPrice(product.price, discount);
var totalDiscount = discount * product.quantity;
var totalPrice = price * product.quantity;
return {"price" : totalPrice, "discount" : totalDiscount}
}
The result is an optional JSValue, and as you see I can call isObject on it to check whether this is something I can try to cast as an object. There are similar methods like isNumber, isString, isDate, isArray. If the cast is successful I have a Dictionary with two numbers, you can pass more complex JSONs back and forth if you need to, or you can return simpler values.
Call a Swift function from JavaScript
You can add objects to the JSContext, and these objects can be functions or custom types.
Let's start with functions, first you need to create one and then you can add it to the JSContext.
let discountedPrice: @convention(block) (Float, Float) -> Float = { price, discount in
price * (1 - discount)
}
jsHandler.setObject(object: discountedPrice, withName: "discountedPrice")
// JSConnectionHandler
func setObject(object:Any, withName:String) {
context?.setObject(object, forKeyedSubscript: withName as NSCopying & NSObjectProtocol)
}
You may wonder what is the @convention syntax. JavaScriptCore supports Objective-C blocks, so by prefixing @convention you're making the closure compatible.
To make the block available in JavaScript you need to call setObject on the JSContext passing the object and a String for the name. Look back at getPriceAndDiscountOfProduct, as you can see it is using discountedPrice, the very native code we just defined and registered.
Export a Swift object
It is possible to map a native object to the JavaScript environment by conforming it to the JSExport protocol.
First we need to declare an object that inherits from JSExport
@objc protocol ProductJSExport:JSExport {
var name:String {get set}
var price:Float {get set}
var quantity:Int {get set}
static func createProduct(name:String, price:Float, quantity:Int) -> ProductJS
}
Similar to what we did for the block we need to prefix @objc to make the protocol compatible with Objective-C.
The static function createProduct returns a ProductJS object, this is the object conforming to the protocol that will be exported to the JSContext. Why do we need it? Because the init function isn't exported to the context, so we need a function to create an instance of the object.
class ProductJS: NSObject, ProductJSExport {
dynamic var name: String
dynamic var price: Float
dynamic var quantity: Int
init(name:String, price:Float, quantity:Int) {
self.name = name
self.price = price
self.quantity = quantity
}
class func createProduct(name: String, price: Float, quantity: Int) -> ProductJS {
ProductJS(name: name, price: price, quantity: quantity)
}
}
The instance variables need to be dynamic as they need to operate in the Objective-C runtime. The static function is simply creating an instance of the object by accessing its initialiser.
Now that we have the object let's add it to the context
jsHandler.setObject(object: ProductJS.self, withName: "ProductJS")
let result = jsHandler.evaluateJavaScript("getProduct('name', 11)")
if let product = result?.toObject() as? ProductJS {
print("created product with name \(product.name)")
}
We can add it just like we added the block. Then we can call a JavaScript function in the context and that function will return the object.
function getProduct(name, price) {
return ProductJS.createProductWithNamePriceQuantity(name, price, 0);
}
As you can see the function name has a different name, that's because Swift and Objective-C have name in the parameters, while JavaScript doesn't. The function is named with parameters inserted using camel case, so createProduct(name:price:quantity) becomes createProductWithNamePriceQuantity. The result of this JavaScript function call can be casted to the native object, so you can get a Swift struct back from JavaScript in addition to calling a Swift function.
I usually communicate with JavaScript from my native code by passing JSONs back and forth and then convert them back to native objects, but having the possibility to have Swift objects is cool and I thought it was worth spending some extra time explaining it.
Hope you found it interesting, this is a subject I'm passionate about as I often need to interact with HTML and JS, I'm a fan of native apps but I also like sharing business logic code between platforms. Happy coding!
Originally posted http://www.gfrigerio.com/swift-and-javascript-interaction/
Posted on November 21, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.