WebTecNote

[MooTools Tutorial] MooTools用プラグインの作り方

このエントリーはMooToolsチュートリアル特別編で、MooTools用プラグインの作成手順をステップバイステップで晒しています。
解説ソースの元にしているのはmooContreGalleryですが、
チュートリアルに使っているソースは実際配布しているものと仕様が異なります。

初歩的な説明はかっ飛ばしているので、詳しい解説については公式のドキュメントとか高橋文樹さんの日本語訳ドキュメントなどを参考にどうぞ。

プラグインを作るにあたっての前提と必要なファイルの用意

mooContreGalleryはhttp://www.contreforme.ch/のProjectsページで使われている
画像拡大、スクロール、インフォメーション表示など一連のエフェクトを実装するプラグインです。
元々「これどうやって作るの?」という質問に答えるために作ったものですが、結構いい感じに出来たので許可を頂いた上で公開するに至りました。
そういうわけなのでこのチュートリアルは質問に対する回答でもあります。

HTMLソースは本家とだいたい同じという前提ですが、すべてをスクリプト側で補うようには作っていないので
利用にあたってはCSSやソースにいくつか決まりごとがあります。

MooToolsでは配布しやすくするためにClassを使って作るのが一般的。
プラグイン化するにあたって考えた仕様は次の通りです。

プラグインは任意で設定変更出来なきゃ意味がない。ってことで、これらはオプションで実現させました。

XHTMLファイルと画像、MooTools Core、Moreを用意。
Moreでは、画像読み込みをするのでAssets、開いたときにスクロールさせるのでScroll、説明をスライドさせるのでSlideが必要です。
(…と結論を書いてるけどMoreは作りながらリストアップしていく事が多い)

XHTMLソースの例:

<ul id="gallery"><!--ID指定するギャラリー-->
	<li><!--ギャラリーの子要素(自動取得)-->
		<a class="thumb" href="images/cat1.jpg"><img title="cat1" src="images/cat1_s.jpg" alt="cat1" width="100" height="68" />
		<!--サムネイル画像とそれを入れる要素。クラス名必須-->
		</a>
		<div class="info"><!--スライドされる説明要素。クラス名必須--></div>
	</li>
</ul>

noscript対策で拡大画像にリンクする場合は上記ソースのようにサムネイルを入れる要素がaタグになります。
サンプルでは上2つだけこのソースです。

続きから口調が投げやりなふいんきになります。

Step0: The base Class of the MooTools framework

MooToolsのクラスは、書き方が決まっているのでそれさえ覚えてしまえば作るのは簡単。
Coreで提供されるnewで始まる機能は全部クラスなので、圧縮されていないソースを直接見ると作る時の参考になる。

クラスの基本的な構文はドキュメントを参照して頂くとして、
新たに作るプラグインについて

以上のように決めてクラスのベースを書くと次のようになる。

var mooContreGallery = new Class({

	//Optionsクラスを継承して実装
	Implements: [Options],

	//このクラスのオプション
	options: {
	},

	//初期化メソッド
	initialize: function(element, options) {
		//OptionsクラスのsetOptionsメソッドでクラスのオプションに渡されたユーザ定義オプションを追加
		this.setOptions(options);
	}
});

インスタンス作成時に new mooContreGallery("gallery"); として、UL要素につけたIDを必須にするため、
initializeの引数に、IDを格納するelementと、クラスオプションを格納するoptionsを指定する。

これがテンプレートみたいなものなので、スニペットとかに保存しとけばコピペでプラグインが作れるようになります。

【補足】initializeプロパティについて

PHPで言うところの__constructorです。

下記のようにdomreadyイベントなどでクラスインスタンスを作成すると、自動的にinitializeが1度だけ実行される。

window.addEvent("domready",function(){
	new mooContreGallery("gallery");
});

変数への代入や要素の取得などの初期処理を記述すれば呼び出されるたび初期値に戻る。
簡単な機能を提供するクラスであれば、initializeにすべて記述しても良い。

