Chapter 04無料公開

iOSでのキーボードの出現をスムーズにする

榊原昌彦
榊原昌彦
2021.09.22に更新

この章では、 ion-footer をつかったページでのiOSでのキーボードの出現をスムーズにします。

左がデフォルト設定で、右がこれから実装するものとなります。

仕様確認

まず、左の動作をみてみましょう。キーボードが立ち上がったあとに、 ion-footer が移動しています。また、キーボードの高さが変わっただけなので、コンテンツのスクロールは行われていません。それに対して、これから実装する右はキーボードと同時にコンテンツが移動してる様子がわかります。

つまりこれから実装する内容としては、以下の3点となります。

  • CapacitorでiOSアプリをビルド
  • Capacitorは初期設定では ion-footer をスムーズに移動しないので、手動で行う
  • キーボード出現にあわせて、 ion-content を下部に移動する

なお、iOSは仕様上、キーボードはアプリの前面に出現します。ですので、設定次第では「 ion-footer は移動しない」といったことも可能となります。それに対して、Androidは仕様上、キーボードにあわせてアプリ自体がリサイズします。なので、キーボードの出現にあわせて手動ハンドリングする必要があるのはiOSアプリのみとなります。

実装

本章は、前章に続いて talkroom を利用して実装します。まず、Capacitor iOSをインストールしましょう。もしも package.json@capacitor/keyboard がない場合はここであわせてインストールしておいてください。

% npm install @capacitor/ios @capacitor/keyboard

そしてiOSプラットフォームを追加して、 npm run cap でビルド、 npx cap open ios でXcodeを開きましょう。

% npm run cap
% npx cap add ios
% npx cap open ios

そしてまずはそのままエミュレーターで実行してみてください。以下のようにうまくキーボードにあわせてコンテンツが移動していないことを確認することができます。

キーボード出現の設定をする

Capacitor iOSは、 capacitor.config.json を使ってキーボードの出現に伴ったコンテンツのリサイズ方法を4つから選択できます。

概要
body body タグのみをリサイズします。ビューポートは変更されないので相対単位(vh)は影響を受けません。
ionic ion-app タグのみをリサイズします。Ionicを利用してるアプリ向け。
native ネイティブのWebビュー全体のサイズを変更します。相対単位(vh)に影響します。
none リサイズしません。

capacitor.config.json を書き換えて、 cap update を行うと反映されます。どのリサイズ方法も キーボード出現後に リサイズされて遅延しますので、ここでは none を選択します。以下のように書き換えてください。

  {
    "appId": "io.ionic.starter",
    "appName": "ionic-pro",
    "webDir": "www",
    "bundledWebRuntime": false,
    "plugins": {
+     "Keyboard": {
+       "resize": "none"
+     }
    }
  }

試しにこれでビルドして動作確認してみましょう。キーボードが出現しても、コンテンツに影響を与えないことがわかります。

キーボードにあわせてコンテンツをリサイズする

まず、コンテンツをリサイズします。ページの外側のDOMだと大体どれでもリサイズすることができますが、ここでは ion-app タグをリサイズしましょう。 @capacitor/keyboard プラグインに、キーボードの showhide イベントがあるのでそれを利用してリサイズを行います。

最初にロードされる app.component.ts で設定しましょう。 ion-app はIonicタグですので、 IonAppViewChild で参照して、プロパティ ionApp にいれましょう。インターフェイス IonApp にはDOMの型が入っていないので、実際に格納されているキー elHTMLElement を追加して、 ionApp の型にします。また、キーボードイベントはiOSのみで利用するので、デバイス判定のために Platform オブジェクトをインポートして platform オブジェクトに代入しておきます。

- import { Component } from '@angular/core';
+ import { Component, OnInit, ViewChild } from '@angular/core';
+ import { Platform, IonApp } from '@ionic/angular';
+ import { Keyboard } from '@capacitor/keyboard';

  @Component({
    selector: 'app-root',
    templateUrl: 'app.component.html',
    styleUrls: ['app.component.scss'],
  })
