Try .NET Core

.NET Coreを動かした、試した記録を書き残します。

音楽サーバ"Mopidy"のフロントエンドを作る:11 スワイプ操作を導入する

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第11回です。

今回は、スワイプ操作を検知して画面を移動する実装を追います。

使えることは使える、けども...

前回、デバイスごとに見栄えを調整しました。

そしてスマートフォン表示時は、ヘッダバーにボタンを置いて、リストの切替が出来るようにしてあります。
f:id:try_dot_net_core:20190812131545p:plain

しかし、実際に操作してみると。
UIの切替が、思ったより不便でした...。

片手サイズのデバイスでヘッダをタッチすると、手でデバイス全体が隠れちゃうんです。

あんまり好きじゃないなー、ということで。
スワイプ操作によるUI切替を、導入することにしました。

Hammer.JSはスグレモノ

Hammer.jsは、ブラウザのタッチ操作をひと纏めに検出出来る、便利なライブラリです。
以前の案件で使っていたこともあり、特に迷いも無くこれを導入しました。
なんとなく、iOSジェスチャー検知を思わせる作りです。

Vue用にラップされたライブラリ、vue2-hammervue-touchもあるんですが。
特に、ラップしてもらう必要も感じませんで...。
そのまんま使っています。

インストールはいつものnpm経由で。

# npm install hammerjs

@typesに型定義がありますので、こちらも入れておきます。

# npm install -D @types/hammerjs

スワイプ検知イベントを書く

Hammer.jsでは、スワイプの検出を下のように書きます。

import * as Hammer from 'hammerjs';

const elem = document.querySelector('.detector-element');
const detector = new Hammer(elem);
detector.get('swipte').set({
    direction: Hammer.DIRECTION_HORIZONTAL // 水平方向のスワイプを検出する。
});
detector.on('swipeleft', () => {
    // 左スワイプ検知時の処理
});

これで、タッチパネルはおろか、マウス操作でも反応してくれる、スグレモノです。

Android-Chromeで反応しない...?

さてさて、Viewの各パーツにHammer.jsを導入して、動作を確認してみたところ。
Android機のChromeで、反応がありません。

ぐぐってみると、not worksのワードがずらり。
f:id:try_dot_net_core:20190812201459p:plain どうも、頻出する現象のようですね。

ご本家GitHubのissueで、解決策が議論されていました。
こちらのコメントで、コード例があります。

var hammertime = new Hammer.Manager(document.querySelector('.l-main'), {
    touchAction: 'auto',
    inputClass: Hammer.SUPPORT_POINTER_EVENTS 
        ? Hammer.PointerEventInput 
        : Hammer.TouchInput,
    recognizers: [
        [Hammer.Swipe, {
            direction: Hammer.DIRECTION_HORIZONTAL
        }]
    ]
});

ほうほう。イベントの入力方式を、対応状況に応じて切り替えてるのかな?
これをこのまま書いてみると、@types上の変数定義が無いため、ビルドが通りません。

この変数、ライブラリの実装にはあるんかいな?と調べてみると。
このへんに記述がありました

var SUPPORT_TOUCH = 'ontouchstart' in window;
var SUPPORT_POINTER_EVENTS = prefixed(window, 'PointerEvent') !== undefined;
var SUPPORT_ONLY_TOUCH = SUPPORT_TOUCH && MOBILE_REGEX.test(navigator.userAgent);

こりゃ単に、@types上の定義が不足してるだけ、ですね。
例によって型定義ファイルをtypesフォルダにコピーし、変数定義を追記します。
types/hammerjs/index.d.ts