【補足】Implementsプロパティについて

Implementsをすると他のクラスの機能がコピーされる。
Implements: [Options] とした場合は、現在製作中のmooContreGalleryクラスに
Optionというクラスの機能を複製するので、クラス内には無いthis.setOptionsメソッドが使えるようになる。

Step1: ギャラリー要素の取得とエフェクトの決定

インスタンス作成時に指定されたIDを元に要素を取得する。

MooToolsで任意の要素を取得するには、ドル関数を使う方法と、
目印となる要素を1つ決めてMooToolsのElementメソッドで取得する方法がある。
後者はgetChildrenやgetFirstなどを使うため、IDやクラスを指定しなくてもいい反面HTMLソースの構造が変わると使い物にならないというデメリットがある。

「要素を得る時に使うIDやクラス名は変更できるようにする」と決めたので、オプションに変更可能な設定を入れておく。

options: {
	infoClass:".info", //スライドさせる要素のクラス
	thumbClass:".thumb" //サムネイル画像の親クラス
},

最後にカンマを付けるとIEでエラーが出るので注意。

とりあえず直接の子であるLI要素はgetChildren()で取得するが、その前に
インスタンス作成時に渡されたIDを持つ要素が無かったら実行しないようにreturnを置いておく。

var mooContreGallery = new Class({
	Implements: [Options],
	options: {
		infoClass:".info", //スライドさせる要素のクラス
		thumbClass:".thumb" //サムネイル画像の親クラス
	},

	initialize: function(element, options) {
		this.setOptions(options);
		if(!$(element)) return;//実行キャンセル
		this.lists = $(element).getChildren();//リスト要素取得
		//console.log(this.lists);
	}
});

これを実行してthis.listsに各リスト要素が格納されていればOK。
ここまでのサンプル

Step2: eachで配列を処理

ここからは編集中のソースだけ表示する。全文はサンプルを見てください。

リスト要素が変数に格納されたら、その子要素であるサムネイル画像と説明要素を処理する。
リスト要素は複数あり、その複数ある要素を1つずつ処理しなければならない。つまり反復処理だ。

反復処理でお馴染みはfor文だが、ここでは便利なeachを使う。
Arrayメソッドのeachは配列のみ、$eachはオブジェクトなどにも使用出来るという地味な違いがある。
eachを使うと、繰り返し実行される関数の引数に配列の要素やキー番号が格納されるので、関数内でそれらを使用することが出来る。

initialize: function(element, options) {
	this.setOptions(options);
	if(!$(element)) return;//実行キャンセル
	this.lists = $(element).getChildren();//リスト要素取得

	//elはリスト要素、indexはキー番号
	$each(this.lists,function(el,index){
	});
}

eachでLI要素を1つずつ処理して行うのはエフェクトを掛ける要素の取得と、インスタンスの作成。
このクラスで使う主なエフェクトと要素は次の3つ。

サムネイルの拡大縮小にはFx.Morphを使う。
何でMorphなのかというと、幾つかの効果を同時に与えなくてはならない為。1つだけならTweenで良い。
this.listsの下にFx.Morphオブジェクトを格納するメンバ変数morphを作成。空配列にしておく。

initialize: function(element, options) {
	this.setOptions(options);
	if(!$(element)) return;//実行キャンセル
	this.lists = $(element).getChildren();//リスト要素取得
	this.morph = [];//サムネイル画像のFx.Morphオブジェクトを格納する入れ物(配列)

	//elはリスト要素、indexはキー番号
	$each(this.lists,function(el,index){
});
}

Step2.2 サムネイル画像の取得

サムネイル画像をgetElementメソッドで取得して変数に一時保存する。
getChildrenやgetElementの引数にクラスやID、タグ名を指定すればその指定した要素だけ得る事が出来る。
サムネイル画像はリスト要素の子かつサムネイル要素の中にあるものでなければならないので、
まずel.getElement()でサムネイル要素を取得してから、getElementの引数にタグ名を指定して得る。
クラス名はあとで変更が出来るようにオプションにする。
設定したオプションはthis.options.名前とすれば値を得る事が出来る。
名前が長くなる時はメンバ変数に入れなおしても良い。

