音楽サーバ"Mopidy"のフロントエンドを作る:11 スワイプ操作を導入する
音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第11回です。
今回は、スワイプ操作を検知して画面を移動する実装を追います。
使えることは使える、けども...
前回、デバイスごとに見栄えを調整しました。
そしてスマートフォン表示時は、ヘッダバーにボタンを置いて、リストの切替が出来るようにしてあります。
しかし、実際に操作してみると。
UIの切替が、思ったより不便でした...。
片手サイズのデバイスでヘッダをタッチすると、手でデバイス全体が隠れちゃうんです。
あんまり好きじゃないなー、ということで。
スワイプ操作によるUI切替を、導入することにしました。
Hammer.JSはスグレモノ
Hammer.jsは、ブラウザのタッチ操作をひと纏めに検出出来る、便利なライブラリです。
以前の案件で使っていたこともあり、特に迷いも無くこれを導入しました。
なんとなく、iOSのジェスチャー検知を思わせる作りです。
Vue用にラップされたライブラリ、vue2-hammerやvue-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
のワードがずらり。
どうも、頻出する現象のようですね。
ご本家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; }
さて、スワイプ操作時に移動してみましょう!
あれえ?
スライドアウトは動くんですが、移動先のUIがスライドインしてきません...。
UIのレイアウト修正
なぜこうなるか、というと。
Mopidy.Finderの画面構造では、Bootstrapのrow
に複数のcol-lg
を入れることで、各種モニタサイズに対応させていました。
スマートフォンサイズのディスプレイでは、下のような表示になっています。
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); }
それらをモニタサイズ検知時に実行するように組み込み。
いざ動作確認!
ああ、よかった!
カレントUIがスライドアウトするのと一緒に、別のUIがスライドインしてきてます。
以上、スワイプ操作の導入のおはなし、でした!