第5回ではフォーマッターとサービスについて解説しました。今回はルーティングによりアプリケーションの中でビューを切り替える方法について解説します。
今回の元記事は6. Creating Viewsです。
まずはサンプルアプリを動かしてみよう
こちらにサンプルアプリのソースコードがあります。リポジトリをクローンして、Chapter_06ディレクトリで> pub serve
すればサンプルアプリが起動します。
新たに"Add Recipe"ボタンと、"Edit Recipe"ボタンが追加され、レシピの詳細表示にURLが対応するようになりました。
ビューのカプセル化
大きくなってきたrecipe_book.html
をリファクタリングするために、次の2つのコンポーネントを新たに作成します。
- SearchRecipeComponent : レシピの検索部分
- ViewRecipeComponent : レシピの詳細表示部分
<h3>Recipe List</h3> ...
<search-recipe
name-filter-string="nameFilter"
category-filter-map="categoryFilterMap">
</search-recipe> ...
<div id="recipe-list">...</div> ...
<section id="details">
<ng-view></ng-view>
</section>
<div id="filters">
<div>
<label for="name-filter">Filter ...</label>
<input id="name-filter" type="text"
ng-model="nameFilterString">
</div>
<div>
Filter recipes by category:
<span ng-repeat="category in categories">
<label>
<input type="checkbox" ... >{{category}}
</label>
</span>
</div>
<input type="button" value="Clear Filters"...>
</div>
SearchRecipeComponent
にはnameFilterString
とcategoryFilterMap
の2つのフィールドが定義されています。この2つはclearFilter()
でDart側から値を更新するために双方向にバインディングします。
@Component(
selector: 'search-recipe',
templateUrl: 'search_recipe.html')
class SearchRecipeComponent {
Map<String, bool> _categoryFilterMap = {};
final List<String> categories = [];
@NgTwoWay('name-filter-string')
String nameFilterString = "";
@NgOneWay('category-filter-map')
Map<String, bool> get categoryFilterMap => _categoryFilterMap;
void set categoryFilterMap(values) {
_categoryFilterMap = values;
categories.clear();
categories.addAll(categoryFilterMap.keys);
}
void clearFilters() {
_categoryFilterMap.keys.forEach((f) => _categoryFilterMap[f] = false);
nameFilterString = "";
}
}
URLによりビューをルーティングする
これまではアプリケーションはひとつのビューしか使っておらず、URLは変わりませんでした。ルーティングを使うことでビューごとにURLが変わり、レシピごとにブックマークできるようになります。
ルーティングの設定
ルーティングを定義するにはルーティング設定を初期化するためのtypedef RouteInitializerFn
を実装する必要があります。RouteInitializerFn
が取る引数はRouter
とRouteViewFactory
の2つで、これらはAngular側から与えられます。
recipeBookRouteInitializer(Router router, RouteViewFactory views) {
// ...
}
実装したRouteInitializerFn
はモジュールにバインドします。
bind(RouteInitializerFn, toValue: recipeBookRouteInitializer);
ルーティングの設定をするためにRouteViewFactory#configure()
にngRoute
のマップを渡します。
void recipeBookRouteInitializer(Router router, RouteViewFactory views) {
views.configure({
'add': ngRoute(
path: '/add',
view: 'view/addRecipe.html'),
'recipe': ngRoute(
path: '/recipe/:recipeId',
mount: // ...
});
}
ngRoute
には次のフィールドが設定できます。
-
path
: ルーティングを行うURLパス(相対パス) -
view
:<ng-view>
に読み込まれるHTMLファイルの相対パス -
enter
: ルーティングが行われた際に呼び出されるイベントハンドラRouteEnterEventHandler
-
preEnter
: ルーティング前に呼び出されるイベントハンドラRoutePreEnterEventHandler
。戻り値によりルーティングを中断することができる。 -
leave
: ルーティング終了前に呼び出されるイベントハンドラRouteLeaveEventHandler
。戻り値によりビューの変更を禁止することができる。 -
defaultRoute
: パスが見つからない際のデフォルトルーティング先にするかどうか
ルーティング時の処理
サンプルのdefaultRoute
では複雑な処理を行っています。/recipe
以下のルーティングで不正なURLが与えられた場合、enter
が呼び出されます。enter
ではrouter.go()
によって自動的にview
ルーティングに切り替えるようになっています。また、replace: true
により、不正なURLは履歴に残らないようにして、ブラウザバックできないようにしています。
'view_default': ngRoute(
defaultRoute: true,
enter: (RouteEnterEvent e) =>
router.go('view', {},
startingFrom: router.root.findRoute('recipe'),
replace: true))
ルーティングの入れ子構造
mount
はルーティングを入れ子にするためのフィールドです。次の例では/recipe/:recipeId
の中に、/recipe/:recipeId/view
を設定しています。
'recipe': ngRoute(
path: '/recipe/:recipeId',
mount: {
'view': ngRoute(
path: '/view',
view: 'view/viewRecipe.html'),
// ...
})
今回のアプリケーションでは次のURLをルーティングしています
.../#/
.../#/add
.../#/recipe/6/view
.../#/recipe/6/edit
ルーティングとビューの接続
設定したルーティングのビューは<ng-view>
と接続されます。
<section id="details">
<ng-view></ng-view>
</section>
<ng-view>
は現在のルーティングに合わせてテンプレートをレンダリングして表示します。
ルーティングからパラメーターを得るためにはRouteProvider
を使います。ViewRecipeComponent
ではルーティングによりレシピのIDを取得しています。
ViewRecipeComponent(RouteProvider routeProvider) {
_recipeId = routeProvider.parameters['recipeId'];
}
その他の機能
今回のサンプルアプリケーションでは、RecipeBookComponent
からクエリ層をQueryService
に分離しています。QueryService
はもともと_loadData()
で行っていた処理に加えていくつかの追加機能があります。
QueryService
から取得できるのは具体的なオブジェクトではなく、Future
です。このアプリケーションはデータの量が大きくなると、読み込みに時間がかかるようになることが予想されるからです。これは第5回のサンプルアプリケーションの_loadData()
も同様です。
QueryService
にはキャッシュが実装されています。すでにデータを読み込んでいる場合はキャッシュからデータが読み取られます。また、Future
の連結によってデータが読み込まれたかどうかを保証しています。
@Injectable()
QueryService(Http this._http) {
_loaded = Future.wait([_loadRecipes(), _loadCategories()]);
}
_loadRecipes
と_loadCategories
の呼び出しはFuture.wait
にラップされています。Future.wait
は「与えられたFuture
がすべて完了したときに値を返すFuture
」を返します。また、@Injectable
アノテーションはクラスをAngularによる依存性の注入に使えるように公開します。
QueryServie
のゲッター(getRecipeById
, getAllRecipes
, getAllCategories
)はまず最初にデータがキャッシュされているかどうかをチェックします。キャッシュされていなければロードが終わるまで待ってから値を返すFuture
を返します。キャッシュがある場合はキャッシュされた値を返すFuture
を返します。
Future<Recipe> getRecipeById(String id) {
return _recipesCache == null
? _loaded.then((_) => _recipesCache[id])
: new Future.value(_recipesCache[id]);
}
まとめ
- ルーティングは
RouteInitializerFn
を実装する -
ngRoute
によりルーティングを定義する - ルーティングされたビューは
<ng-view>
に反映される
演習
今回のアプリケーションにはレシピの追加と編集の呼び出し部分だけを作りました。演習ではこの2つを実装してみましょう。演習の1と2ではレシピのデータの追加、変更は_recipesCache
の中だけに反映すればいいです。最後の演習では変更したデータをデータストア(今回はrecipes.json
)に書き込みます。
- レシピの変更をサポートしましょう。まずはレシピ編集用のコンポーネントを作る必要があります。初めはレシピのタイトルだけでもいいですが、すべてのプロパティを変更できるように徐々に改良していきましょう。
- レシピの追加もサポートしましょう。
- 1と2の演習が終わったら、変更したレシピをサーバーサイドのデータストアとして振る舞う
recipe.json
に書き込んでみましょう。QueryService
にいくつかのメソッドを追加する必要があるでしょう。Dart EditorからRun in Dartium
で起動した開発用のサーバーはリソースの保存に対応していないため、この演習のテストを行うにはサンプルアプリケーションのbin
フォルダに提供されているコマンドライン用のサーバーアプリケーションを実行するか、pub serve
を使う必要があります。
さらに演習
演習を終えてまだ余裕がある方は、レシピが編集中のときはページを離れられないようにルーティングのleave
を実装してみましょう。
最後の第7回は7. Deploying Your App
を元にバージョン1.0に置き換えて解説します。