$each(this.lists,function(el,index){
var image = el.getElement(self.options.thumbClass).getElement("img");
});

Step2.3 配列のキー番号を保存

store()は任意のアイテムを保存出来る便利なElementメソッド。
el.index = index; と同等なので単純な値を保存するだけならstore()使わなくてもいいんだけど、
こういうのもあるよってことであえて使ってみる。

$each(this.lists,function(el,index){
var image = el.getElement(self.options.thumbClass).getElement("img");
el.store("index",index);
});

Step2.4 サムネイルのFx.Morphインスタンス作成

後で呼び出して実行するため、予め作っておいた配列にインスタンスを作っては入れ作っては入れしていく。
オプションにトランジションや遅延を新しく追加する。

options: {
	infoClass:".info",//スライドさせる要素のクラス
	thumbClass:".thumb",//サムネイル画像の親クラス
	thumbDration:700,//サムネイルの遅延
	tumbTransition:Fx.Transitions.Back.easeOut,//サムネイルのトランジション
},

配列に格納せずにクリックする都度インスタンスを作成するソースだと、オブジェクトが作られすぎて重くなってしまう。

$each(this.lists,function(el,index){
	var image = el.getElement(self.options.thumbClass).getElement("img");
	el.store("index",index);
	//サムネイルのFx.Morph
	self.morph[index]= new Fx.Morph(image,{duration: self.options.thumbDration, transition: self.options.tumbTransition});
});

Step2.5 クリックイベント

リスト要素にクリックイベントを追加する。

引数elにはリスト要素が格納されているので、addEventでクリックイベントを追加し
リスト要素のインデックス番号を表示させてみよう。
store()で保存した値はretrieve()で得る事が出来る。

$each(this.lists,function(el,index){
	var image = el.getElement(self.options.thumbClass).getElement("img");
	el.store("index",index);
	//サムネイルのFx.Morph
	self.morph[index]= new Fx.Morph(image,{duration: self.options.thumbDration, transition: 	self.options.tumbTransition});
	el.addEvent("click",function(e){
		e.stop();
		alert("open "+el.retrieve("index"));
	});
});

【補足】stop()について

addEventで実行される関数には引数でイベントオブジェクトが渡される。
冒頭でstop()しておくと、例えばリンクをクリックした時の移動などを行わないようにすることが出来る。

【補足】var self = this; について

グローバルな状態のthisはクラスオブジェクトそのものを指すが、
関数の中にあるthisは、その関数そのものだったり、要素だったり、windowだったりと、
クラスそのものを示すthisとは意味が違ってしまう。
bind()を使えばthisが示すオブジェクトは変更出来るものの、可読性が下がってややこしいので
ローカル変数に格納する方法を取る。

ここまでのサンプル

Step3: クリックで実行されるメソッドの作成

クリックイベントのalert()をクラスメソッドに変更する。名前は適当にopenとした。
$eachでサムネイル画像を取得しているので、引数でリスト要素(el)とサムネイル画像(image)を渡す。

//クリックイベント登録
el.addEvent("click",function(e){
	e.stop();
	self.open(el,image);
});

呼び出されるクラスメソッドopenの中であらかじめ作っておいたmorphをstartさせて、
透明度を変えるとローディングしてるっぽい雰囲気になる。
キー番号はretrieveメソッドでstoreした値を読み込む。

/**
* クリックで呼ばれる関数
* @param {HTML Object} el リスト要素
* @param {HTML Object} image サムネイル画像
*/
open:function(el,image){
	//console.log(image);

	var num = el.retrieve("index");
	this.morph[num].start({'opacity':0.3});
}

ここまでのサンプル

