Sencha Advent Calendar 2014 – 12月3日 比較的軽めのドロワーメニューを作ってみよう

娘ももうすぐ3歳です :)

今年も Sencha Advent Calendar の季節がやってきましたね。
早いですね ;(

今年は ExtJS 5 がメインな空気が漂っており、Touch が可哀想な子になりつつあるので、Touchのエントリー書きます :)

こんなやつ

作ってみる

構成

viewport
├ drawer menu container (docked: left)
└ content container

こんだけ。

あらかじめViewportにドロワー用のメニューコンテナをくっつけておいて、その上にコンテンツのコンテナ被しておいて、メニュー選択したいタイミングでコンテンツのコンテナをずらす感じです。

とりあえずスケルトンのプロジェクト作成

Sencha Cmd でコマンド叩くだけでOK :)

sencha -sdk /xx/xx/touch generate app App ../demo

念のためコマンドのパラメータ説明

  • /xx/xx/touch 部分
    • Sencha TouchのSDKが格納されているディレクトパス
  • App
    • 生成されるアプリケーションのルートの名前空間
  • ../demo
    • 生成するアプリケーションを出力するパス
      • 指定したフォルダがない場合は自動的にフォルダも生成されます

それ以外は、Sencha Cmdのコマンドなのでとりあえず説明省きます。

生成されたアプリケーションにアクセスして、一応動確しておきます。

Theme変えてますが、こんなスケルトンのアプリケーションが表示されてればOKです。

生成されたコードの確認

自動的に生成されたソースコードの app.js 内の launch メソッドを見てみると、次のようになってると思います

app.js

launch: function() {
    // Destroy the #appLoadingIndicator element
    Ext.fly('appLoadingIndicator').destroy();

    // Initialize the main view
    Ext.Viewport.add(Ext.create('App.view.Main'));
},

この一番最後にviewportに対して画面を追加している App.view.Main ってのが、最初の構成説明で記載した content container の部分になります。

なので

viewport
├ drawer menu container (docked: left)
└ content container
    └ このコンテナが App.view.Main になる

てな感じになって、今から作るメニュー用のコンテナを同じ階層にaddしてあげる感じになります。


ドロワーメニュー(Menu.js)を作ってく

app/
└ view/
    ├ Main.js
    └ Menu.js <- New!!

Menu.js

まっさらな Ext.Container クラス拡張していきます。
まずは、継承元とxtypeを指定してあげて

app/view/Menu.js

Ext.define('App.view.Menu', {
    extend: 'Ext.Container',
    xtype: 'slidemenu'
});

各configを定義していきます。

app/view/Menu.js

Ext.define('App.view.Menu', {
    extend: 'Ext.Container',
    xtype: 'slidemenu',
    config: {
        cls     : 'sidemenu',
        docked  : 'left',
        top     : 0,
        left    : 0,
        bottom  : 0,
        zIndex  : 0,
        padding : '0 0 0 0',
        open    : false,
        scrollable: 'vertical'
    }
});
  • cls
    • CSSのクラス名(あとで見た目整える際に使うよ!)
  • docked
    • viewportの左側へドッキングさせる
  • top / left / bottom
    • floating状態にして画面一杯まで広げる
  • zIndex
    • 深度設定(下に隠れて欲しいので0指定)
  • padding
    • 標準で反映される余計な余白の除去
  • open
    • 開閉フラグ(このフラグの変更をトリガーにする)
  • scrollable
    • 縦スクロール設定

あとは、このコンテナの中にメニューのボタンをどんどん追加していくんですが、いちいちxtype指定するのは面倒なのでdefaultsの設定してあげましょう。

app/view/Menu.js

Ext.define('App.view.Menu', {
    extend: 'Ext.Container',
    xtype: 'slidemenu',
    config: {
        ... 中略
        scrollable: 'vertical',
        defaults: {
            xtype       : 'button',
            textAlign   : 'left'
        }
    }
});

これで、このMenuコンテナ内にアイテム突っ込めば勝手にボタンになりますね!

ただ、このままだと width の指定がないため表示されないのですが、configにwidthを設定しても無視されてしまうので、このコンポーネントの初期化タイミング時にセットをしてあげます。

app/view/Menu.js

Ext.define('App.view.Menu', {
    extend: 'Ext.Container',
    xtype: 'slidemenu',
    config: {
        ... 中略
    },
    initialize: function() {
        var me = this;
        me.setWidth(200);
        me.callParent(arguments);
    }
});

itemsを追加して確認してみる

ここまで一旦できたら、実際にボタンを追加して表示してみましょう :)
configプロパティ内にitemsを追加して

app/view/Menu.js