- export class AppComponent {
-   constructor() {}
+ export class AppComponent implements OnInit {
+   @ViewChild(IonApp) ionApp: IonApp & {
+     el: HTMLElement;
+   };
+   constructor(private platform: Platform) {}

続いて、キーボードのリサイズイベントを用意します。デバイス判定して ios で、かつCapcitorで実行されている場合のみに、 keyboardWillShowkeyboardWillHide でイベントを登録します。キーボードが表れたら、クラスに show-keyboard を追加して、 marginBottom にキーボードの高さを追加しましょう。キーボードが隠れる時にはこれらを削除します。

async ngOnInit() {
  if (this.platform.is('ios') && this.platform.is('hybrid')) {
    Keyboard.addListener('keyboardWillShow', (info) => {
      this.ionApp.el.classList.add('show-keyboard');
      this.ionApp.el.style.marginBottom = info.keyboardHeight + 'px';
    });
    Keyboard.addListener('keyboardWillHide', () => {
      this.ionApp.el.classList.remove('show-keyboard');
      this.ionApp.el.style.marginBottom = '0px';
    });
  }
}

クラス show-keyboard のCSSを追加します。 src 直下にある global.scss に以下を追記します。

ion-app {
  transition: margin-bottom 420ms;

  &.show-keyboard ion-footer ion-toolbar:last-of-type {
    padding-bottom: 0;
  }
}

iOSのキーボードは約420msかけて開閉を行いますので、 transitions で420msかけて margin-bottom のサイズ変更を行うようにします。

また、iPhone SEなどホームボタンのある機種は下部にセーフエリアはありませんが、iPhone12などホームボタンがない機種は、下部にセーフエリアが存在します。
Ionicではセーフエリアがある場合は ion-toolbar にセーフエリア分の padding をつけるようにしていますが、キーボードが表示されている時はセーフエリアは不要なので、 padding-bottom: 0 を指定します。

これで起動してみましょう。キーボードの昇降にあわせて、 ion-app がスムーズにリサイズされていることを確認することができます。

キーボードにあわせて下にスクロール

しかし、これだけでは、キーボードの出現でコンテンツが隠れてしまいます。

そこで隠れて困るコンテンツがあるページでは、キーボードの出現にあわせて下にスクロールするようにしましょう。 talkroom/talkroom.page.ts を編集します。まず、 @capacitor/keyboard プラグインと、あとCapacitorのイベントリスナーをハンドルするためのインターフェイス PluginListenerHandle をインポートします。 PluginListenerHandle を今回使うのは、ページを離れる時にイベントリスナーを削除するためです。

import { Platform } from '@ionic/angular';
import { Keyboard } from '@capacitor/keyboard';
import { PluginListenerHandle } from '@capacitor/core';

まず、イベントリスナーに登録するメソッド toBottomAnimation を作成しましょう。ms毎にスクロールを行うと画面に描画されないスクロールが発生しますので、 requestAnimationFrame で間引きながら実行します。また、キーボードは420msかけて出現しますので、キーボードの出現がはじまってから420ms経過するとイベントは終了するようにしています。

private toBottomAnimation(content: IonContent): void {
  const startTime = new Date().getTime();
  const toBottomAnimation = () => {
    if (new Date().getTime() - startTime <= 420) {
      content.scrollToBottom();
      requestAnimationFrame(toBottomAnimation);
    }
  };
  requestAnimationFrame(toBottomAnimation);
}

そしてこれをイベントリスナーに登録します。 ionViewWillEnterionViewWillLeave で実行・削除しましょう。まず、 ViewWillLeave をimplementsしましょう。そして、 ionViewWillEnter でイベントリスナーに登録して、またその返り値を作成したプロパティ listenerHandlers にいれます。

  @Component({
    selector: 'app-talkroom',
    templateUrl: './talkroom.page.html',
    styleUrls: ['./talkroom.page.scss'],
  })
- export class TalkroomPage implements OnInit, ViewWillEnter, ViewDidEnter  {
+ export class TalkroomPage implements OnInit, ViewWillEnter, ViewDidEnter, ViewWillLeave {
...
+   private readonly listenerHandlers: PluginListenerHandle[] = [];
-   constructor(private talkroomService: TalkroomService) {}
+   constructor(private talkroomService: TalkroomService, private platform: Platform) {}
...
    ionViewWillEnter() {
+     if (this.platform.is('capacitor')) {
+       const scrollHandler = Keyboard.addListener('keyboardWillShow', () => this.toBottomAnimation(this.content));
+       this.listenerHandlers.push(scrollHandler);
+     }
      this.isReady = false;
    }

+   ionViewWillLeave() {
+     if (this.platform.is('capacitor')) {
+       this.listenerHandlers.forEach(handler => handler.remove());
+     }
+  }

ionViewWillLeave では、プロパティ listenerHandlers に登録されているすべてのハンドラーを解除しています。Ionicを使っているとイベントの登録解除の頻度が増えますので、こうやって配列でとりまわす癖をつけておくと効率的に開発を進めることができておすすめです。

それではビルドして実行してみましょう。無事、スムーズにキーボードに追随してリサイズが発生し、またスクロールされていることがわかります。

LINEにあわせたスクロール

LINEでは、キーボード出現時、「一番下にスクロールしていた場合は、そのままの状態が保持されて(下にスクロールされたまま)キーボードが立ち上がる」「下から200px程度上にスクロールしていた場合はコンテンツはスクロールを行わずにキーボードが立ち上がる」という仕様となっています。

これは過去のトークを見ながら返信を書けるようにするための配慮だとは思いますので、先程の実装をそれにあわせましょう。 メソッド toBottomAnimation が実行された段階で、 ion-contentscrollHeight から scrollTop を引いたものと clientHeight を比較します。これらが一致していれば一番下までスクロールされているということになります。

https://developer.mozilla.org/ja/docs/Web/API/Element/scrollHeight#要素が完全にスクロールされたかどうかを判定する

なので、 scrollHeight から残りすべてをひいたものが 200px より大きければ、下から200px以上、上にスクロールしているものと判定して、キーボード起動時に下へのスクロールを行わないようにします。以下のようになります。

- private toBottomAnimation(content: IonContent): void {
+ private async toBottomAnimation(content: IonContent): Promise<void> {
+     const scrollElement = await content.getScrollElement();
+     if (scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight > 200) {
+       return;
+     }
      const startTime = new Date().getTime();
      const toBottomAnimation = () => {

これで、スムーズにキーボードの開閉にあわせてコンテンツを動かすことができるようになりました。