[Ionic]IonicでカレンダーUI（スワイプ型＆スクロール型）を実装してみた

この記事は[Ionic Advent Calendar 2017](https://qiita.com/advent-calendar/2017/ionic)の２日目です。

---


やあ （´・ω・｀)
ようこそ、本記事へ。
まずはこのリンクのデモを見て落ち着いて欲しい。(スマホ推奨)
[https://ionic-calendar-demo.firebaseapp.com/](https://ionic-calendar-demo.firebaseapp.com/)

うん、「未完成」なんだ。済まない。
仏の顔もって言うしね、謝って許してもらおうとも思っていない。

でも、このデモを見たとき、君は、きっと言葉では言い表せない
「違和感」みたいなものを感じてくれたと思う。
殺伐とした世の中で、そういう気持ちを忘れないで欲しい
そう思って、この記事を書いたんだ。

じゃあ、注文を聞こうか。

---

# この記事について

アプリ作成時にわりとカレンダーUIを求められることが多いです。たしかによく見るけど、作り方がわからなかったためこの機会に実装してみようと思いやってみました。
本記事では、ざっくり実装内容を振り返ります。冒頭でも述べたとおり未完成（スクロールUIなど）。というかハマってしまって未完成ですがよろしくお願いします。

ソースコードはこちら
[https://github.com/scrpgil/ionic-calendar-demo](https://github.com/scrpgil/ionic-calendar-demo)

# 目次
１．仕様について
２．カレンダー情報の作成について
４．カレンダーUIの実装（スワイプ版）
５．カレンダーUIの実装（スクロール版）
６．改めてカレンダーの仕様を考える
７．まとめ


# カレンダーの仕様について

実装するに当たって有名所のカレンダーの仕様を調べました。
大別するとスワイプ型とスクロール型があるようです。この両方を実装しています。


## スワイプ型

スワイプで切り替えていくスタイル。Googleカレンダーなどはこっち。

・カレンダーの切り替え方法
前の月：左へスワイプ
次の月：右へスワイプ

![g.gif](https://qiita-image-store.s3.amazonaws.com/0/165335/2bd49fff-b3e7-9019-9c68-06d609de0a49.gif)



## スクロール型

スクロールでカレンダーを切り替えていくスタイル。iOS標準アプリはこっちの仕様です。

・カレンダーの切り替え方法
前の月：上へスクロール
次の月：下へスクロール

![a.gif](https://qiita-image-store.s3.amazonaws.com/0/165335/28e0daad-3b48-769f-c805-e9f2247e4410.gif)


# カレンダー情報の作成について

本実装では、カレンダーの情報を以下のオブジェクトで表しています。

````

{
	"year":number,
	"month":number,
	"week":
		[
			{
				"day":number,
				"other":boolean
			}
		]
	]
}

year :年 （例）2017
month:月 （例）12
week :6(週)x7(日)の2次元配列。
day  :日付け（例）2
other:year、month内の日付けかどうか？色を変えるのに使用。
````

HTMLにてカレンダーを描画する際は、weekの２次元配列をループで回して描画する作戦です。

## Calendarプロパイダーの実装

上記のカレンダー情報を作成するために、CalendarProviderを作成しています。
これは、スワイプ型、スクロール型の両ページから共通の処理でカレンダーを情報を作成できるようにするためです.

Calendarプロパイダーは、以下の関数をもっています・
・getToday()：今日の年月日を取得
・nextMonth(year, month)：次の年月を取得
・lastMonth(year, month)：前の年月を取得
・getCalendarYM(year, month)：年月からカレンダー情報を取得



## getToday()：今日の年月日を取得

今日の日付けを取得して配列[year,month,day]で返却しています。


````Typescript
    getToday(){
        let today = new Date();
        let year = today.getFullYear();
        let month = today.getMonth()+1;
        let date = today.getDate();
        return [year, month, date];
    }
````

## nextMonth(year, month)：次の月を取得

````Typescript
    nextMonth(year, month){
        let next_year = year;
        let next_month = month + 1;
        if(13 <= next_month){
            next_month = 1;
            next_year = next_year + 1;
        }
        return [next_year, next_month];
    }
````

## lastMonth(year, month)：前の月を取得

````Typescript
    lastMonth(year, month){
        let last_year = year;
        let last_month = month - 1;
        if(last_month <= 0){
            last_month = 12;
            last_year = last_year - 1;
        }
        return [last_year, last_month];
    }

````

## getCalendarYM(year, month)：カレンダー情報の作成

本プロパイダーのメイン処理になります。年月の情報から日付け情報を作成します。
本処理はこちらの実装を参考にしています。
[カレンダー出力の練習](https://qiita.com/Ponyo1127/items/d7062ad3422622ad5878)

コードはちょっと長いので以下にGithubのリンクを貼ります。
[ソースコード](https://github.com/scrpgil/ionic-calendar-demo/blob/08725b484a2d409b96483a33421a1c83628b4bd4/src/providers/calendar/calendar.ts#L7)

ざっくりとした処理の流れは以下になります。

月の初めの曜日を取得
　↓
うるう年の判定
　↓
カレンダー情報の作成★

もしも、この実装を流用して何か特別な情報を付与するなら★の処理にて年月日に紐づく情報を追加すればよいと思います。以下は、カレンダー情報を作成しているコード。

````Typescript
    generateDate(year, year_date, month, first_day){
        let cur_day = 1;
        let other_day = 1;
        let first_day_chk = false;
        let result_month = [];
        for(var w = 0; w< 6; w++){
            var week = [];
            for(var i = 1; i <= 7; i++){
                var day = {};
                day["day"]="";
                if(i <= first_day && !first_day_chk){
                    let last_m = this.lastMonth(year, month);
                    day["day"] = year_date[last_m[1]] - first_day + i;
                    day["other"] = true;
                }else{
                    if(!first_day_chk){
                        first_day_chk = true;
                    }
                    if(cur_day <= year_date[month]){
                        day["day"] = cur_day;
                    }else{
                        day["day"] = other_day;
                        day["other"] = true;
                        other_day += 1;
                    }
                    cur_day += 1;
                }
                week.push(day);
            }
            result_month.push(week);
        }
        
        /*
         * このあたりでもう一回weekの二次元配列をループさせて日毎の構造体にメンバを追加するとか？
         */
        return result_month;
    }

````


# カレンダーUIの実装（スワイプ版）

スワイプの実装ですが、ion-slidesをしよ湯して実装しました。
このコンポーネントを使用することで比較的ラクにスワイプするカレンダーを実装できました。

## ion-slidesについて

ion-slidesはIonicが提供するコンポーネントです。主にカルーセルを作成することができます。
[https://ionicframework.com/docs/api/components/slides/Slides/](https://ionicframework.com/docs/api/components/slides/Slides/)

たまに癖のある動作をするコンポーネントですが、機能が豊富に用意されているため、使いこなすと相当いろんなUIを実現できると思います（カバーフローの表示とか）

## ざっくりとした処理

スワイプページを開いた時、以下の処理が実行されます。

１．今日の日付けを取得
２．前月、来月の年月を取得
３．前月、今月、来月のカレンダー情報を作成し配列calに格納


配列calは[前月、今月、来月]のカレンダー情報をもつ配列です。この配列をngForで回してion-slideを作成しています。


````HTML
<ion-content no-bounce>
  <ion-slides #Slides (ionSlidePrevEnd)="beforeSlide()" (ionSlideNextEnd)="nextSlide()" initialSlide="1">
    <ion-slide *ngFor="let c of cal; let idx = index;">
      <table class="date-table">
        <tbody class="date-body">
          <tr class="weekday">
            <td>日</td>
            <td>月</td>
            <td>火</td>
            <td>水</td>
            <td>木</td>
            <td>金</td>
            <td>土</td>
          </tr>
          <tr *ngFor="let week of c.week; let w = index;">
            <td *ngFor="let day of week; let d = index;">
              <div class="day">
                <span [ngClass]="{'other':day.other}">{{day.day}}</span>
              </div>
            </td>
          </tr>
        </tbody>
      </table>
    </ion-slide>
  </ion-slides>
</ion-content>
````


### カレンダー切り替え処理について

ion-slidesがもつアウトプットイベントを利用して、スライドの切り替えを検知しています。
・ionSlideNextEnd：次のスライドへ移動
・ionSlidePrevEnd：前のスライドへ移動

````Typescript
    nextSlide(){
        console.log("next");
        if(this.slides._slides.length != 3 || this.wait == false){
            this.wait = true;
            return;
        }
        let c = this.cal[2];
        this.current_calendar = [c.year, c.month];
        let ym = this.calendar.nextMonth(c.year, c.month);

        let n = this.calendar.getCalendarYM(ym[0], ym[1]);
        this.cal.push(n); //配列の最後に来月の情報を追加
        this.cal.shift(); //配列の先頭を削除
        this.slides.slideTo(1, 0, false); //2番目のスライドに移動
    }
````

注目してほしいのは下3行の処理です。
cal配列へ来月の情報をプッシュした後すぐ、先頭を削除し、現在のslideを1に設定しています.
なぜ、現在のSlideを1に設定しているかというと、上述の処理をすると現在表示中のスライドがずれるからです。
以下、処理の流れです。

````
1. 現在11月のカレンダーを表示中。ここから右にスワイプ　　
[10月][11月★][12月]

2. 10月の情報を削除し、末尾に来月の情報を追加します。
　　ここで、本来12月を表示したいのですが、右にスワイプしているので1月が表示されてしまいます。
[11月][12月][1月★]
   
3. 12月のカレンダーを表示するため、slideTo()関数で2番目のスライドに移動します。
[11月][12月★][1月]
````

別に削除しなくても良いのでは？という意見もあるかもしれませんが、削除しないとDOMが増えすぎてスワイプ処理が遅くなるので、今回は常に3つだけになるようにしています。


### 怪しい挙動

nextSlide()関数のはじめに謎のif文があると思いますが、これはなぜか起動時にionSlideNextEndイベントが発火してしまうためです。

![Screen Shot 2017-12-01 at 15.45.43.png](https://qiita-image-store.s3.amazonaws.com/0/165335/b49da9dd-4432-ea19-67d5-cd615fdf7aa4.png "Screen Shot 2017-12-01 at 15.45.43.png")

原因はよくわからないです。私の実装が悪いかもしれませんし、Ionicのバグかもしれません。原因わかったら追記します。

## UIについて

カレンダーのUIはGoogleカレンダーに似せて作ってあります。Ionicはそのままでもフラットデザイン＆マテリアルデザインっぽいデザインができあがるので、CSSを少し記述するだけで対応は済みました。UIの変更は本当にIonicはやりやすいですね。

# カレンダーUIの実装（スクロール版）

スワイプと違いスクロールのUIは相当苦労しました。
今回、スクロールの実装にVirtualScrollを使用しましたが、一部おかしな挙動が残っています。

## VirtualScrollについて

Ionicが提供しているリストコンポーネントの一種？です。試してないですが、スクロールするカレンダーのような無限に続くリストをそのままngForで作るとおそらくDOMが増えすぎてレンダリングができなくなるんじゃないかと思います。

VirtualScrollは表示・非表示を上手いこと処理してくれるのでリストが増えすぎても動作が重くなることがないようになっています。

ただ、VirtualScrollを利用するためには各アイテムの高さが固定でないといけません。今回のカレンダーであれば、各月ごとの高さは一様一定にできるのでVirtualScrollを利用しました。

### 厳密には・・・

厳密にはカレンダーは1日がどこから始まるかによって、行数が変化します。例えば、2017年12月は6行ですが、2017年10月は5行になります。iOS標準アプリはこの変化に対応していますが、本記事の実装は対応していません。そのため、11月と12月の間に少し隙間がありますが、許容できる範囲だと思います。

## ざっくりとした処理

スクロールページに遷移した際、以下の処理が行われます。

１．今日の日付けを取得
２．来月の年月を取得
３．来月から12ヶ月前までのカレンダー情報を作成し配列に格納

例えば、11月30日にスクロールページを開いた時は、カレンダー情報を保持する配列は以下のようになっています。
配列cal[1月,2月,3月,4月,5月,6月,7月,8月,9月,10月,11月,12月]

こちらの配列calをVirtualScrollを使用してカレンダーを描画しています。ソースは以下。

````HTML
<ion-content>
  <ion-infinite-scroll position="top" (ionInfinite)="doInfinite($event)" threshold="1%">
    <ion-infinite-scroll-content></ion-infinite-scroll-content>
  </ion-infinite-scroll>
  <ion-list [virtualScroll]="cal" [headerFn]="checkInitialChange" approxItemHeight="160px" approxHeaderHeight="40px" #scroll bufferRatio=20>
    <ion-item-divider *virtualHeader="let header" class="itemHeader">
      <span class="ym">
        {{ header }}
      </span>
    </ion-item-divider>
    <ion-item *virtualItem="let c" no-lines>
      <div id="c{{c.year}}{{c.month}}" class="calItem">
        <table>
          <tbody>
            <tr *ngFor="let week of c.week; let w = index;">
              <td *ngFor="let day of week; let d = index;" [ngClass]="{'td-other':day.other}">
                <div class="day">
                  <span [ngClass]="{'other':day.other}">{{day.day}}</span>
                </div>
                <div class="circle" *ngIf="!day.other">●</div>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </ion-item>
  </ion-list>
  <ion-infinite-scroll (ionInfinite)="doInfiniteBottom($event)">
    <ion-infinite-scroll-content></ion-infinite-scroll-content>
  </ion-infinite-scroll>
</ion-content>

````

また、上下の端に達した時に、新しいカレンダー情報を取得する必要があるため、上下に<ion-infinite-scroll>を記述しています。

## カレンダー情報を追加する処理

カレンダー情報の追加はリストが両端に達した時に行います。両端に達したかどうかの判定はion-infinite-scrollを使用すると比較的に楽に行なえます。。

### 下まで来た時の処理

リストが下の端まで達した時は、配列の最後に格納されている年月から次の12ヶ月分のカレンダー情報を取得しています。スワイプ版との違いは、配列をどんどん追加しても配列内が全てレンダリングされるわけでないので、スクロール処理が遅くなることはありません。

````
    doInfiniteBottom(infiniteScroll){
        let c = this.cal[this.cal.length - 1];
        let year = c.year;
        let month = c.month;
        if(this.loading == false){
            this.loading = true;
            for(var i=0; i<12;i++){
                let ym = this.calendar.nextMonth(year, month);
                year = ym[0];
                month = ym[1];
                let nc = this.calendar.getCalendarYM(year, month);
                this.cal.push(nc);
            }
            this.loading = false;
            infiniteScroll.complete();
        }
    }

````

### 上まで来た時の処理

上まで達したときは配列の先頭のカレンダー情報より前の月の6ヶ月分のカレンダー情報を取得して先頭に追加します。
さらにここが最も頭を悩ませたところなんですが、配列の先頭にカレンダー情報を追加すると、表示中のカレンダーの位置が盛大にずれます。本実装では、なんとかその症状に対処するために、配列に要素を追加した後、content.scrollTo()メソッドを呼んで、表示位置の調整を行っています。
が、しかし、その処理も全然制御がうまくいっておらず、盛大に位置がずれます（´・ω・｀)。
色々試してみましたが、私には解決策がわかりませんでした。


````
    doInfinite(infiniteScroll){
        let c = this.cal[0];
        let year = c.year;
        let month = c.month;
        if(this.loading == false){
            this.loading = true;
            for(var i=0; i<6;i++){
                let ym = this.calendar.lastMonth(year, month);
                year = ym[0];
                month = ym[1];
                let bc = this.calendar.getCalendarYM(year, month);
                this.cal.unshift(bc);
                this.content.scrollTo(0, this.itemHeight * i, 0);
            }
            setTimeout(()=>{
                this.loading = false;
            }, 20);
            infiniteScroll.complete();
        }
    }

````

## ヘッダーについて

スワイプ型カレンダーでは、今、表示しているカレンダーの年月をヘッダーに表示しています。同じようなことをスクロール型のカレンダーで実装しようとしたのですが、現在表示中のカレンダーというものをどのように取得すればよいのかわからず断念しました。
いわゆるアンカーを使えばよいのか？それともscrollTopとリスト全体の長さの計算から導きだせばよいのか？いつかまた機会があったら試してみたいと思います。

## UIについて

カレンダーのUIはiOS標準アプリに似せて作ってあります。こちらも、CSSを少し記述するだけで対応は済みました。
余談ですが、iOS標準のカレンダーアプリは、スクロール時につっかえることが全くなくヌルヌル動きます。一体どのようにしてこれを実現しているのか、正直謎です。Swiftだとできるのかもと思ったけどその割には、iOSのその他のアプリで縦スクロールのカレンダーあまり見ない気もします。

# まとめ

Ionicでカレンダーを実装してみましたが、スクロールの実装は想像してたより苦労しました。
特に、過去に遡るときが鬼門なため、この実装だと実践投入はできなさそうです（´・ω・｀)。
もしも、上下を逆にし、未来を表示する必要がないカレンダーであれば、スクロールでも実装もよさそうです。

ただ、カレンダーUIは度々、案として出てきますが、「未来へ進む必要はあるか？」、「空白の日に意味はあるか？二度と空白が埋まらないなら意味はないのではないか？」等を自問自答して実装するか決めたいと思います。
それでは。