のらねこの気まま暮らし

技術系だけど、Qiita向きではないポエムとかを書くめったに更新されないヤツ

AngularJSでDOMの更新を監視する with iScroll4

iScrollをAngularJSと使いたい案件があったので、それを使うためにいろいろ調べた。

DOMを動かしてScroll

position:fixed等、モバイルでは一部バグがあったり非サポートだったりして、その大体にiScrollやscrollerjs等といったライブラリを用いる。

これらはDOMをCSSとJSで動かしてあたかもスクロールしているかのように見せるライブラリだ。

いろいろライブラリがあるけれど、とりあえずiScroll4を使った。 一度使ったことがあったし、ng-iscrollも使っていたので。

ng-iScroll

Angular x iScrollを使うとなった時、google先生に問うと真っ先にでてくるのだけど、個人的な都合で利用を避けた。

DOMの変更を監視

ng-iScrollのやり方に沿えば、実際iScroll自体は動いたのだけれど、そのままだと、Ajaxで取得したHTML系のコンテンツが非同期的に投入されたときにiscroll.refresh()してくれない。

そこでどうしたかっていうのが本題。

$watchを使う

以下のコードを見てくれ、どう思う?

scope.$watch(function () {
  scroller.refresh()
});

・・・実はこれでも、XHRで取得したDOMを追加したときにrefreshしてくれる。 でもこの使い方間違っているので注意。

ドキュメントを見てもらえればわかるけれど、$watchの第一引数は、監視する値を示していて、変更時に走らせる処理は第2引数に指定しなければならない。

一応、第1引数であるwatchExpressionは、functionを渡すことも可能で、値の変更時に呼ばれるのでこれでも動くが、明らかにBKであり、間違っている。

ではどうすればいいのか。 それを知るには、$watchを含めたscopeのライフサイクルを理解しなければならない。

$watchと$digest

$watchの仕組みや動くタイミングは以下の記事が丁寧にかかれているので参考になった。

AngularJS のデータバインドを支える $watch

$watch$digestで呼ばれており、XHRや値の変更時等にdigestが呼ばれる。

以下の記事を併せて読むとより理解が深まるんじゃないかと。

AngularJS の $watch, $digest, $apply について書く

DOMの高さの更新を検知したい

さて、$watchで登録したfunctionがいつどこで呼ばれるかがなんとなくわかったと思う。

今回僕が知りたかったのはiScrollの対象としたDOMの高さが変わったタイミングだ。 つまり、こうすれば取れるはず。

angular.module("myApp").directive("ngScrollable", function () {
  return {
    restrict: "A",
    link: function (scope, elements, attr) {
      var element = elements[0];
      var content = element.children[0]; // スクロールさせたいコンテンツ。適宜セレクタは書き換えるべし。

      var scroller = new iScroll(element, {});

      scope.$watch(function () { return content.clientHeight }, function {
        scroller.refresh();
      });
    }
  }
});

ここまでで2時間かかった・・・ Angular奥が深い

AngularJS x UI-RouterでpushStateを使う