Ext.define('App.view.Menu', {
    extend: 'Ext.Container',
    xtype: 'slidemenu',
    config: {
        ... 中略
        items: [{
            text: 'button1'
        }, {
            text: 'button2'
        }]
    },
    ... 中略
});

こんな感じにしたら、ちょこっとエントリーポイントのファイルの app.js を書き換えます。

記事の最初の方にも記載した launch メソッド内でViewportにaddしているクラスを、App.view.Main から App.view.Menu に変更します。

app.js

launch: function() {
    // Destroy the #appLoadingIndicator element
    Ext.fly('appLoadingIndicator').destroy();

    // Initialize the main view
    Ext.Viewport.add(Ext.create('App.view.Menu'));
},

そしたら再度ブラウザでアプリケーションを立ち上げてみると

こんな感じの非常に残念な見た目になっていれば正解です :)

マスクを追加する

続いては、メニューを表示した際にメニュー外をタップしたらメニューを閉じるようにしたいので、それを検知するためのマスクを設定していきます。

実際にはコンテンツが表示されているビューも一緒に表示されているので、それらの親DOMに対してマスクを設置します。

なので設置するタイミングは、Menuクラスの親コンポーネントへの参照がセットされる setParent メソッドをフックして行います。

※ setParentはprivateメソッドです

app/view/Menu.js

Ext.define('App.view.Menu', {
    ... 中略
    initialize: function() {
        var me = this;
        me.setWidth(200);
        me.callParent(arguments);
    },
    setParent: function(c) {
        var me = this;
        me.callParent(arguments);
        me.maskCmp = c.add({
            xtype   : 'component',
            cls     : 'sidemenu-mask',
            top     : 0,
            left    : me.getWidth(),
            bottom  : 0,
            width   : 9999,
            zIndex  : 5000,
            hidden  : true
        });
    }
});

setParent が呼び出されたタイミングで、親コンポーネントに対してマスク用の空componentを設置します。

初期値で hidden: true になっているので表示されませんが、メニュー展開時にこのマスクを表示することで領域外のタップを検知します。

設定しているプロパティ群は先ほどと似ているためだいたい想像つくかと思いますが、どんな感じになっているか確認するためにちょこっとプロパティいじります

app/view/Menu.js

        me.maskCmp = c.add({
            xtype   : 'component',
            cls     : 'sidemenu-mask',
            top     : 0,
            left    : me.getWidth(),
            bottom  : 0,
            width   : 9999,
            zIndex  : 5000,
            hidden  : false,
            style   : {
                'background-color': '#f00'
            }
        });

これを表示してみると

こうなります :)

確認できたらコードを元に戻しておいてください

マスクのタップを検知する

先ほどマスクを追加しましたが、これだけだとタップの検知ができないためイベントを貼ります。やることは普通にマスクのelementのタップイベントをリッスンするだけです。

app/view/Menu.js

Ext.define('App.view.Menu', {
    ... 中略
    initialize: function() {
        var me = this;
        me.setWidth(200);
        me.callParent(arguments);
    },
    setParent: function(c) {
        var me = this;
        me.callParent(arguments);
        me.maskCmp = c.add({
            ... 中略
        });
        me.maskCmp.element.on({
            scope   : me,
            touchend: 'onMaskRelease'
        });
    }
});

on メソッド使ってあげて、スコープを固定しつつ touchend のイベントが発火したら onMaskRelease メソッドを実行してあげるようにしておきます。

onMaskReleaseメソッドの追加

マスクエレメントに登録したリスナーが実行するメソッドを実装します。
メソッド内でやることはただ configプロパティに追加したopenプロパティの切り替えを行うだけです。

app/view/Menu.js

Ext.define('App.view.Menu', {
    ... 中略
    setParent: function(c) {
        ... 中略
    },
    onMaskRelease: function() {
        var me = this;
        me.setOpen(false);
    }
});

※ configに記述したプロパティは全て、set / get / apply / update メソッドが自動的に生成されますので、知らない人は覚えておくと良いです。
例:
config: {
    hoge: true
}
と指定しておくと次のメソッドがそのクラスに自動生成されます。
・setHoge
・getHoge
・applyHoge
・updateHoge
細かい説明はドキュメントを見ましょう!

お掃除用処理を入れる

Menuクラスが破棄された際に、マスク用コンポーネントも一緒に掃除するようにしておきましょう。

コンポーネント破棄の destroy メソッドをフックして、マスク用コンポーネントの破棄と、念のため参照も削除しておきます。

その後親クラスの destroy をコールしてあげればOKです。

app/view/Menu.js

Ext.define('App.view.Menu', {
    ... 中略
    onMaskRelease: function() {
        ... 中略
    },
    destroy: function() {
        var me = this;
        me.maskCmp.destroy();
        delete me.maskCmp;
        me.callParent(arguments);
    }
});

