スマホ向けフリック操作でめくれる縦スクロールカレンダーの作成

#動作イメージ
![test.gif](https://qiita-image-store.s3.amazonaws.com/0/102207/b5d072ec-4e30-5133-a852-ecc642005673.gif)

Windows 10 標準のカレンダーを眺めていて、ふとJavascriptで作れないか試してみました。

ソースコードは長くなってしまったので、[Plunker](http://embed.plnkr.co/iXBELTdM3bDTXBGCWBqZ/)にアップしました。

#カレンダー生成部
Dateオブジェクトの加減算とフォーマットに使用しているのは[こちら](http://qiita.com/Urushibara01/items/30607955511ac33d441a)の自作の日付用自作prototype関数を使用しています。

tableタグはスクロールや追加削除に影響が出そうだったので使用せず、全てdivタグで作成しました。

```javascript:部分コード
function makeCal(date, flg){
	var frame = $("<div/>"); //ダミーの枠
	
	//年月を取得する
	var this_year = date.getFullYear();
	var this_month = date.getMonth() + 1;

	//1日の曜日を取得する
	var first_day = (new Date(this_year, this_month -1, 1));
	
	var start_week = first_day.getDay();

	var start_day, end_day;
	
	if(flg == -1){ //先月用
		//同じ週の日曜日の日付を取得する
		start_day = first_day.add(-1 * first_day.getDay(), 'd');
		//末日の曜日ぶん引く
		end_day = first_day.add(1,'M').add(-1,'d');
		if(end_day.getDay() < 6){
			end_day = end_day.add(-1 * end_day.getDay() - 1, 'd');
		}
	} else
	if(flg == 0){
		//同じ週の日曜日の日付を取得する
		start_day = first_day.add(-1 * first_day.getDay(), 'd');
		//末日の取得
		end_day = first_day.add(1,'M').add(-1,'d');
	} else
	if(flg == 1){ //来月用
		//末日の取得
		end_day = first_day.add(1,'M').add(-1,'d');
		//開始日を次の週の日曜日にする
		if(first_day.getDay() > 0){
			first_day = first_day.add(7-first_day.getDay(), 'd');
			start_week = first_day.getDay();
		}
		start_day = first_day;
	}
	
	//日曜日から先月の末まで追加
	var line = $('<div class="week-line"/>');
	for(var i=0; i<start_week; i++){
		var day = start_day.add(i, 'd');
		$('<div class="days" date="'+ day.format('yyyy-MM-dd') +'">' + day.getDate() + '</div>').appendTo(line);
		if(day.getDay()==6){ /*土曜日なら追加して次の行を作成*/
			line.appendTo(frame);
			line = $('<div class="week-line"/>');
		}
	}
	
	//今月の1日から末まで順に追加
	for(var i=0, day=first_day; day.getDate() < end_day.getDate(); i++){
		day = first_day.add(i, 'd');
		$('<div class="days" date="'+ day.format('yyyy-MM-dd') +'">' + day.getDate() + '</div>').appendTo(line);
		if(day.getDay()==6){ /*土曜日なら追加して次の行を作成*/
			line.appendTo(frame);
			line = $('<div class="week-line"/>');
		}
	}
	
	//末日の曜日を取得
	var end_week = end_day.getDay();
	
	//土曜日までの残りの枠を次の月頭で埋める
	for(var i=(end_week+1), j=1; i<7; i++, j++){
		var day = end_day.add(j, 'd');
		$('<div class="days" date="'+ day.format('yyyy-MM-dd') +'">' + day.getDate() + '</div>').appendTo(line);
		if(day.getDay()==6){ /*土曜日なら追加して次の行を作成*/
			line.appendTo(frame);
			line = $('<div class="week-line"/>');
		}
	}
	return frame;
}
```

こんなにソースが複雑になってしまっているのは、カレンダーの上下が前後の月とつながるように表示させている為です。

- 第一引数：作成する日付のDateオブジェクト
- 第二引数：作成するカレンダーの指定 0:今月 1:来月～ -1:先月～

今月カレンダーは1日～末日まで全てを追加し、かつ余った部分は前後の月の末日と頭で埋めるように入れています。
このせいで、前後の月の追加には、削られた末日や頭の日付を除外する分岐処理が入っています。

#フリック制御部分
アップロードされているソースコードには、PC向けのマウス操作に対応するイベントも追加されていますが基本は同じです。

```javascript:部分コード
var touch_pos = {};
function touchstart(e){
	if(e.touches.length > 1 || touch_pos.start) return;
	touch_pos = {
		scrollPos: $(".scroll-area").scrollTop(),
		start: e.touches[0].pageY,
		pos: e.touches[0].pageY
	};
}

function touchmove(e){
	if (e.touches.length > 1 || !touch_pos.start) return;
	var pos = e.touches[0].pageY;
	$(".scroll-area").scrollTop(touch_pos.scrollPos + (touch_pos.start - pos));
	touch_pos.pos = pos;
	e.preventDefault(); /* Disable 'pull to refresh' for Android Chrome */
}

function touchend(e){
	var limit = $(".scroll-area").height() * 0.33;
	if(Math.abs(touch_pos.start - touch_pos.pos) > limit){
		var sgn = Math.sign(touch_pos.start - touch_pos.pos);
		//表示位置の設定
		var top = $("[date='"+sel_date.add(sgn, 'M').format('yyyy-MM-01')+"']").parent().position().top;
		$(".scroll-area").animate({scrollTop:$(".scroll-area").scrollTop() + top}, 200, 'easeInQuad',
			function(){
				sel_date = sel_date.add(sgn, 'M');
				//カレンダーの再作成
				modify_cal(sgn);
				//表示位置の設定
				reposition();
				touch_pos = {};
			}
		);
	} else {
		//元の場所に戻す(ぼよよーん)
		$(".scroll-area").animate({scrollTop:touch_pos.scrollPos}, 300, 'easeOutBack');
		touch_pos = {};
	}
}
```

ソースの解説

- `touchstart`イベントで現在のdivのスクロール位置とタッチ位置を保存しておく。
- `touchmove`イベントで初期位置からのタッチ位置の差でスクロール位置を変更させる。現在のタッチ位置を保存。
- `touchend`イベントで最終タッチの位置がスクロール枠の1/3の幅を超えてスクロールした場合、<br>次の月の１日の位置までアニメーションでスクロールさせ、<br>アニメーション終了時に次の次の月カレンダー部分を追加＋使用しない前々月のカレンダーを削除。<br>1/3を超えていないなら、元の位置にアニメーションでぼよよーんとスクロールさせる。

<span class="label label-danger">必読</span>　実はここは罠がいっぱいで色々ハマった部分です。
まず、**スマホ向けフリック操作にjQueryイベントは使わないほうがいいです。**わりとクリティカルな不具合が潜んでいます。

おそらく、Androidに対応していない、gestureイベント(ピンチイン、ピンチアウト等)に対応させるためか、タッチ時のタッチ位置の数を２つ以上教えてくれません。
なので、`if (e.originalEvent.touches.length > 1)`などとしても指が２個以上触れているかどうかの判別ができません。

これのなにがマズいかと言うと、一部分の枠だけタッチで永久スクロール可能なオブジェクトを配置したとします。
ユーザーが、そのオブジェクトではない部分でピンチインして画面を拡大し、その後そのオブジェクトの場所に画面をスクロールさせてオブジェクトが画面いっぱいになる状態にします。

すると、jQueryのイベントでは２点以上の操作の判定ができないため、ピンチアウトができず、再度画面を縮小させることができない状態になります。
しかも現時点でのAndroid版Chromeではその状態でリロードしても、位置や拡大状態がリセットされず、二度と縮小やスクロールが不可能な状態に陥りました。

次に、**Disable 'pull to refresh' for Android Chrome**の部分。

Android Cromeブラウザでは~~嫌らしいことに~~下方向にフリックすると、ページ更新の丸いゲージが上からピョコッと出てきます。
これを回避するためには、対象部分のタグに`overflow-y: hidden;`を指定して、`touchmove`イベント中に`preventDefault`を使用しないとダメなようです。

#スクロール後のカレンダーの補完処理

```javascript:部分コード
function modify_cal(flg){
	//タイトルを変更
	$(".cal_title").text(sel_date.format('yyyy MMM'));

	//逆方向に2ヶ月離れた月は削除対象
	var delete_date = sel_date.add(-1 * flg * 3, 'M').format('yyyy-MM-');
	var delete_date2 = sel_date.add(-1 * flg * 4, 'M').format('yyyy-MM-');

	//削除対象月の日付入りのLineを削除
	var lines = document.getElementsByClassName('scroll-area')[0].childNodes;
	for(var i=lines.length-1; i>=0; i--){
		var line = lines[i];
		if((line.firstChild.attributes['date'].value.indexOf(delete_date)>=0 || line.firstChild.attributes['date'].value.indexOf(delete_date2)>=0)
			&& 
			(line.lastChild.attributes['date'].value.indexOf(delete_date)>=0 || line.lastChild.attributes['date'].value.indexOf(delete_date2)>=0))
		{
			line.parentNode.removeChild(line);
		}
	}
	
	//正方向に新しい月を追加
	var append_elm = makeCal(sel_date.add(flg * 2, 'M'), flg);
	var frame = $(".scroll-area");
	if(flg>0){
		$(append_elm[0].childNodes).appendTo(frame);
	} else {
		$(append_elm[0].childNodes).prependTo(frame);
	}
	
	//文字色の付与
	$("[date^='"+sel_date.add(-1 * flg, 'M').format('yyyy-MM-')+"']").css({color: 'gray'});
	$("[date^='"+sel_date.format('yyyy-MM-')+"']").css({color: '#fff'});
}
```

スクロールアニメーションのイベントの終了時、使用しない無駄なカレンダーの除去と、次の次の月を新しく追加し、現在の月の文字色をハイライトしています。

なぜ、前後１ヶ月ではなく、前後２ヶ月のカレンダーを追加しているかと言うと、フリック操作する際、１ヶ月分だけだと、フリック距離が長い場合、次の月の端までいくと、カクッとスクロールが止まり、スムーズなフリック感が損なわれてしまい、若干のストレスとなるからです。

次に、削除対象月の削除部分のソースをjQueryではなくバニラなコードで書いているかというと、最初は前後１ヶ月で実装していたのを、前後２ヶ月に変更したため、PCではスムーズなのにAndroid端末ではスクロール後の後処理が目に見えて重く感じられ、連続操作時、カクカクな感じが否めなかったからです。
（それでも若干のカクツキは感じられるかもしれません）

あと、ここはスクロール処理との兼ね合いもあるので、バックグラウンドでこっそり削除したりできないシビアな部分なので高速で削除追加できないと不味そうです。

#最後に
簡単に作れるだろうと始めたものの、いろいろ思いもつかない不具合に悩まされて丸一日かかってしまいましたが、スマホ用のサイト作成の勉強にはちょうどよかったと思います。

本当は、スクロール中に文字色をじわじわと次の月へ変更したりしてみたかったのですが、私のAndroid端末の場合、レンダリングに使用するGPUが非力な為か、フェードイン処理やスクロールアニメーションのDPSが低下したり、画面がブラックアウトするなどの症状が出てうまく行きませんでした。

最近のスマホはPC並に性能があがってるはず！とか思っていましたが、Webに関してはまだPCほどの性能は期待しないほうがいいのかも、と感じました。＞＜