Step4: 画像の拡大

Step3で作成したopenメソッド冒頭でローカル変数を2つ作成。

open:function(el,image){
	var self = this;
	var num = el.retrieve("index");
	this.morph[num].start({'opacity':0.3});
}

Step3の透明度モーフィングの後に処理をするため、chainメソッドを使う。

open:function(el,image){
	var self = this;
	var num = el.retrieve("index");
	this.morph[num].start({'opacity':0.3}) //←セミコロンは削除
	.chain(function(){
	})
}

超はしょって説明すると、chainはコールバック関数を付け加えるもの…かな。クラスメソッドに有効で拡張メソッドには使えない。
ある関数の処理が完了してから次の関数を開始する、といった具合に関数を順番に実行させたいときに使う。
例として、
Object.chain(function1).chain(function2).chain(function3);
とした場合はfunction1から順に実行される。実行がつなげた順になるというだけで、結果が開始と同じ順になるとは限らない。

画像の読み込みはAssetsを使う。
これは前のチュートリアルで何度か取り上げているが、XHRでファイルを取得するMoreのクラス。
必須引数である読み込み対象のファイル名はサムネイル画像のsrcを置換する。

open:function(el,image){
	var self = this;
	var num = el.retrieve("index");
	this.morph[num].start({'opacity':0.3}) //←セミコロンは削除
	.chain(function(){
		//フル画像のプリロード Class Assets
		new Asset.image(image.src.replace("_s.","_b.")});
	})
}

読み込みが完了した時に実行されるonloadメソッドの引数には読み込んだ画像のオブジェクトが格納される。
それから得た画像のサイズをstoreで保存してから、Fx.Morphにより拡大する。

.chain(function(){
	//フル画像のプリロード Class Assets
	new Asset.image(image.src.replace("/s_","/b_"),{
		// @param {Object} e HTML IMG Object
		onload: function(e){
			
			//拡大サイズの保存
			image.store("width", e.width );
			image.store("height", e.height);
			
			//サムネイルモーフィング(open)
			self.morph[num].start({
				width: e.width,
				height: e.height
			});
		}
	});
})

このままだと属性とかがサムネイルのままなので、さらにchainを繋げて画像の属性を変更する。
ついでにカーソルをデフォルトに戻しておく。

.chain(function(){
	image.width = image.retrieve("width");//横幅変更
	image.height = image.retrieve("height");//高さ変更
	image.src = image.src.replace("_s.","_b.");//src変更
	self.morph[num].start({'opacity':1});//透明度down
	el.setStyle("cursor","default");//cursor:default
});

これで、クリック→ちょっと透けてローディング見える→拡大 というロジックが完成。

ここまでのサンプル

Step5: コメントを隠す

出っぱなしになっているコメントをスライドで隠そう。

先ずオプションにスライド用のDrationとTransitionを増やす。
冗長して見難くはなるが、任意で変えられそうな値はオプションにしといた方が後々楽。

options: {
	infoClass:".info",//スライドさせる要素のクラス
	thumbClass:".thumb",//サムネイル画像の親クラス
	imgWith:100,//サムネイルの初期横幅
	thumbDration:700,//サムネイルの遅延
	infoDration:800,//情報の遅延
	tumbTransition:Fx.Transitions.Back.easeOut,//サムネイルのトランジション
	infoTransition:Fx.Transitions.Expo.easeOut//情報のトランジション
},

Fx.Slideは任意の要素を縦または横方向に引き出しの如くなめらかにアニメーションさせるMoreのクラス。
このプラグインではコメントの表示・非表示に利用してます。

コメントのスライドはmorphと同じくインスタンスを保存するので、入れ物を用意しておく。

this.morph = [];
this.slide = [];

$eachの中でFx.Slideのインスタンスを作る。Fxなので書式はMorphとほぼ同じ。
第一引数にオプションで設定されたクラス名を持つ要素を指定するため、getElement()メソッドを使う。

