Edited at

ElmをiOSで動かす

いわゆるガワネイティブというやつです。

iOSアプリのView構築に疲れたのでElmでViewを作れればいいんだという思いつきで試してみました。


iOSでHTMLを読み込んで表示する

iosプロジェクト内にHTMLファイルがあればWKWebViewを利用して画面を表示することが出来ます。

以下のようなプロジェクト構造を想定します。

project/

elm-app/
public/
js/
main.js
index.html

iOS側ではHTMLのコードをロードすればその内部にあるスクリプトが実行されるのでElmから生成したjsが動作します。

final class ViewController: UIViewController {

// HTML内のリソース指定がアクセスするためのベースURL
private let baseURL: URL = {
let basePath = Bundle.main.path(forResource: "elm-app/public", ofType: nil)!
return URL(fileURLWithPath: basePath)
}()

// HTMLファイルの文字列を取得
private let contents: String = {
let htmlPath = Bundle.main.path(forResource: "elm-app/public/index", ofType: "html")!
return try! String(contentsOfFile: htmlPath, encoding: .utf8)
}()

self.webView: WKWebView!

override func viewDidLoad() {
super.viewDidLoad()
// WKWebViewをルートViewにセット
self.setWebView()
// WKWebViewにHTMLを読み込む
self.webView.loadHTMLString(self.contents, baseURL: self.baseURL)
}

private func setWebView() {
let webConfiguration = WKWebViewConfiguration()
self.webView = WKWebView(frame: .zero, configuration: webConfiguration)

self.view.addSubview(self.webView)

self.webView.translatesAutoresizingMaskIntoConstraints = false
self.view.addConstraints([
self.webView.topAnchor.constraint(equalTo: self.webView.superview!.topAnchor),
self.webView.leftAnchor.constraint(equalTo: self.webView.superview!.leftAnchor),
self.webView.rightAnchor.constraint(equalTo: self.webView.superview!.rightAnchor),
self.webView.bottomAnchor.constraint(equalTo: self.webView.superview!.bottomAnchor)
])
}
}


JSからiOSへの通信

WKWebViewで生成したブラウザ内部ではwebkit.messageHandlersというプロパティを通してブラウザ側のイベントを受け取ることが出来ます。

受け取るイベントはiOS側で予め設定する必要があります。

WKWebViewを生成するときに、JS側から受け取るイベントをWKUserContentControllerを利用して設定します。

selfに指定したインスタンスはWKScriptMessageHandlerというProtocolに準拠する必要があります。

let contentController = WKUserContentController();

contentController.add(self, name: "increment")
contentController.add(self, name: "decrement")

let webConfiguration = WKWebViewConfiguration()
webConfiguration.userContentController = contentController

self.webView = WKWebView(frame: .zero, configuration: webConfiguration)

WKScriptMessageHandlerに準拠したインスタンスにuserContentController(userContentController:didReceive)を実装して受け取ったイベントを処理します。

extension ViewController: WKScriptMessageHandler {

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
// nameにWKUserContentController.addで設定したイベント名が入ります
switch message.name {
case "increment":
print("increment")
case "decrement":
print("decrement")
default:
assertionFailure()
}

// bodyにJS側から送られる値が入ります、型はAnyになります
print(message.body)
}
}

JS側ではwebkitというインスタンスが利用出来るのでこれを利用してiOSへイベントを送ります。

// incrementイベントを送信

try {
webkit.messageHandlers.increment.postMessage(1);
} catch (err) {
console.log(err);
}

// decrementイベントを送信
try {
webkit.messageHandlers.decrement.postMessage(2);
} catch (err) {
console.log(err);
}


iOSからJSへの通信

WKWebViewが提供するevaluateJavaScriptを利用してブラウザへ任意のJSコードを送ることが出来ます。

webView.evaluateJavaScript("console.log('from iOS')") { response, error in

if let error = error {
print(error)
}
print(response)
}


ElmからiOS間の通信

ここまでJSとiOSの通信を書いてきましたので、Elmを少しばかり理解している人ならどうすればいいかはもう分かっていると思います。

Elmのportsを利用してElmとiOSを橋渡しすれば良いだけです。

Elmのサンプルにあるカウンターのコードを少しばかりいじったものを用意します。

port module Main exposing (..)

import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)

type alias Model = Int

main =
Browser.element
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}

type Msg = Increment | Decrement | Set Model

init : Maybe Model -> ( Model, Cmd msg )
init flags =
case flags of
Just model ->
( model, Cmd.none )
Nothing ->
( 0, Cmd.none )

-- Elmでカウントアップボタンが押されたら、JSへ通知する
port incremented : Model -> Cmd msg

-- Elmでカウントダウンボタンが押されたら、JSへ通知する
port decremented : Model -> Cmd msg

update msg model =
case msg of
Increment ->
let
newModel = model + 1
in
( newModel, incremented newModel )

Decrement ->
let
newModel = model - 1
in
( newModel, decremented newModel )

Set newModel ->
( newModel, Cmd.none )

-- JSから通知を受け取ったら、その値でModelを更新する
port fixModel : (Model -> msg) -> Sub msg

subscriptions model =
fixModel Set

view model =
div []
[ button [ onClick Decrement ] [ text "-" ]
, div [] [ text (String.fromInt model) ]
, button [ onClick Increment ] [ text "+" ]
]

JS側のportsのコードにiOSと連携する処理を書いていきます。

app.ports.incremented.subscribe((data) => {

try {
webkit.messageHandlers.increment.postMessage(data);
} catch (err) {
console.log(err);
}
});

app.ports.decremented.subscribe((data) => {
try {
webkit.messageHandlers.decrement.postMessage(data);
} catch (err) {
console.log(err);
}
});

iOS側からはElmのPortsにイベントを送る処理を書いていきます。

@IBAction func onTapButton(sender: UIButton) {

let js = "app.ports.fixModel.send(10);"
self.webView.evaluateJavaScript(js) { _, error in
if let error = error {
print(error)
}
}
}


記事にのせたコードだと全容が掴み難いかもしれませんので、全体のコードをgistにおいておきました。

興味のある方は御覧ください。

https://gist.github.com/k-motoyan/c4be847b2b36060f2cbb410df7076c20