【APL】スクロール中の画像をタッチで止めた位置をサーバーに送る

# (Updated)
(2021/4/27追記) APL1.6からScrollViewの挙動が変化し、この記事のようにScrollコマンドを短時間でループすることができなくなってしまったようです。長らく公開している「貪欲な猫のスロット」も現在正常に動作しておりません。
※今風に実現するなら縦長の画像をhandleTickとtranslateを使ってずらしていく感じでしょうか。
※スクロールし続けることを考えなければ、タイトル通り「スクロール中の画像をタッチで止めた位置をサーバーに送る」ことは可能です。

(2019/10/26追記) 下記方法で開発コンソールのテストシミュレータではスクロール位置を正確に（公式ドキュメント通りに）小数点で取得できるのですが、**現在実機では0,1,2などの整数になってしまい、つまりは１画面単位の精度しか取得できません。**技術サポートに問い合わせ中ですが、代替案を思いついたのでまた後日追記します。。

**⇒本投稿の末尾に対策方法を追記しました。**

# 実はスロットマシンのゲームスキルを作りたい
APL1.0の頃から構想を練り練りしていて先日のスキルアワード2019でも公開できなかった「うさぎのスロット」という作りかけのスキルがありまして、挫折ポイントの一つである「スロットのリールを止めた位置がわからない問題」が最近になって解決手段が実装できたので、解説していきたいと思います。

# スクロールさせるものを用意する

ScrollViewの中に縦に細長いImageを入れます。

```json
{
    "type": "ScrollView",
    "id": "ReelSample",
    "width": "30vw",
    "height": "100vh",
    "item": [
        {
            "type": "Container",
            "width": "30vw",
            "height": "200vw",
            "items": [
                {
                    "type": "Image",
                    "width": "100%",
                    "height": "100%",
                    "source": "https://....../reel.png",
                    "scale": "fill",
                    "align": "center"
                }
            ]
        }
    ]
}
```

# ボタンを作る

ここはImageすら使わずFrameで手を抜くことにします。

```json
{
    "type": "Frame",
    "width": "300dp",
    "height": "300dp",
    "backgroundColor": "#660000",
    "borderRadius": "150dp"
}
```

# タッチイベントを埋め込む

タッチと言えば間違いなくTouchWrapperの登場です！
TouchWrapperにぶら下がったオブジェクトにタッチすると、Lambdaなどのバックエンドにイベントを送ることができます(インテントを処理するのと同じ感覚で処理できます)。値も送れます。
先ほどのFrameをはめ込みます。

```json
{
    "type": "TouchWrapper",
    "width": "300dp",
    "height": "300dp",
    "item": {
        "type": "Frame",
        "width": "300dp",
        "height": "300dp",
        "backgroundColor": "#660000",
        "borderRadius": "150dp"
    },
    "onPress": []
}
```

# バックエンドに送る値を設定する

上記ソースでonPressの部分にSendEventを記述することで、ボタンが押された際に値をバックエンドに送る事ができます。

ここからが今回の話のメイン…

ついこの前まで私が知ってた送出内容は、以下のようにバインド変数を指定することでした。(arguments)

```json
    "onPress": [{
        "type": "SendEvent",
        "arguments": [
            "${value1}"
        ]
    }]
```

ScrollViewはスクロールした際に他のコンポーネントの値を変更できるonScrollというプロパティがあり、スクロール位置をリアルタイムにTextで出力したり、Imageの透明度(opacity)を変更したり…ということができます。

タッチした時にバインド変数に入れたスクロール位置をSendEventで送出できれば…と思ったのですが、これができない。onScrollは（一部の）プロパティを変更できますが、バインド変数は変更できないのです。

ここで永らく暗礁に乗り上げていたのですが、最近何気なく公式ドキュメントを読んでいると…
> components
> componentsプロパティはコンポーネントIDの配列です。各コンポーネントのvalueがイベントで出力されます。これによってスキル作成者は、フォームのコンテンツがサーバーに直接送信されるようにフォームを作成できます。

各コンポーネントのvalueですと…？
ということで、いつの間にかコンポーネントのvalue値を渡せるようになってました（詳細は後述）。いつからだろう…

とりあえず先ほどのargumentsを書き換えてcomponentsにします。

```json
    "onPress": [{
        "type": "SendEvent",
        "components": [
            "ReelSample"
        ]
    }]
```

# おっと、スクロールさせておかねば！

大事なことを忘れてました。実行時に常に上下にスクロールし続けるようにします。
※（スロットのリールのように無限ループさせる方法は、これだけでもう一本書けそうなので別の機会に。ヒントは「ロープウェイ」です…）

```json:APL_exe_slot.json
{
  "type": "Alexa.Presentation.APL.ExecuteCommands",
  "token": "token",
  "commands": [
    { "type": "Sequential", "commands": [
        { "type": "Scroll", "delay": 0, "componentId": "ReelSample", "distance": 1 },
        { "type": "Scroll", "delay": 0, "componentId": "ReelSample", "distance": -1 }
    ], "repeatCount": 99 }
  ]
}
```

# 全体俯瞰
コンポーネントの構成とシミュレータ上での画面はこのようになります。
![image_1_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/309295/df9003f4-fe79-fc03-80bb-f3744f945b19.png)

![image_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/309295/24402ffb-36a6-f7ab-6a27-21adae3d17ad.png)

リールは最終的に3本並べたいのでレイアウト化しています。
味気ない画面ですが、左が上下に動くリール、右の丸いのが押しボタンです。

