ElmをiOSで動かす

いわゆるガワネイティブというやつです。
iOSアプリのView構築に疲れたのでElmでViewを作れればいいんだという思いつきで試してみました。

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

iosプロジェクト内にHTMLファイルがあればWKWebViewを利用して画面を表示することが出来ます。
以下のようなプロジェクト構造を想定します。

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

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

```swift
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に準拠する必要があります。

```swift
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)`を実装して受け取ったイベントを処理します。

```swift
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へイベントを送ります。

```js
// 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コードを送ることが出来ます。

```swift
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のサンプルにあるカウンターのコードを少しばかりいじったものを用意します。

```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と連携する処理を書いていきます。

```js
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にイベントを送る処理を書いていきます。

```swift
@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