表示トグルメソッド

次はメニューの表示をトグルさせる際に、外から実行されるメソッドを追加します。

app/view/Menu.js

Ext.define('App.view.Menu', {
    ... 中略
    destroy: function() {
        ... 中略
    },
    toggle: function(v) {
        var me = this;
        me.setOpen(Ext.isEmpty(v) ? !me.getOpen() : v);
    }
});

引数があればその引数を、なければ現在の開閉状態を逆にしてopenフラグを更新してるだけです。

openフラグのアップデート処理

最後に肝心の実際に開閉を行う処理になります。
ちょっと前の説明で、config配下にプロパティつけてあげると幾つかのメソッドが自動生成されるといった説明をちょこっと載せましたが、今回開閉処理に利用する updateOpen メソッドもその1つです。

app/view/Menu.js

Ext.define('App.view.Menu', {
    ... 中略
    toggle: function(v) {
        ... 中略
    },
    updateOpen: function(v) {
        var me = this,
            up = me.up(),
            el;
        if (!up) {
            return;
        }
        el = up.innerElement;
        if (v) {
            el.translate(me.getWidth(), 0, 0);
            me.maskCmp.show();
        } else {
            el.translate(0, 0, 0);
            me.maskCmp.hide();
        }
    }
});

setOpen メソッドを利用してopenフラグの変更を行うと、最終的にこの updateOpen メソッドが実行されます。

実際には引数に

updateOpen(新たに設定された値, 更新前の古い値)

が渡ってきますが、今回は新たに設定された値だけあれば良いので引数は1つにしてあります。

updateOpen メソッド内ではMenuクラスの親コンポーネント(viewport)を取得し、その親コンポーネントのinnerElementを translate メソッドを使ってずらしてあげてます。

Menuクラス自体もviewport配下に設置されていますが、ドッキングコンポーネントになっておりinnerElement外にDOMがあるため、上記で実行している translate には影響されない形になります。
(あとは translate のタイミングでマスク用コンポーネントの表示切り替えを行っています)

実際にViewportに設置して、toggle メソッドを実行してみる

まずはviewportに追加するviewをちょこっと変更します。

app.js

Ext.application({
    ... 中略
    views: [
        'Main',
        'Menu'
    ],
    ... 中略
    launch: function() {
        // Destroy the #appLoadingIndicator element
        Ext.fly('appLoadingIndicator').destroy();

        // Initialize the main view
        Ext.Viewport.add([{
            xtype: 'slidemenu'
        }, {
            xtype: 'main'
        }]);
    },
    ... 中略
});

viewsプロパティに先ほど作成したMenuクラスを追加してあげて、Ext.Viewport.add メソッドの引数をオブジェクトリテラルの形に書き換えてMenuクラスも一緒に追加してあげます。

これだけだと、メニューの開閉ができないのでMainクラスも少し調整して、タイトルバーにボタンを追加して、ボタンの handler 内でMenuのインスタンスを取得して toggle メソッドを実行してあげます。

app/view/Main.js

Ext.define('App.view.Main', {
    ... 中略
    config: {
        tabBarPosition: 'bottom',
        items: [{
            ... 中略
            items: {
                ... 中略
                items: [{
                    xtype: 'button',
                    iconCls: 'more',
                    handler: function() {
                        Ext.Viewport.down('slidemenu').toggle();
                    }
                }]
            },
            ... 中略
        }, {
            ... 中略
        }]
    }
});

これを実行してみてみると

こんな感じで切り替わりますが、アニメーションしません。
これをアニメーションさせるためにはCSSの指定を入れてあげます。

アニメーション用のCSS定義を行う

index.htmlのローディング用CSSが記述されているところに次のCSSを追記します。

※ちょいと手抜き指定です

index.html

<!DOCTYPE HTML>
<html manifest="" lang="en-US">
<head>
    <meta charset="UTF-8">
    <title>App</title>
    <style type="text/css">
        ... 中略
        #ext-viewport > .x-body > .x-layout-card {
            -webkit-transition: all, 0.2s;
        }
    </style>
    ... 中略
</head>

というような感じで translate を実行している対象のnodeにアニメーション用のスタイルを書いておいてあげると

gifアニメーションなので若干コマ落ちしてますがお気になさらず。
以上でドロワーメニューのコンポーネントの完成です。

Menuクラス自体は単なるコンテナクラスなので、layout: 'vbox' とかにしておいてあげて button と component を縦に並べて装飾してあげれば、比較的簡単に下記のような感じにすることができます。

ということで以上になります。
なんかおかしいところあったら教えてください ;(

明日は @uno_yuta さんでございまする。