self.slide[index] = new Fx.Slide(el.getElement(self.options.infoClass),{
	duration: self.options.infoDration,
	transition: self.options.infoTransition,
}).hide();

hide()メソッドを直接くっつけるだけで初期状態が非表示になる。
これだけでコメントが全て隠れたはず。

Step5.2: 画像が拡大した時に連動させる

Assetのonloadに一行slideInメソッドを付け加えるだけでOK。
拡大した後でスライドさせるなら次のchainに追加する。

new Asset.image(image.src.replace("_s.","_b."),{
	
	onload: function(e){
		
		//拡大サイズの保存
		image.store("width", e.width );
		image.store("height", e.height);
		
		//サムネイルモーフィング(open)
		self.morph[num].start({
			width: e.width,
			height: e.height
		});

		self.slide[num].slideIn();//スライドイン
	}
});

Step6: サムネイルの縦横サイズを保存

開く方が出来たので閉じるロジックを作成していくが、画像を元通り縮小させるにはサムネイルのサイズが必要になる。
全ての画像が同じサイズなら、オプションで新たに縦横サイズを追加すればいいが、
それぞれ異る画像サイズのサムネイルを使いたいので、initializeする時にメンバ変数へ保存しておくことにする。
※ここで配列にするかstore()にするかは作者の好み。

サイズを格納する入れ物を用意する。

this.size = [];//width and height

サムネイル画像自体は$eachで取得しているので、サイズはgetSize()で取得出来る。
用意した入れ物にgetSize()の返り値(widthとheightのオブジェクト)を入れていく。