```json:APL_doc_slot.json
{
  "type": "APL",
  "version": "1.1",
  "settings": {},
  "theme": "dark",
  "import": [],
  "resources": [],
  "styles": {},
  "onMount": [],
  "graphics": {},
  "commands": {},
  "layouts": {
    "REEL": {
      "parameters": [
        "reelName"
      ],
      "item": [
        {
          "type": "ScrollView",
          "id": "${reelName}",
          "width": "30vw",
          "height": "100vh",
          "item": [
            {
              "type": "Container",
              "width": "30vw",
              "height": "200vw",
              "items": [
                {
                  "type": "Image",
                  "width": "100%",
                  "height": "100%",
                  "source": "https://....../reel.png",
                  "scale": "fill",
                  "align": "center"
                }
              ]
            }
          ]
        }
      ]
    }
  },
  "mainTemplate": {
    "parameters": [
      "payload"
    ],
    "items": [
      {
        "type": "Container",
        "alignItems": "stretch",
        "direction": "row",
        "item": [
          {
            "type": "Container",
            "width": "30vw",
            "height": "100vh",
            "direction": "row",
            "item": [
              {
                "reelName": "ReelSample",
                "type": "REEL"
              }
            ]
          },
          {
            "type": "TouchWrapper",
            "width": "300dp",
            "height": "300dp",
            "items": [
              {
                "type": "Frame",
                "width": "300dp",
                "height": "300dp",
                "backgroundColor": "#660000",
                "borderRadius": "150dp"
              }
            ],
            "onPress": [
              {
                "type": "SendEvent",
                "components": [
                  "ReelSample"
                ]
              }
            ]
          }
        ]
      }
    ]
  }
}
```

# 念のため、バックエンドでの受け取り
先ほど、バックエンドでインテントと同じように受け取れると書きましたが、イベントを受け取るハンドラは概ね以下のような感じです。
handlerInput.requestEnvelope.requestをごっそり取って、ログで確認してみましょう。

```node.js:index.js(抜粋)
const touchEventHandler = {
    canHandle(handlerInput) {
      return handlerInput.requestEnvelope.request.type === 'Alexa.Presentation.APL.UserEvent'
      && (handlerInput.requestEnvelope.request.source.handler === 'Press' || 
        handlerInput.requestEnvelope.request.source.handler === 'onPress');
    },
    handle(handlerInput) {
      const speechOutput = 'ボタンが押されました。';
      const req = handlerInput.requestEnvelope.request;
      console.log('Envelope.request: '+JSON.stringify(req));

      return handlerInput.responseBuilder
        .speak(speechOutput)
        .withShouldEndSession(false)
        .getResponse();
    }
  };
```

# 実行結果
スキル実行し、画像のスクロールが開始し、ボタンタッチした時のログは以下のようになります。

```text
Envelope.request:
{
    "type": "Alexa.Presentation.APL.UserEvent",
    "requestId": "amzn1.echo-api.request.44ce3aca-5ddc-4656-80f3-6f38fd857550",
    "timestamp": "2019-10-13T14:13:47Z",
    "locale": "ja-JP",
    "arguments": [],
    "components": {
        "ReelSample": 0.7450000047683716
    },
    "source": {
        "type": "TouchWrapper",
        "handler": "Press",
        "id": ""
    },
    "token": "token"
}
```

request.componentsのなかの
> "ReelSample": 0.7450000047683716

この部分ですね！公式確認すると

> スクロールしたコンポーネントの位置（パーセント表記）

とあるので、間違いなさそうですね。

> 報告されるposition値は、onScrollプロパティのvalueプロパティと同じ方法で計算されます。
(・・・中略・・・)
> コマンドで出力されるevent.source.valueは、現在のスクロール位置をスクロールビューの高さで割ったパーセント値になります。たとえば、ScrollViewの高さが200ピクセルであり、コンテンツを上方向に320ピクセルだけ動かした場合、出力される値は1.60になります。

ふむふむ。取れた値からなんとか止めた位置は取れそうだけど、echo spot/show/show 5では割合変わってくるのかな・・・この辺は追って検証したいと思います。
(2019/10/26追記) ScrollViewコンポーネントの縦幅が1.00となるようです。まさに上記公式ドキュメントの通りとなります。機種によって縦横比が変わるため、単純なvw-vhディメンジョンのレイアウトでは機種によって値が変わることがあります。ご注意ください。

# 実機でうまく位置取れなーい
(2019/10/26追記)
冒頭に書きましたが、開発コンソールのテストシミュレータではスクロール位置を正確に（公式ドキュメント通りに）小数点で取得できるのですが、現在実機では0,1,2などの整数になってしまい、つまりは１画面単位の精度しか取得できません。
しかし、以下の方法なら小数点まで正確に取得できます。

1. Textコンポーネントを設置し（見えない場所で良い）、コンポーネントIDを振る。（便宜上これを物体Xと命名します）
2. ScrollViewコンポーネントのonScrollプロパティに、「物体Xのtextプロパティにスクロール位置をSetValueする」と書く。
3. TouchWrapper > onPress > SendEvent > componentsプロパティに、ScrollviewのコンポーネントID("ReelSample")ではなく、物体XのコンポーネントIDを指定する。
4. index.jsにて"handlerInput.requestEnvelope.request.components.X"でスクロール位置取得。

```json:1.
{
  "type": "Text",
  "id": "X"
}
```

```json:2.
"onScroll": [
  {
    "type": "SetValue",
    "componentId": "X",
    "property": "text",
    "value": "${event.source.value}"
  }
]
```

```json:3.
"onPress": [
  {
    "type": "SendEvent",
    "components": [
      "X"
    ]
  }
]
```

以上で実機でも正確な位置が取得できました。
今後このようなことをしなくても取得できるようになる可能性があるため、差分として記載しておきます。