Elm と Ionic でモバイルアプリっぽい UI を作る

# はじめに

Ionic を使い Elm でもモバイルアプリっぽい UI を作りたいと思って試してみました！どのように動くか気になる方はこちらの[デモ](https://calmery.github.io/elm-ionic-example/)を見てください。この記事では Elm 上で Ionic を使う方法、その上でできること、できないことをまとめます。

# Ionic とは

[Ionic](https://ionicframework.com/) は、iOS や Android、Web などクロスプラットフォームでアプリを開発するための UI フレームワークです。以前は Angular でしか使うことができませんでしたが [Stencil](https://stenciljs.com/) という Ionic チームが開発したフレームワークを使うことで React や Angular、Vue などでも使うことができるようになりました。どのようなコンポーネントが用意されているのか、また使い方などは [UI Components - Ionic Documentation](https://ionicframework.com/docs/components) を見るとおおよそわかると思います。

# Stencil とは

[Stencil](https://stenciljs.com/) は全てのブラウザで実行できる、標準仕様に従った Web Components を生成するためのフレームワークです。Ionic はこのフレームワークを使って開発されています。個人的にプロパティやイベントを含めドキュメントを自動生成できるところが、ドキュメントの手動更新による間違いを減らすことができて便利そうでした。

[Docs Readme Auto-Generation - Stencil](https://stenciljs.com/docs/docs-readme)

> Stencil is able to auto-generate readme.md files in markdown. This is an opt-in feature and will save the readme files as a sibling to the component within the same directory. When this feature is used it can be useful for others to easily find and read formatted docs about one component. 

# Web Components とは

[Elm と他のフレームワークを組み合わせる - Qiita](https://qiita.com/calmery/items/d85e4755a4d0644c0610#web-components-%E3%81%A8%E3%81%AF)

> Web Components は，再利用可能なカスタム要素を作成し，ブラウザ上で利用するための技術，HTML Templates や Custom Elements，Shadow DOM をまとめた総称です．これまで React や Angular，Vue などのフレームワークを使用して実現していたカスタムコンポーネントや Scoped CSS などの機能を，標準の機能のみで実現することができます．

# Ionic を使うための準備をする

Ionic を React や Angular、Vue 以外で使う場合は CDN、[jsDelivr](https://www.jsdelivr.com/) から配信されるファイルを使う、または npm で `@ionic/core` を追加することになります。ここで `@ionic/core` を使う場合、基本的には webpack などのバンドラーが必要になります。もし試すだけであれば CDN から配信されるファイルを使う方が簡単かもしれませんね。

## CDN から配信されるファイルを使う

ここでは CDN から配信されるファイルを `link` や `script` タグを使って HTML ファイルに追加する必要があります。そのため `elm make` の `--output` オプションを使い JavaScript ファイルとして出力するようにします。次に[ドキュメント](https://ionicframework.com/docs/installation/cdn#ionic-framework-cdn)に従って次のタグを HTML ファイルに追加します。

> It's recommended to use jsdelivr to access the Framework from a CDN. To get the latest version, add the following inside the `<head>` element in an HTML file, or where external assets are included in the online code editor:

```html
<script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css"/>
```

## `@ionic/core` を使う

外部の CDN に依存したくない場合、バンドラーを使って自前で `@ionic/core` をバンドルする必要があります。これは少し面倒です。特に理由がないのであれば CDN を使う方法をオススメします。

まず、エントリーファイルに `@ionic/core` から必要となるファイルをインポートします。ここでは CSS ファイルを読み込めるようにする必要があり、webpack であれば css-loader を使う、または copy-webpack-plugin を使ってコピーするのもありかもしれません。

```typescript
import { defineCustomElements } from "@ionic/core/loader";
import "@ionic/core/css/ionic.bundle.css";
import "ionicons/icons";

defineCustomElements(window);
```

また、Ionic には [Ionicons](https://ionicons.com/) というものがあり Ionic 上で使うアイコンを SVG として管理しています。そのため `ion-icon` などのタグを使いたい場合、適切な場所に SVG ファイルを配置する必要があります。`import "ionicons/icons"` すると SVG ファイルがインポートされるのでバンドラーのファイル出力先に `svg` フォルダを作り、その下に SVG ファイルを配置するようにします。例として webpack では次のように設定します。

```javascript
{
  test: /\.svg$/,
  loader: [
    {
      loader: 'file-loader',
      options: { name: 'svg/[name].[ext]' }
    }
  ]
}
```

CDN から配信されるファイルを使う場合と比べるとちょっと面倒ですね！

## Elm 上で Ionic のコンポーネントを扱えるようにする

`node` 関数や `on` 関数を使って Ionic のタグやプロパティ、イベントを Elm 上でも使えるようにします。これは使いたいコンポーネント毎に `node` 関数を記述していく必要があります。

```elm
import Html exposing (node)
import Html exposing (Attribute, attribute)
import Html.Events exposing (on, targetValue)

button =
  node "ion-button"

datetime =
  node "ion-datetime"

displayFormat =
  attribute "display-format"

onChange : (String -> msg) -> Attribute msg
onChange handler =
  on "ionChange" (Json.map handler targetValue)
```

タグやプロパティ、イベント毎に分けて作っておくと Html モジュールと同じように使えて便利です。

```
.
├── Ionic
│   ├── Attributes.elm
│   └── Events.elm
└── Ionic.elm

1 directory, 3 files
```

これで Ionic を使う準備が整いました！

# 使ってみる

基本的には Elm の Html と同じように記述します。

```elm
type alias Model =
  { datetime : String }

type Msg
  = DateTimeChanged String

view : Model -> Html Msg
view model =
  div
    []
    [ button [] [ text "Hello World" ]
    , datetime
        [ onChange DateTimeChanged
        , displayFormat "YYYY/MM/DD"
        , value model.datetime
        ]
        []
    ]
```

ここで使った `ion-datetime` と `ion-button` 以外にも `ion-refresher` や `ion-select`、`ion-toggle` など、便利なコンポーネントが沢山用意されていて面白いです！

## ion-action-sheet や ion-alert を使う

Ionic には画面にオーバーレイを表示し、ユーザが選択肢をタップすることで処理が先に進む `ion-action-sheet` や `ion-alert` といったコンポーネントも用意されています。これらは仕組み上、Elm だけでは表示することはできません。Elm の Ports を使って JavaScript 側でオーバーレイを表示、特定の選択肢が選択された場合に Elm 側に Ports を使って情報を送り返すといった処理が必要になります。また、次の例では `ion-alert` にはない `create` メソッドを使うため `ion-alert` ではなく `ion-alert-controller` を使用しています。

```elm
port createAlert : { header : String, message : String } -> Cmd msg

port onClickOkButton : (() -> msg) -> Sub msg

subscriptions : Model -> Sub Msg
subscriptions model =
  onClickOkButton (\_ -> OKButtonClicked)
```

```javascript
ports.createAlert.subscribe(async ({ header, message }) => {
  const alertController = document.querySelector("ion-alert-controller");
  const alert = await alertController.create({
    header,
    message,
    buttons: [{
      text: "OK",
      handler: handler: () => ports.onClickOkButton.send(null)
    }]
  });
  await alert.present();
});
```

# ページ遷移時のアニメーションが使えない

Ionic にはページ遷移時のアニメーションも用意されています。具体的には `ion-router`、`ion-route` というコンポーネントが用意されていて、このコンポーネントに `url` 属性を指定することでルーティング、そして `component` 属性に HTML タグ名を指定することで、ページ遷移時にモバイルアプリの画面遷移のようなアニメーションが発火します。とても便利なコンポーネントですが、この `component` 属性に指定できるのは特定の Ionic コンポーネントと HTML タグ名だけで id や class などを指定することができず、Elm から使う方法はありません。

しかし `@ionic/react` など、内部的に `customElements.define` を使っていないフレームワークへの実装ではページ遷移を実現しています。どのようにページ遷移を実現しているのか `@ionic/react-router` のソースコードを読んでみたところ、まずルーター自体をそのフレームワークに合わせたもの、ここでは `react-router` で実装し直し、[アニメーション用の関数](https://github.com/ionic-team/ionic/blob/1c344beda18c129279a5eec68360712cb54ccd5a/core/src/utils/transition/index.ts#L9-L25)を React コンポーネントに対して使っているようです。もし Elm で使う場合、Elm のページ遷移が発生したら Ports を介して JavaScript 側でページ遷移のアニメーションを行うことで実現できそう。これはまた別の機会に！試してみたいですね！

JavaScript のみで実装された `ion-router`、`ion-route` のサンプルはあまり見かけないので例として、次のようなページ毎に `customElements.define` をしているソースコードはアニメーションも含めページ遷移が正しく動作します。

```html
<script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css"/>

<script>
customElements.define('page-a', class extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
<ion-content padding>
  <ion-button href="#/b">Go to Page-B</ion-button>
</ion-content>
`;
  }
});

customElements.define('page-b', class extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
<ion-content padding>
  <ion-button href="#/">Go to Page-A</ion-button>
</ion-content>
`;
  }
});
</script>

<ion-app>
  <ion-router>
    <ion-route url="/" component="page-a"></ion-route>
    <ion-route url="/b" component="page-b"></ion-route>
  </ion-router>
  <ion-nav></ion-nav>
</ion-app>
```

# まとめ

まだちょっと問題はありますが、Elm でも Ionic を使ってモバイルアプリっぽい UI を作ることができました。簡単なものであれば十分使うことができそうで、特に PWA にしてフルスクリーンで起動できるととても面白そうですね。この記事で行ったこととは逆に Elm を Web Components として React や Angular、Vue などで使う方法もあります。気になる方は [Elm と他のフレームワークを組み合わせる - Qiita](https://qiita.com/calmery/items/d85e4755a4d0644c0610) を見てください！