一部コンテンツを固定状態で、ピンチインして別コンテンツ（画像）だけ拡大・縮小する

***
以下の手順でもそれっぽく実現できますが、いまいちスムーズに動きません。
現在調整中のためもうしばらくお待ちください。
ちなみに拡大縮小についてはgestureイベントを使用した方がスムーズに動きます。
***

個人的に実現できたときに感動したのでメモ。

当初、以下のページを参考にして「あれ、できんじゃね？」と思ったのですが、
いざ試してみると、記事にある通り動きが「モッサリ」。

[スマホで固定メニューはそのままにコンテンツをピンチインできるようにする（メモ:未完）](http://webutubutu.com/webdesign/3150)
[モバイルブラウザでピンチインしても位置-サイズの変わらない固定要素を作る方法](http://ja.stackoverflow.com/questions/2673/%E3%83%A2%E3%83%90%E3%82%A4%E3%83%AB%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%81%A7%E3%83%94%E3%83%B3%E3%83%81%E3%82%A4%E3%83%B3%E3%81%97%E3%81%A6%E3%82%82%E4%BD%8D%E7%BD%AE-%E3%82%B5%E3%82%A4%E3%82%BA%E3%81%AE%E5%A4%89%E3%82%8F%E3%82%89%E3%81%AA%E3%81%84%E5%9B%BA%E5%AE%9A%E8%A6%81%E7%B4%A0%E3%82%92%E4%BD%9C%E3%82%8B%E6%96%B9%E6%B3%95)

でもプラグインとかで実現できてるのもあるし、やってやれないことはないんじゃないかとカリカリやってみました。
いろいろ調べて無事解決して、ある程度達成感があったので備忘録として残します。
個人的にはある程度満足できる出来にはなったと思います。
たぶんもうちょっとイベント追加するともっと使いやすくなるんだと思います。今回はやったところまで。
今回の拡大・縮小対象は画像です。

デモはこちら
[コンテンツだけピンチインデモ](http://elegant-house-c.sakura.ne.jp/demo/pinch_test/)

# 仕様（実現したいこと）
### 対象デバイス
- iPad Pro
※自分のandroid端末でもできることは確認。

### 画面構成
ヘッダ、左メニュー（今回の場合はサムネイル一覧）
イメージ的にはこんな感じ。

![pintch-in.png](https://qiita-image-store.s3.amazonaws.com/0/95842/a5240490-7927-9bfa-27d9-2ef8101c6eb2.png)


### 機能
- サムネイル一覧をクリック（タップ）したら右側のエリアに画像を表示
- 画像はピンチイン・アウトで拡大・縮小が可能
- 拡大後にスワイプで画像を移動可能
- （拡大してない状態のみ）フリックでサムネイル切替可能

### 使用言語
- HTML, CSS
- jQuery

その他、レイアウトにbootstrapを使用しています。


# サンプルコードを試す

まず参考にさせていただいた下記サイトを実際に試してみました。

[スマホで固定メニューはそのままにコンテンツをピンチインできるようにする（メモ:未完）](http://webutubutu.com/webdesign/3150)

一部修正が必要ですが、確かに拡大・縮小は可能。
ただ、拡大・縮小がギュン！ギュン！とイマイチなめらかではない。。。個人的に使うならまぁ別にいいか、ってなるのですが客先で使用してもらうのでこれは改善の余地があるなぁ～と思いGoogle先生に尋ねました。

基本的に似たようなキーワードでは検索しつくした感はあったので、矛先を変えて調べてみたところ以下のページがヒット。

[Web制作にGPU処理を取り入れる](https://w3g.jp/blog/web_gpu_adopt)

なんでもCSSの`transform: translate3d(0,0,0)`を使うとGPUが使用されるからなめらかな動きが実現できる、と。
で、結構調べてみるとそれ関連のページはたくさんあってどうやら本当っぽいので、サンプルコードを`translate3d`を使って修正してみることにしました。


# 実装

## 構成
ページの構成は以下。

index.html
images/画像たち
css/
┣ bootstrap.min.css
┣ common.css
┗ style.css
js/
┣ bootstrap.min.js
┣ jquery-2.1.1.min.js
┗ index.css


以下のソースは基本的にデモのコピペです。
コメントは多少増やしています。

## ヘッダ

### meta定義とか

``` index.html
<!DOCTYPE html>
<html>
<head>
<title>PINCH TEST</title>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>

<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-touch-fullscreen" content="yes">
```

viewportは`user-scalable=no`で拡大不可にしておきます。
その他は環境に合わせて。

### CSS、　script
``` index.html
<link rel="stylesheet" href="./css/bootstrap.min.css" />
<link rel="stylesheet" href="./css/common.css?date=20160402" />
<link rel="stylesheet" href="./css/style.css?date=20160402" />
<script type="text/javascript" src="./js/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="./js/bootstrap.min.js"></script>
<script type="text/javascript" src="./js/index.js?date=201600402"></script>

</head>
<script>
var scrollHeight;
$(function() {
    $(window).bind('resize', function () {
        scrollHeight = $(window).height() - $('header').height() - $('#img-info').height() - 24 - 10 - 20;  // 全体 - ヘッダ - margin - 固定バー
        $('#detail-left').css('height', scrollHeight + 'px');
        $('#detail-right').css('height', scrollHeight + 'px');

        var rightWidth = $(window).width() - $('#detail-left').width() - 20;
        $('#detail-right').css('width', rightWidth + 'px');
    }).trigger('resize');
});
</script>
```
bootstrap関連とjqueryは必須です。
`<script></script>`内ではサムネイルエリアの高さを設定しています。
スクロールバーを画面内に収めたかったので。

## コンテンツ

### 画像情報&ページングエリア
``` lang.index.html
<body>
    <header>
        <div class="header">PINCH TEST</div>
    </header>

    <div id="img-info">
        <div class="row">
            <div class="col-md-offset-3 col-md-3">
                <div id="disp-name" class="f15em fwBold col-black text-center">TITLE</div>
            </div>
            <div class="col-md-6 text-right" id="img-buttons">
                <img class="mgr10" src="./images/page_first_black.png" alt="" width="30px" />
                <img class="mgr10" src="./images/page_prev_black.png" alt="" width="30px" />
                <span class="f15em fwBold col-white" style="vertical-align: bottom;"></span>
                <img class="mgr10 mgl10" src="./images/page_next_black.png" alt="" width="30px" />
                <img class="mgr10" src="./images/page_last_black.png" alt="" width="30px" />
                <img id="to-base-size" class="mgr10" src="./images/toBaseSize_black.png" alt="" width="30px" />
                <img src="./images/toFullscreen_black.png" alt="" width="30px" />
            </div>
        </div>
    </div>
```
`<div id="img-info"></div>`はページングや画像情報を表示するエリアになります。
デモ環境では「元に戻る」ボタンのみ有効です。

### サムネイル一覧（左側）
``` lang.index.html
    <article class="clearfix">
        <div class="fLeft" id="detail-left">
            <ol>
                <li>
                    <figure>
                        <img onclick="changeImg(this);" class="img-thumbnail" src="./images/img01_thumb.jpg" alt="" />
                        <figcaption style="display: none;">画像1</figcaption>
                    </figure>
                </li>
                <li>
                    <figure>
                        <img onclick="changeImg(this);" class="img-thumbnail" src="./images/img02_thumb.jpg" alt="" />
                        <figcaption style="display: none;">画像2</figcaption>
                    </figure>
                </li>
                <li>
                    <figure>
                        <img onclick="changeImg(this);" class="img-thumbnail" src="./images/img03_thumb.jpg" alt="" />
                        <figcaption style="display: none;">画像3</figcaption>
                    </figure>
                </li>
            </ol>
        </div>
```
`<ol><li><figure><img /></figure></li></ol>`で画像を囲んでいますが、実際の観光ではphotoswipeプラグインを使用しているのでその影響です。
`<img>`にイベント`onclick="changeImg(this);"`をつけて、サムネイルクリックで画像が切り替わるようにしています。`img-thumbnail`をクラスとしてつけてるのは、jqueryで楽したいからです。
`<figcaption>`は非表示にしていますが、これは情報として保持しているだけのためです。画像が選択された時に画像名を`#disp-name`に表示します。

### 画像表示エリア（右側）
``` lang.index.html
        <div class="fLeft" id="detail-right" style="padding: 10px; overflow: hidden;">
            <div id="img-main" style="width: 100%; height: 100%;">
            </div>
            <input type="hidden" name="org-width" id="org-width" value="" />
            <input type="hidden" name="org-height" id="org-height" value="" />
        </div>
    </article>
```
`#img-main`に画像を表示します。
`#org-width`と`#org-height`は選択された画像のオリジナルサイズになります。
計算の時に使用します。


## サムネイルが選択されたら右側に画像を表示する
これは難しくないですね。

``` index.js
function changeImg(selector) {
    // 選択された画像のパスを取得
    var imgPath = $(selector).attr('src');

    // 画像名をタイトルに設定
    var dispName = $(selector).parent().children('figcaption').text();
    $('#disp-name').text(dispName);

    // 現在の画像を削除
    $('#img-main').empty();

    // 画像パスを作成
    imgPath = imgPath.replace(/_thumb/g, "");
    $('#img-main').append('<img id="img-main-image" src="' + imgPath + '" alt ="" width="98%" />');

    // 画像がロードされてからサイズを設定する
    $('#img-main-image').bind("load", function () {
        $('#org-width').val($(this).width());
        $('#org-height').val($(this).height());
//        console.log($('#img-main-image').height());
    });
}
```
コメントのとおりです。
ちなみに最後の処理`$('#img-main-image').bind("load", function () {});`を使わないと、かなりの確率で画像の高さが取れない現象が発生しますので、こうしておいたほうが良いと思います。

はい、ここまでで画像が表示できました。
次が問題の処理です。

## ピンチイン・アウト

やってることは、

- touchイベントで前回のタッチ位置からの距離の差分をとり、そこから倍率を計算
- その倍率で画像を拡大・縮小し、かつ`translate3d`で位置を移動する

という感じ。
ポイントは拡大・縮小＋移動でしょうか。元の画像と拡大後の画像の差分を取って位置を移動します。
そんな難しいことはやってないです。ソース内にコメントします。

``` index.js
var bZoom = false;    // ズーム中かどうかフラグ

$(function () {
    var nowWidth; // 現在の画像の幅
    var nowHeight; // 現在の画像の高さ
    var pinching = false; // ピンチかどうかフラグ
    var d0 = 1; // 前回の指の位置
    var d1 = 1; // 今回の指の位置

    var startPosX = 0; // タッチが始まったX座標
    var startPosY = 0; // タッチが始まったY座標

    // タッチ開始
    $('#img-main').on('touchstart', function (e) {
        if (bZoom) {
            // 拡大中の場合のみ
            startPosX = e.originalEvent.touches[0].screenX;
            startPosY = e.originalEvent.touches[0].screenY;
        }
    });

    // タッチ移動
    $("#img-main").on("touchmove", function (e) {
        if (e.originalEvent.touches.length == 1) {
            // 指1本。スクロール
            if (bZoom) {
                // 拡大中の場合のみ
                // タッチの座標取得
                var dx = e.originalEvent.touches[0].screenX - startPosX;
                var dy = e.originalEvent.touches[0].screenY - startPosY;

                // 現在のtransform値を取得
                var matrix = $('#img-main-image').css('transform');

                // 値はmatrix(0,0,0,0,0,0)のフォーマットでかえってくる。
                // 未設定の場合はnone
                if (matrix != 'none') {
                    // 値の解析
                    matrix = matrix.substring(7, matrix.length - 1);
                    var matList = matrix.split(',');
                    var nowTransX = matList[4].trim(); // 縦位置
                    var nowTransY = matList[5].trim(); // 横位置

                    // 差分を加える
                    dx = parseInt(nowTransX) + parseInt(dx);
                    dy = parseInt(nowTransY) + parseInt(dy);

                    // 移動の位置制御
                    // 何もしないと画面をはみ出してしまうので
                    var minWidth = $('#org-width').val();
                    var minHeight = $('#org-height').val();
                    var maxDx = nowWidth - minWidth;
                    var maxDy = nowHeight - minHeight;

                    // 左端まで
                    if (dx > 0) {
                        dx = 0;
                    }
                    // 右端まで
                    if (Math.abs(dx) > Math.abs(maxDx)) {
                        dx = -(maxDx);
                    }
                    // 下端まで
                    if (Math.abs(dy) > Math.abs(maxDy)) {
                        dy = -(maxDy);
                    }
                    // 上端まで
                    if (dy > 0) {
                        dy = 0;
                    }

                    // これ大事
                    $('#img-main-image').css({
                        'transform': 'translate3d(' + dx + 'px, ' + dy + 'px, 0px)',
                        '-webkit-transform': 'translate3d(' + dx + 'px, ' + dy + 'px, 0px)',
                        '-moz-transform': 'translate3d(' + dx + 'px, ' + dy + 'px, 0px)',
                        '-ms-transform': 'translate3d(' + dx + 'px, ' + dy + 'px, 0px)'
                    });

                    // タッチ位置を設定
                    // touchmoveの場合、タッチが離れないのでtouchstartが実行されないのでこちらで更新する必要あり
                    startPosX = e.originalEvent.touches[0].screenX;
                    startPosY = e.originalEvent.touches[0].screenY;
                }
            }
        }
        else if (e.originalEvent.touches.length == 2) {
            // 指2本。ピンチイン/アウト
            if (!pinching) {
                // 初回のタッチ
                // ピンチインをtrueにして、ピンチ状態にする
                pinching = true;

                // Xの2乗 + Yの2乗のルート
                // Math.powは階乗、Math.sqrtはルートをとってるだけ
                // ここは参考サイトのものをそのまま利用させてもらいました
                d0 = Math.sqrt(
                  Math.pow(e.originalEvent.touches[1].screenX - e.originalEvent.touches[0].screenX, 2) +
                  Math.pow(e.originalEvent.touches[1].screenY - e.originalEvent.touches[0].screenY, 2)
                );
            }
            else {
                d1 = Math.sqrt(
                  Math.pow(e.originalEvent.touches[1].screenX - e.originalEvent.touches[0].screenX, 2) +
                  Math.pow(e.originalEvent.touches[1].screenY - e.originalEvent.touches[0].screenY, 2)
                );

                // 前回から何倍の距離を移動したかを計算
                var zoom = d1 / d0;

                // ピンチインかアウトか判定するため差分を取る
                var diff = d1 - d0;

                // 最小・最大拡大率の設定
                // 今回は最大3倍までの拡大率
                var maxWidth = $('#org-width').val() * 3;
                var maxHeight = $('#org-height').val() * 3;
                var minWidth = $('#org-width').val();
                var minHeight = $('#org-height').val();

                // 前回の距離との倍率を取得
                var newWidth = $('#img-main-image').width() * zoom;
                var newHeight = $('#img-main-image').height() * zoom;

                // 幅
                if (newWidth > maxWidth) {
                    // 大きすぎるから最大倍率に
                    newWidth = maxWidth;
                }
                else if (newWidth < minWidth) {
                    // 小さすぎるから最小倍率に
                    newWidth = minWidth;
                }

                // 高さ
                if (newHeight > maxHeight) {
                    // 大きすぎるから最大倍率に
                    newHeight = maxHeight;
                }
                else if (newHeight < minHeight) {
                    // 小さすぎるから最小倍率に
                    newHeight = minHeight;
                }

                // ピンチの中心座標を取得
                var baseDx = (parseInt(e.originalEvent.touches[1].screenX) + parseInt(e.originalEvent.touches[0].screenX)) / 2;
                var baseDy = (parseInt(e.originalEvent.touches[1].screenY) + parseInt(e.originalEvent.touches[0].screenY)) / 2;

                // 移動
　　　　　　　　　 // 元の画像と拡大後の画像の差分を取って位置を移動します
                // 2で割っているのは、画像は中心から拡大されるので、元画像と比較すると上下左右にはみ出ることになります。
                // 正しい位置に移動するには中心からの距離が必要になるので、画像のサイズの差分を半分にした値になります（詳細は下部の画像を参照）
                var dx = (newWidth - $('#img-main-image').width()) / 2;
                var dy = (newHeight - $('#img-main-image').height()) / 2;

                // ピンチインとアウトで距離のプラスマイナスを変えています
                // ピンチインだとマイナス、ピンチアウトだとプラス
                if (diff >= 0) {
                    // ピンチアウト
                    dx = parseInt(dx) + baseDx;
                    dy = parseInt(dy) + baseDy;
                }
                else {
                    // ピンチイン
                    dx = dx - baseDx;
                    dy = dy - baseDy;
                    
                    // 位置がマイナスにならないようにする
                    // これがないと悲しいことになる　
                    if (dx < 0) {
                        dx = 0;
                    }
                    if (dy < 0) {
                        dy = 0;
                    }
                }

                $('#img-main-image').css({
                    'transform': 'translate3d(-' + dx + 'px, -' + dy + 'px, 0px)',
                    '-webkit-transform': 'translate3d(-' + dx + 'px, -' + dy + 'px, 0px)',
                    '-moz-transform': 'translate3d(-' + dx + 'px, -' + dy + 'px, 0px)',
                    '-ms-transform': 'translate3d(-' + dx + 'px, -' + dy + 'px, 0px)'
                });

                // 画像の拡大/縮小
                $("#img-main-image").css({
                    'width': newWidth + 'px',
                    'height': newHeight + 'px'
                });

                nowWidth = newWidth;
                nowHeight = newHeight;

                if (newWidth > minWidth) {
                    // 拡大中
                    bZoom = true;
                }
                else {
                    // 拡大していない
                    bZoom = false;
                }
            }
        }

        // ブラウザのイージング抑止
        // iPadで見たときにスクロール終わりにぐにょーんってなるのが変だったので
        e.preventDefault();
    });

    // タッチ終了
    $("#img-main").on("touchend", function (e) {
        // ピンチ終わり
        pinching = false;
    });
});
```

当初はどこでピンチアウトしても左上は固定で拡大していたのですが、ピンチアウトした位置から拡大する処理を追加しています。
が、いまいち微妙な感じがするのでその辺使う使わないはお任せします。

計算の手順としては、

1. 指2と指1の座標から距離を計算し、倍率を計算（変数：zoom）
2. 1で計算した距離の差分を取る（変数：diff）
3. 拡大後の画像サイズの決定（大きすぎる、小さすぎる場合は是正）（変数：newWidth、newHeight）
4. ピンチの中心座標をX、Yそれぞれ取得（変数：baseDx、baseDy）
5. 移動距離の計算1（拡大後の画像との差分）
6. ピンチインアウトの判定

↓↓↓ ピンチアウトした座標で拡大処理 ↓↓↓

(7). （ピンチアウトの場合）4の座標を加算
(8). （ピンチインの場合）4の座標を減算、かつマイナスの場合は0に設定

補足として画像作ったんですが、いまいち伝わりにくい気がする。。。

![pinch1.png](https://qiita-image-store.s3.amazonaws.com/0/95842/f98f208c-8d74-ac43-8424-f88c48b80a3d.png)

![pinch2.png](https://qiita-image-store.s3.amazonaws.com/0/95842/c708685b-51a1-63a5-1594-e9777d389088.png)

ニュアンスは伝わるかと思うので、参考程度にお願いします。


## 標準サイズに戻す
右上のボタンの右から２番目のボタンを押すと標準サイズに戻ります。
本当はダブルタップで戻したかったのですが、他の処理とかぶってやや面倒だったので手を抜きました。。。

```index.js
$('#to-base-size').on('click', function () {
    // 拡大/縮小
    $("#img-main-image").css({
        'width': '',
        'height': '',
        'transform': '',
        '-webkit-transform': '',
        '-moz-transform': '',
        '-ms-transform': '',
    });

    // 拡大中じゃなくする
    bZoom = false;
});
```

各属性を初期値に戻すだけです。（空文字を設定すると初期値に戻ります）
最後にbZoomをfalseにすることで拡大中じゃなくしています。


## その他実装したこと
- フリックによるページング（landscapeFlickSensorプラグイン使用）
- 右上のボタンクリックで全画面表示（photoswipeプラグイン使用）

# がんばればもうちょっとできると思うこと

- マウスホイールによる拡大・縮小
- マウスドラッグによる移動
- ピンチインアウトの位置を判定して、その位置を中心に拡大する。<--- それっぽくはできてるけど、完全ではない気がする

# 参考
[landscapeFlickSensor](http://d.hatena.ne.jp/tatesuke/20111127)
[photoswipe](https://github.com/dimsemenov/photoswipe)

便利なプラグインありがとうございます。