interface HammerStatic
{
  // -- 追記部分のみ抜粋 --
  SUPPORT_TOUCH: boolean;
  SUPPORT_POINTER_EVENTS: boolean;
  SUPPORT_ONLY_TOUCH: boolean;
  ...

Android-Chromeで試すと、きちんと検知出来るようになっていました。

うむよしよし!、と思ってたら...。
この方法だと、PCのマウス操作に反応しなくなっちゃいまいした...。

うーん、PCブラウザのマウス操作なんで、別にオミットしちゃってもいいんですが。
気になるっちゃ、気になる。

悩んでいたら、例のissueの少し下の方で、CSSによる対応サンプルがありました。

touch-action: pan-y;

なるほど、スクロールの上下操作のみを許可するよ、と定義しちゃうわけですね。
試してみると、Hammer.js側の方はサンプル通りの記述のまま、Android-Chromeで動くようになりました!

教訓。
英語でも、きちんと全部読め、ということですね...。

アニメーションを導入する

検知イベントを取れるようになれば、あとはUIの切替機能を加えるだけです。

しかし、元々はタブパネルのように切り替えを実装していたもの。
左右にスワイプしたのに、UIが横移動せずにガチャン、と切り替わると妙な感じです。
ここは、横移動アニメーションを追加したいところ。

そこで、頼りになるCSSアニメーションライブラリAnimate.cssにご活躍いただきます。

なお、Animate.cssもnpmパッケージ化されており、インストールは至極簡単です。

# npm install animate.css

使い方も、至極簡単。
DOM要素のクラスに、animatedと、各アニメーションの種類を示すクラスを追加するだけです。

import 'animate.css/animate.css';

const elem =  document.querySelector('.anim-elem');
elem.classList.add('animated', 'fadeOut');

たったこれだけで、CSSによるアニメーションが動いてくれます。
なんとまあ、簡単だこと!

これをなるべく多用できるように、こんなユーティリティクラスを用意していました。
src/ts/Utils/Animate.ts:

export default class Animate {
    // -- 一部抜粋 --
    private _isHidingAnimation: boolean = false;
    private _resolver: (value: boolean) => void = null;
    private _elem: HTMLElement = null;
    private _classes: DOMTokenList = null;

    public constructor(elem: HTMLElement) {
        this._elem = elem;
        this._classes = this._elem.classList;

        this.OnAnimationEnd = this.OnAnimationEnd.bind(this);
    }

    public Execute(animation: Animation, speed: Speed = Speed.Normal): Promise<boolean> {
        return new Promise((resolve: (value: boolean) => void): void => {
            this._resolver = resolve;
            this._elem.addEventListener(Animate.AnimationEndEvent, this.OnAnimationEnd);

            // 同じ内容のアニメーションが既に設定済みか否か
            const needsDefer = (
                this._classes.contains(Animate.ClassAnimated)
                && this._classes.contains(animation.toString())
            );

            Animate.ClearAnimation(this._elem);

            (needsDefer)
                //既にアニメーションセット済みのとき: 一度クリアしたあとで遅延実行
                ? _.defer((): void => {
                    this.InnerExecute(animation, speed)
                })
                // プレーン状態のとき: 即時アニメーション実行
                : this.InnerExecute(animation, speed);
        });
    }
    