$each(this.lists,function(el,index){	
	var image = el.getElement(self.options.thumbClass).getFirst("img");//サムネイル画像取得

	self.size[index] = image.getSize();//元サイズを格納
	
	//---省略----

getSizeメソッド使わない場合getSize()は次のようになる。

self.size[index] = { x: image.width, y:image.height };

クリックでオープンメソッドを実行する時に保存された値と横幅を比較するように修正しておく。

if(image.width == self.size[index].x){
	self.open(el,image);
}

これで、$eachが終った時点でthis.sizeには[{x:100,y:68},{x:100,y:122}….]と各サムネイルの初期サイズが格納される。

ここまでのサンプル

Step7:クローズロジック

前に拡大した画像をクリックした時に元に戻す。
新しくクリックされたものかそうでないものかの判別をして、既に拡大されているものだけを縮小するロジックを作る。

その要素がアクティブかどうか判別する方法はいくつかある。

  1. プロパティ値が初期と同じか比較
  2. Class名の有無を比較
  3. フラグを立てて比較

(2)は個人的にはあんまり使わない。なんとなくClass内部でこっそりやってる感じがイイと思うので。
(1)だとこの場合サイズの比較になるが、そうなると配列に保存された元サイズとの比較になり、
配列からサイズを引き出すには拡大されている画像のインデックス番号が必要なので、
それならインデックス番号をフラグにすればよい。というわけで今回は(3)の方法を取る。

initializeにアクティブになった時にインデックス番号を保存する入れ物を作る。

this.active = null;//アクティブな要素

openメソッド内でcloseメソッドを呼び出す。
実行条件はthis.activeに値があった場合のみ。

//アクティブな要素を閉じる
if(this.active != null) this.close();

openメソッドの一番最後でアクティブになった要素のインデックス番号を保存する。

this.active = num;//アクティブな要素のインデックスを保存

こうすると、ページ開いたばかりの初期状態はthis.activeがnullなのでclose()が実行されない。

閉じるのは開くのと逆なのでcloseメソッドのソースはopenと似たような感じになる。

close:function(el){
	var self = this;
	
	this.morph[this.active].start({'opacity':0.3})
	.chain(function(){
		this.start({
			width:self.size[self.active].x,
			height:self.size[self.active].y
		});
		self.slide[self.active].slideOut();//スライドアウト
	})
	.chain(function(){
		this.element.width = self.size[self.active].x;
		this.element.height = self.size[self.active].y;
		this.element.src = this.element.src.replace("_b.","_s.");
		self.morph[self.active].start({'opacity':1});
		self.lists[self.active].setStyle("cursor","pointer");
	});
}

ここまでのサンプル

ここまでのソースを実行すると、閉じるアクションは出来ているものの
縮小後のサイズが違ったりフェードが機能していなかったりする筈。
これはthis.activeの値がclose()のchainを実行する前に
open()最後尾のthis.active=numによりクリックした要素の番号に変わってしまっている為だ。
これを防ぐ為にthis.activeの値をclose()に引数として渡す。

呼び出し側

if(this.active != null){
	this.close(el, this.active);
}

関数側

/* 関数側 */
close:function(el, act){
	var self = this;
	
	this.morph[act].start({'opacity':0.3})
	.chain(function(){
		this.start({
			width:self.size[act].x,
			height:self.size[act].y
		});
		self.slide[act].slideOut();//スライドアウト
	})
	.chain(function(){
		this.element.width = self.size[act].x;
		this.element.height = self.size[act].y;
		this.element.src = this.element.src.replace("_b.","_s.");
		self.morph[act].start({'opacity':1});
		self.lists[act].setStyle("cursor","pointer");
	});
}

これで一応拡大縮小ができました。

【補足】アニメーションのキャンセルとチェイン

7の時点で、クリックしたら拡大他はクローズという動きは完成しているが、
連続してクリックしたり、拡大している途中で別の要素をクリックすると、透明度変更をすっ飛ばして縮小や拡大をしてしまう。
これはFxクラスのlinkオプションがデフォルトでignoreになっているためで、
エフェクト実行中に呼び出されたものはすべて無視される。
そこで、initializeのFx.MorphとFx.Slideにlinkオプションをchainで追加すれば、
キャンセルされることなく実行中のエフェクトが終ってから実行されるようになる。

そのままでいい場合は追加しなくても良い。(お手本サイトはデフォルトのまま)

self.morph[index]= new Fx.Morph(image,{
	duration: self.options.thumbDration,
	transition: self.options.tumbTransition,
	link:"chain"
});

Step8: スクロール

openした要素にスクロールさせる。使うのはFx.Scroll。
Fx.Scrollはoverflow値を持ったあらゆる要素(window含む)をスクロールさせるクラスで、SmoothScrollとは別物。

まずinitializeでFx.Scrollのインスタンスを変数this.Scrollerに格納。対象はwindowオブジェクト

this.Scroller = new Fx.Scroll(window, {dration:this.options.winDration, transition:this.options.winTransition});

インスタンスを作ったらあとはstart()させるだけ。
スクローラーのstartメソッドの引数はx軸座標とy軸座標の値。
CSSのpositionプロパティと同じなので、Elementから取得する場合はgetPositionやgetTopを使う。

まず、アクティブな要素かどうかチェックしているif文のelse。

if(this.active != null){
	this.close(el, this.active);
}else{
	this.Scroller.start(0,el.getTop());//スクロール
}

次に、close()にある最後のchain。

https://tenderfeel.xsrv.jp/wtn/wp-admin/post.php?action=edit&post=678&message=7
close:function(el, act){
	var self = this;
	
	this.morph[act].start({'opacity':0.3})
	.chain(function(){
		this.start({
			width:self.size[act].x,
			height:self.size[act].y
		});
		self.slide[act].slideOut();//スライドアウト
	})
	.chain(function(){
		this.element.width = self.size[act].x;
		this.element.height = self.size[act].y;
		this.element.src = this.element.src.replace("_b.","_s.");
		self.morph[act].start({'opacity':1});
		self.lists[act].setStyle("cursor","pointer");
		self.Scroller.start(0,el.getTop());//スクロール
	});
}

以上で完成!あとは好みの動きになるよう適当に変更すればおk。

ここまでのサンプル

モバイルバージョンを終了