何も考えずにAngularJSを使うと、遷移のURLはハッシュフラグメント(#)を使うことになる。 しかし、ngRouterの$locationProviderにはhtml5Modeという機能があり、こいつを有効にすると、pushStateを使ってURLを構築することができる。

それをUI-Routerで使うよという話。

UI-Routerも$locationProviderをそのまま利用可能

ほんとうにまんま一緒に有効化できる。

ngRouterの場合:

angular.module("myApp").config(["$locationProvider", function ($locationProvider) {
  $locationProvider.html5Mode(true);
}]);

UI-Routerの場合:

angular.module("myApp").config(["$locationProvider", function ($locationProvider) {
  $locationProvider.htmlMode(true);
}]);

ui-srefの扱い

UI-Routerを使っていて感動したのは、ui-srefの扱い。 ui-srefはstateを指定し、そのstateで定義したURLをhrefの属性に変換してくれるDirectiveですが、html5Modeを有効にすると、ハッシュ(#)を取り除いてくれる。

つまり、URLの差し替えとかせずにhtml5Modeに移行できる。 なので、UI-Routerを使っているなら、hrefよりもui-srefを使ったほうが可用性が高いです。

grunt-contrib-connectと併せて使いたい

変更を最小限にhtml5Modeを利用できるのは素晴らしいが、pushStateを使う以上は当然、サーバ側にも手を入れなければならない。

基本的には/のアクセスは/index.htmlにリライトされているだけなので、/hogeとかのURLを(内部の遷移ではなく)叩いてしまうと、当然Not Foundになる。

わざわざテンプレートをRouterにまかせて管理しているのに、同じようなHTMLを大量に生産するのも無駄過ぎるし、一般的なプロラマなら違う方法があると考える。

connect-rewrite

grunt-pluginではないが、URLに対して特定コンテンツを返す様に設定できるnode-connectのプラグインがある。これをgrunt-contrib-connectのmiddlewareとして利用してやればいい。

/api, /css, /js, /tmpl, /fonts, /imgのHTTP-PATHを除くリクエストをすべて/index.htmlへリライトしたい場合は以下の様に書く。

connect: {
  server: {
    options: {
      host: "localhost",
      port: 8000,
      base: "www",
      middleware: function (connect, options) {
        if (!Array.isArray(options.base)) options.base =[options.base];
        directory = options.directory || options.base[options.base.length - 1];

        // node-moduleを読み込み
        modRewrite = require("connect-modrewrite");

        middlewares = [];
        middlewares.push(connect.directory(directory));
        // modRewriteの設定
        middlewares.push(modRewrite([["!/api|/css|/js|/tmpl|/fonts|/img /index.html"]);
      }
    }
  }
}

備忘録

pushState対応してて気づいた余談。

RouterのtemplateUrlは絶対パスで記述しよう

相対パスで指定してて、/hogeなURLでアクセスした時にテンプレートが404になって困った。 templateUrlとしてはhttp://localhost:8000/tmpl/fuga.htmlを期待してても、相対パスで書かれていると、/hogeから/fugaに遷移しようとしたときに読みに行くURLはhttp://localhost:8000/hoge/tmpl/fuga.htmlになる。

WEB屋としてはあまりに当たり前なコトなのだけど、サンプルで書かれてた相対パスをそのまま使っていた。まあ、ハッシュフラグメントでやっている限りは困らないけど。

URLの指定にHashfragment(#)ではなくHashbang(#!)を使いたい

$locationProvider.hashPrefix('!');ってすれば、#!になる。

Angular UI-Routerのviewsの定義を確認してみた

state.viewsの指定方法を理解してなかったので調べた。

基本

viewの名前はビュー名ステート名@で結合したviewName@stateNameで構成される。

無名のview

ui-viewは名前を与えず無名のviewとして定義ができる。

<div ui-view></div>

その場合、stateは""でviewを指定する

$stateProvider
  .state("example", {
    views: {
      "": "example.html"
    }
  });

viewのstateを省略した場合

viewsの定義文字列は@でビュー名とステート名を区切る。 ビュー名に@が含まれていない場合、ビュー名の末尾に@と親ステートの名前^1を結合してview名する。

$stateProvider
  .state("example", {
    views: {
      "main": {
        templateUrl: "example.html"
      }
  })
  .state("example.child", {
    views: {
      "main": {
        templateUrl: "example.child.html"
      }
    }
  });

このようなstatesを定義した場合、viewsは以下に変換される

views: {
  "main@": {
    templateUrl: "example.html"
  }
}
views: {
  "main@example": {
    templaetUrl: "example.child.html"
  }
}

viewsを省略した場合

viewsは省略することができる。

$stateProvider.state("example", {templateUrl: "example.html"});

上記の場合、viewsの指定は以下に変換される。

views: {
  "@": {
    templateUrl: "example.html"
  }
}

rootステートについて

親要素を$stateProviderに定義しないステートもrootという親をもっている。

root = registerState({
  name: '',
  url: '^',
  views: null,
  'abstract': true
});

これによって、例えば@だけをviewsに指定した場合は最上位に定義されている無名ui-viewを指定したことになる。

AngularJSでdirectiveを作ってみる

2014-03-25っていう文字列の日付をyear, month, dayの3つのフォームに分割して入力したい、と思った。

一つのngModelからフォーマットを変えてinputを並べればいいとおもって、directiveを使ってみた記録。

<input type="date" name="year"     ng-model="user.birth_on">
<input type="date" name="month" ng-model="user.birth_on">
<input type="date" name="day"     ng-model="user.birth_on">

directiveの作り方

ngModelController$formattersというプロパティがArrayで存在するので、そいつにフォーマット用のfunctionを登録してやる。

  • $filterを利用してdateを整形する
  • 整形文字列は、ui-date-formatの属性で指定できるようにする
    • 例)ui-date-format="yyyy"
  • フォーマットする文字列はmodelから取ってくる
angular.module("app")
  .directive("uiDateFormat", [
    "$filter",
    function ($filter) {
      return {
        restrict: "A",
        require: "ngModel",
        link: function (scope, element, attrs, ctrl) {
          ctrl.$formatters.push(function (view_value) {
            return $filter("date")(view_value, attrs.uiDateFormat);
          });
        }
    }
  ]);
<input type="date" name="year"     ng-model="user.birth_on" ui-date-format="yyyy">
<input type="date" name="month" ng-model="user.birth_on" ui-date-format="M">
<input type="date" name="day"     ng-model="user.birth_on" ui-date-format="d">

ui-dateというAngular UIのライブラリ

ui-dateを使えばいいという気はしつつ、ui-dateはjquery-uiとjqueryに依存しているし、そこまでの機能は必要なさそうだった。 なので、勉強を兼ねて作ってみた。

AngularJS x UI-Routerの使い方

UI-Routerはとても強力で、ngRouterよりも多くの事ができるのでとても便利。 しかし、強力すぎてRouterの概念自体を塗り替えてしまっていてなかなかピンと来てなかったのでまとめてみる。

リストページと詳細ページを別々のページとして用意する

state-router.js

app.config(["$stateProvider", function ($stateProvider) {
    $stateProvider
        .state("list", {
            "url": "^/list",
            "templateUrl": "list.html",
            "controller": "ListCtrl"
        })
        .state("detail", {
            "url": "^/detail/:detail_id",
            "templateUrl": "detail.html",
            "controller": "DetailCtrl"
        })
     ;
}])

index.html

<div ui-view></div>

list.html

<ul ng-repeat="content in contents">
  <li><a href="#/detail/{{content.id}}">{{content.title}}</a></li>
</ul>

detail.html

<dl>
  <dt>ID</dt>
  <dd>{{detail.id}}</dd>
  <dt>Title</dt>
  <dd>{{detail.title}}</dd>
</dl>

リストと詳細がひとつのページの中にある

2カラムレイアウトでlistとdeitalを同じページの中に内包する。

state-router.js

app.config(["$stateProvider", function ($stateProvider) {
    $stateProvider
        .state("contents", {
            "templateUrl": "contents.html",
            "controller": "ContentsCtrl"
        })
        .state("contents.detail", {
            "views": {
                "detail@contents": {
                    "templateUrl": "detail.html",
                    "controller": "DetailCtrl"
                }
            }
        })
    ;
}])

index.html

<div ui-view></div>

list.html

<nav class="left-column">
  <ul ng-repeat="content in contents">
    <li><a ui-sref="contents.detail({id: content.id})">{{content.title}}</a></li>
  </ul>
</nav>

<div class="right-column" ng-view="detail">

detail.html

<dl>
  <dt>ID</dt>
  <dd>{{detail.id}}</dd>
  <dt>Title</dt>
  <dd>{{detail.title}}</dd>
</dl>

2カラムレイアウトでdetailをURLで指定できるようにする

/contentでリストとindexを、/content/{detail_id}で指定したdetailを表示できるようにする。

state-router.js

app.config(["$stateProvider", function ($stateProvider) {
    $stateProvider
        .state("contents", {
            "url": "^/content",
            "templateUrl": "contents.html",
            "controller": "ContentsCtrl"
        })
        .state("contents.detail", {
            "url": "/:id"
            "views": {
                "detail@contents": {
                    "templateUrl": "detail.html",
                    "controller": "DetailCtrl"
                }
            }
        })
    ;
}])

まとめ

  • stateは継承が可能
    • .で連結することで、親.子というstateを定義できる
  • viewsにビュー@ステートを指定すると、親ステートで定義したui-viewにtemplateを埋め込むことができる
  • stateをネストした場合、URLは親のURLに追加される形で指定できる
    • ^からURLを開始する(^/content/:detailというように)ことで相対ではなく絶対パスで指定できる
  • hrefの代わりに、ui-sref属性を用いることで、URLではなくstateをリンクに指定できる。
    • ui-srefを利用するばあい、$stateProviderに定義されてないstateを指定するとリンクにならない

UI-Routerの理解に一日費やしたので、とりあえず僕の理解の範囲をまとめた。 ややこしいけど大変便利!

homebrewでperlオプションのついたvimをインストールする術

Macでちょっと複雑な一斉置換を行いたかったのだけれど、perldoが無いって起こられたのでいろいろやった。

perlフラグが立っていない

$ vim --version | grep perl
+cmdline_compl   +insert_expand   -perl            +user_commands

perlのoptionを確認する

試しに打ってみたら、居たっぽい

$ brew options vim
--disable-nls
        Build vim without National Language Support (translated messages, keymaps)
--override-system-vi
        Override system vi
--with-client-server
        Enable client/server mode
--with-lua
        Build vim with lua support
--with-mzscheme
        Build vim with mzscheme support
--with-perl
        Build vim with perl support
--with-python3
        Build vim with python3 support
--with-tcl
        Build vim with tcl support
--without-python
        Build vim without python support
--without-ruby
        Build vim without ruby support
--HEAD
        install HEAD version

入れなおす

$ brew uninstall vim
$ brew install vim --with-perl --with-lua --override-system-vi
==> Installing vim dependency: lua
==> Downloading https://downloads.sf.net/project/machomebrew/Bottles/lua-5.1.5.mountain_lion.bottle.tar.gz
######################################################################## 100.0%
==> Pouring lua-5.1.5.mountain_lion.bottle.tar.gz
🍺  /usr/local/Cellar/lua/5.1.5: 15 files, 300K
==> Installing vim
==> Downloading http://ftp.debian.org/debian/pool/main/v/vim/vim_7.4.161.orig.tar.gz
######################################################################## 100.0%
==> ./configure --prefix=/usr/local --mandir=/usr/local/Cellar/vim/7.4.161/share/man --enable-multibyte --with-tlib=ncu
s==> make
==> make install prefix=/usr/local/Cellar/vim/7.4.161 STRIP=/usr/bin/true
🍺  /usr/local/Cellar/vim/7.4.161: 1566 files, 25M, built in 108 seconds

確認する

:perl[tab]したらperldoされて無事使えたのでこれで完了。 やっぱPerlは最高だぜ!!!!

Heimdall.jsを0.0.2にあげたよ #開発二部

金剛改二がかわいすぎて何も考えずにクリックするだけなのが最近の提督業です。 こんばんわ、Heimdall.jsを0.0.2にアプデしました。

いんすとーる?

bowerでインストールしとくと管理が楽なのでたとえば、

$ bower install --save 'rymizuki/Heimdall#0.0.2'

とかすればいいと思います。ヽ(=´▽`=)ノ

なにが変わったの?

主な更新は二点

  • オレオレコーディングルールだったのを一部jquery-pluginの流儀に合わせた
  • 特定のinputに対してバリデーションできるようになった

一つ目のは単にgrunt周りとjshint周りを調整したのとか細かいのだけ。ホントはインデントとかメソッド名とかも調整したかったんだけど、結構手間・・・だったので・・・。 気付きとしては、testにjshintかけるの大切だなぁと思ったこと。

testemを使ってmochaでテストを走らせているのだけれど、method名間違ってたりtypo系のうっかりミスが多かったりして、この作業中に気づいた事が多々。 javascriptのsyntax errorが出ると走らないんだよね、テスト。でも正常終了する。怖い。 完璧ではないけれど、ブラウザでの動作確認、及びjshintによる制約掛けは大切ですね!

ふたつめ。 布教も兼ねてformizeつかって説明します。←

var heimdall = $.heimdall({
  name: ['required', ['length', [1, 10]],
  age   : ['required, 'int'],
  // ... その他のrule
});

var form = $.forimze('#my-form');
form.getControl('name').on('keyup', function () {
  var $this = $(this);
  var result = heimdall.validate('name', $this.val());

  if (result.has_error()) {
    alert('nameは1文字以上10文字以下じゃないとダメなんだお ヘ(゚∀゚ヘ)アヒャ');
  }
});
form.on('submit', function () {
  var data = form.getValues();
  var result = heimdall.validate(data);

  if (result.has_error()) {
    alert('正しくない入力があるから送信できないよぅ (´Д⊂グスン');
  } else {
    $.post(url, data).done(function () {
      alert('送信したお ヽ(=´▽`=)ノ');
    });
  }
});

nameというname属性を与えたinput要素のkeyupイベントに、入力値が1文字以上10文字以下じゃないとnameは1文字以上10文字以下じゃないとダメなんだお ヘ(゚∀゚ヘ)アヒャという文言を表示するなんとも鬱陶しいコードが出来上がりました。たぶん動くはず。

何が言いたいかというと、formって単位じゃなくてinputの単位でも入力値のチェックが簡単にできるようになったということで、よくある入力がOKになるまで赤文字でエラー文言だしとくとか、入力値がエラーになったタイミングで表示を切り替えるとかが簡単にできるようになったわけです。ビバリッチUI!

ぜひつかって見てね☆ミ