    private InnerExecute(animation: Animation, speed: Speed = Speed.Normal): void {
        this._isHidingAnimation = Animate.IsHideAnimation(animation);

        if (!this.GetIsVisible())
            this.ShowNow();

        this._classes.add(Animate.ClassAnimated);
        this._classes.add(animation.toString());
        if (speed !== Speed.Normal)
            this._classes.add(speed.toString());

        // animationendイベントタイムアウト: 100ms加算。
        let endTime = -1;
        switch (speed) {
            case Speed.Slower: endTime = 3100; break;
            case Speed.Slow: endTime = 2100; break;
            case Speed.Normal: endTime = 1100; break;
            case Speed.Fast: endTime = 900; break;
            case Speed.Faster: endTime = 600; break;
        }

        setTimeout((): void => {
            if (this._resolver)
                this.Resolve(false);
        }, endTime);
    }
    // -- 以降、終了処理など --
}

アニメーションの終了をawaitで待てるようにしてあります。
備えは万全!

横移動アニメーションをいれる...と?

プロジェクトも終盤になると、実装が整理されて抽象クラスが増えていきますね。

Mopidy.Finderでは、カラム表示/フルスクリーン表示を切り替えるコンテンツ部分を、ContentDetailBaseというクラスに機能集約していました。

そこに、左右にスライド移動するアニメーションメソッドを加えます。
src/ts/Views/Bases/ContentDetailBase.ts:

private animate: Animate;

// -- 中略 --

public async SlideInRight(): Promise<boolean> {
    this.ToVisible();
    await this.animate.Execute(this.AnimationSlideInRight, this.AnimationSpeed);
    this.animate.Clear();
    this.ToVisible();

    return true;
}

public async SlideInLeft(): Promise<boolean> {
    this.ToVisible();
    await this.animate.Execute(this.AnimationSlideInLeft, this.AnimationSpeed);
    this.animate.Clear();
    this.ToVisible();

    return true;
}

public async SlideOutRight(): Promise<boolean> {
    this.ToVisible();
    await this.animate.Execute(this.AnimationSlideOutRight, this.AnimationSpeed);
    this.animate.Clear();
    this.ToHide();

    return true;
}

public async SlideOutLeft(): Promise<boolean> {
    this.ToVisible();
    await this.animate.Execute(this.AnimationSlideOutLeft, this.AnimationSpeed);
    this.animate.Clear();
    this.ToHide();

    return true;
}

さて、スワイプ操作時に移動してみましょう!
f:id:try_dot_net_core:20190812215110p:plain
あれえ?
スライドアウトは動くんですが、移動先のUIがスライドインしてきません...。

UIのレイアウト修正

なぜこうなるか、というと。

Mopidy.Finderの画面構造では、Bootstrapのrowに複数のcol-lgを入れることで、各種モニタサイズに対応させていました。

スマートフォンサイズのディスプレイでは、下のような表示になっています。
f:id:try_dot_net_core:20190812221258p:plain
Freepik from www.flaticon.com is licensed by CC 3.0 BY

この図の「コンテンツ1」がスワイプ操作を受けて左にスライドアウトしているとき、「コンテンツ2」は画面に見えない下の方で、スライドインアニメーションしていた、というわけなんですね...。

なるほどこりゃイカン、ということで、スマートフォン表示時はposition: absoluteになるように、CSSを調整します。
src/css/site.css:

.position-static {
    position: static !important;
}
.position-absolute {
    position: absolute !important;
}
.position-relative {
    position: relative !important;
}

そしてこのCSSクラスを、モニタサイズに合わせてセットするメソッドを作ります。 src/ts/Views/Bases/ContentDetailBase.ts:

private static readonly PositionStatic: string = 'position-static';
private static readonly PositionAbsolute: string = 'position-absolute';

// -- 中略 --

public ToPositionStatic(): void {
    const classes = this.$el.classList;
    if (!classes.contains(ContentDetailBase.PositionStatic))
        classes.add(ContentDetailBase.PositionStatic);
    if (classes.contains(ContentDetailBase.PositionAbsolute))
        classes.remove(ContentDetailBase.PositionAbsolute);
}

public ToPositionAbsolute(): void {
    const classes = this.$el.classList;
    if (!classes.contains(ContentDetailBase.PositionAbsolute))
        classes.add(ContentDetailBase.PositionAbsolute);
    if (classes.contains(ContentDetailBase.PositionStatic))
        classes.remove(ContentDetailBase.PositionStatic);
}

それらをモニタサイズ検知時に実行するように組み込み。
いざ動作確認!
f:id:try_dot_net_core:20190812222759p:plain
ああ、よかった!
カレントUIがスライドアウトするのと一緒に、別のUIがスライドインしてきてます。

以上、スワイプ操作の導入のおはなし、でした!