Tech Notes

本当に気持ちいいピンチジェスチャの挙動と実装

諸用により、スマホのようなタッチデバイスの画像表示UIなどで使われるスワイプ/ピンチ操作を自力で実装することがあったためその時に分かったことを書く。

技術的にどうこうというよりは、「既存のソフトウェアに実装されている多くのUIコンポーネントはこのように動作するよう作られているし我々もそれを気持ちいいと感じる」という話にあたる。なので特に実装言語には依存しない話になるはずだ。

この手のUIは元から実装されていることが多いが、何かしらの理由でそのまま使えない場合、あるいはカスタムしたくなった場合は自力で実装する必要が出てくる。 例えばありそうな例としては以下が挙げられる。

  • Googleマップのような無限スクロールの地図アプリの開発
  • 画像ビューアUIの開発
  • Medibang PaintやFigmaのようなイラスト/デザインアプリの開発

こうしたものを開発したいという場合は自分の手で実装する必要が出てくる。調査も実装の調整もそれなりに時間がかかったので、後々役立つよう記事にまとめておく。

一応まあまあ詳細な疑似コードも紹介する。


目次


挙動

2本指によるピンチ操作より前に、1本指によるスクロール(スワイプ)操作から説明した方がよいと思うのでそちらから説明する。

1本指スワイプの挙動

そもそも我々は画面に指をなぞらせて操作している時に何を期待しているかというと、「指でタッチしている部分が保たれること」を期待する。例えばGoogleマップで日本地図を開き、東京のあたりに人差し指を乗せてスワイプすれば、どんなに指をぶんぶん動かしてもその指を離すまでは人差し指が東京と接していることを期待するはずである。

これは現実世界のもののメタファーだからだ。机の上に新聞紙を広げて、ある場所を手で押さえて位置をずらそうとすれば、手で押さえた紙面の位置は保たれるだろう。あるいはホワイトボードに貼りついたマグネットを指で押さえて移動させれば、当然だがマグネットは指についてくるだろう。多くのスワイプUIにおける挙動はこれらの慣れ親しんだ物理挙動を再現したものとして理解するのがよいと思う。

滑り

多くのスワイプUIは指を勢いよく動かして離した場合に、離した後もしばらく滑るように動く挙動が実装されている。これがないと異常に摩擦力が高い、突っ張るような動きに感じられる。

スマホでのスワイプ動作には物理的な抗力というのはほぼないから、そこでいきなりキュッと止まられると感覚と齟齬を来たすのだろう。

スワイプして端にぶつかったときの挙動

Googleマップのように無限スクロールできる特殊な場合はともかく、多くのスクロールUIの場合は端まで行ったらそこでスクロールを止める必要がある。このような状況を「オーバースクロール」と呼ぶらしい。

この際の挙動はいくつかあるが、自分の知る限り大きく分けて 3 つある。分かりやすいように適当な名前を付けた。

1. 単に止まってそれ以上スクロールしなくなる。(止め型)

これが最も原始的だと思う。GoogleフォトやWindows標準の画像ビューアなどもこれなので、基本的にはこれで何の問題もない。

少し気を付ける必要があるのは「端まで行ってから戻る方向にスワイプした場合」の挙動で、この場合はタッチ位置が最初にタッチしていた位置とずれる。 これも現実世界のもののメタファーとして考えれば分かりやすい。

カードか何かを、それより一回り大きな箱の中に入れる。カードを指で押さえて動かすとカードは当然指に付いてくるが、箱の端まで行けばそこでぶつかって動かなくなる。さらに箱の壁に押し付ければ今度は指がカードの上で滑り、最初に指で押さえていた位置からはずれることになる。また戻る方向に動かせば普通に動く。タッチデバイスの UI も同じことである。

2. 端まで行っても動かし続けられるが、指を離すとブルンッと戻る。(ばね型)

これもそれなり見る気がしていたのだが、改めて探すと中々見つからない。みんなどこに行った?

このタイプの美点は、1 と違い指で押さえた位置が最後まで変わらないことである。ただ現実世界のもので似た動きをするものがあるかと言われると厳しい。

3. 画面全体が伸びる。(伸び型)

Android 12で導入された新機軸。ゴムのように伸びる。「ストレッチオーバースクロール」と呼ばれているらしい。評判はやや悪いようだが、自分は慣れてしまった。

(その他)何らかのアクションを割り当てる

スワイプ/ピンチの挙動の話からはずれるが、オーバースクロールに何らかのアクションを割り当てるアプリは多い。多くのスマホブラウザは上方向のオーバースクロールをページのリロードに割り当てている。また Discord や Slack など、左右方向のオーバースクロールをなんらかのサイドパネルの引き出しに割り当てているアプリもままある。

2本指ピンチの挙動

さて、本題の2本指ピンチだ。

ピンチ操作をするときはつまり拡大縮小をするときである。2本の指の距離に比例して拡大率が変わるのが基本的な動作だ。 一方で、指を動かせば1本指スワイプの時と同じように位置の変化も起きて欲しいのが人情である。これが少し曲者となる。

2本指ピンチ時も「指でタッチした場所がおおよそ保たれる」というところは変わらない。 ただひとつ問題は、指が回転する場合どこを基準にすればよいかが問題となる。

  • (A)

  • (B)

(A)のように2本の指の角度が変わらなければ良いが、(B)のように角度が変わる場合どこを基準に位置を保てばいいのだろうか?任意の角度での回転を許す場合は単に表示物を回転させれば良いが、多くの画像ビューアUIは回転を許さない。

結論から言うと、2本の指の中点が保たれる。

従って、ピンチ操作時は2本指の距離に比例した拡縮がされつつ、同時にその中点でタッチされたときと同じようなスクロール動作が行われるとたいそうそれっぽくなる。

以上がピンチ操作の基本的な挙動だ。長かった。


実装

以上のような挙動を実際に実現するようなコードの実装について、以下疑似コードなどで説明する。

移動と拡縮の表現方法

移動と拡縮を行うには、当然ながら移動の位置と拡大率を変数で管理する必要がある。

単純に

  • 変位 X
  • 変位 Y
  • 拡大率

の 3 変数があれば良いのだが、この拡大処理の原点はどこなのか、そして拡大と移動のどちらが先に適用されるのかというのは正確に決めておかないと問題が起きる。

拡大原点とタッチ座標について

まず「原点はどこなのか」ということだが、これが違うと拡大縮小時の変位の調整方法が変わるので把握する必要がある。画像の表示ならば画像の左上なのか、画像の中央なのか、それが違えば挙動も変わってしまう。

そこでなのだが、以下のコードでは「与えられるタッチ座標」=「変位X,Y=(0,0),拡大率=100%のときの拡大原点からの距離」ということにさせて欲しい。このように決めておくと実装が簡潔になる。このような形でタッチ座標が取れない場合は、気合で座標を補正するコードを書いて欲しい。

拡大と移動の適用順序

次に拡大と移動の適用順序について。これは移動 → 拡大の順序で適用した場合に、変位の長さが拡大されてしまうという話である。

例えば変位X,Yが(10px,20px)、拡大率が200%だとした場合、移動を先に適用した場合は変位が後から200%に拡大されてしまう。つまり拡大を先に適用した場合の変位(20px,40px)に相当してしまうことになる。これは非常に面倒くさい。

普通は拡大(および回転)を先に適用することにした方が楽だと思われるので、以降の説明も拡大→移動の順での適用を前提とする。別分野ながら、3DCGにおいても一般にそちらの手法が取られる。

タッチイベントの受け取り

ここはもう完全に言語やプラットフォーム依存なので詳しく説明するべくもないが、大抵の場合は

  • タッチ開始
  • タッチ位置の変化
  • タッチ終了

がそれぞれ受け取れると思う。あとオマケでキャンセルイベントとかが有ったり無かったりする。例えばブラウザ環境の JavaScript ではontouchstart/ontouchmove/ontouchendの 3 イベントが受け取れる。また Android アプリではOnTouchEventで全て受け取ることができ、引数でACTION_DOWN/ACTION_MOVE/ACTION_UP/ACTION_CANCELの別がある。

スワイプ動作の実装

挙動の方の説明で書いたように、タッチ位置を保つのが基本となる。位置変化イベントごとに変位を加算していくのでもいいが、場合によってはそれだと誤差の累積があるのでタッチ開始時との差から割り出す方が良いように思う。

以下は疑似コード。ontouchstart(),ontouchmove(),ontouchend()でイベントを受け取る想定。

// 変位X, Y, 拡大率
deltaX = 0
deltaY = 0
zoomRatio = 1.0

// タッチ開始時の座標
firstTouchX
firstTouchY
// タッチ開始時の変位X,Y
firstDeltaX
firstDeltaY

ontouchstart(touches) {
    firstTouchX = touches[0].x
    firstTouchY = touches[0].y
    firstDeltaX = deltaX
    firstDeltaY = deltaY
}
ontouchmove(touches) {
    deltaX = touches[0].x + firstDeltaX - firstTouchX
    deltaY = touches[0].y + firstDeltaY - firstTouchY
}
ontouchend(touches) {
}

オーバースクロールの実装

上のコードだけだとどこまででもスクロールできてしまうので、適宜オーバースクロール時の処理を入れる必要がある。ここではパターン1(止め型)の挙動を考える。 このままでは最初のタッチ位置を愚直にずっと保ってしまうため、端に達した場合にdeltaX,deltaY を補正するだけでなく、基準位置を取り直す必要がある。

// 移動限界
minX() { /* ... */ }
maxX() { /* ... */ }
minY() { /* ... */ }
maxY() { /* ... */ }

// 範囲制限処理
clamp(val, min, max) {
    if (val < min) return min
    if (val > max) return max
    return val
}

// タッチ情報の初期化処理
initTouchState(touches) {
    firstTouchX = touches[0].x
    firstTouchY = touches[0].y
    firstDeltaX = deltaX
    firstDeltaY = deltaY
}

ontouchstart(touches) {
    initTouchState(touches)
}
ontouchmove(touches) {
    // 移動位置補正
    deltaX = clamp(touches[0].x + firstDeltaX - firstTouchX, minX(), maxX())
    deltaY = clamp(touches[0].y + firstDeltaY - firstTouchY, minY(), maxY())

    // オーバースクロール時に再初期化
    if (deltaX == minX() || deltaX == maxX() || deltaY == minY() || deltaY == maxY()) {
        initTouchState(touches)
    }
}
ontouchend(touches) {
}

移動限界の算出はユースケース次第なのでここでは解説しないが、大抵の場合は拡大率zoomRatioを計算の際考慮に入れる必要があると思う。

ピンチ動作の実装

とりあえず、まずは単に拡大率の変化を実装する。

// タッチ開始時の2本指間距離
firstDist
// タッチ開始時の拡大率
firstZoomRatio
// 操作モード
mode = NONE

// 距離計算
dist(touch1, touch2) {
    return sqrt((touch1.x - touch2.x) ** 2 + (touch1.y - touch2.y) ** 2);
}

initTouchState() {
    if (len(touches) == 1) {  // 1本指タッチならばスワイプ
        mode = SWIPE
        firstTouchX = touches[0].x
        firstTouchY = touches[0].y
        firstDeltaX = deltaX
        firstDeltaY = deltaY
    }
    else if (len(touches) == 2) {  // 2本指タッチならばピンチ
        mode = PINCH
        firstDist = dist(touches[0], touches[1])
        firstZoomRatio = zoomRatio
    }
    else {
        mode = NONE
    }
}

ontouchstart(touches) {
    initTouchState()
}
ontouchmove(touches) {
    if (mode == SWIPE) {
        deltaX = clamp(touches[0].x + firstDeltaX - firstTouchX, minX(), maxX())
        deltaY = clamp(touches[0].y + firstDeltaY - firstTouchY, minY(), maxY())

        if (deltaX == minX() || deltaX == maxX() || deltaY == minY() || deltaY == maxY()) {
            initTouchState()
        }
    } else if (mode == PINCH) {
        zoomRatio = dist(touches[0], touches[1]) * firstZoomRatio / firstDist
    }
}
ontouchend(touches) {
    initTouchState()
}

ontouchendでもinitTouchStateを呼んでいる点に注意。 こうしないと2本指でタッチ→1本離すという操作をした場合にスワイプ動作に移れなくなる。

さてこれだとピンチ時に移動が効かないほか、実はそもそも中心が保たれない。 変位X,Yが変わらないまま拡大原点を中心に拡大されるため、拡大原点から離れた場所を拡大しようとすると画面の中心がすごい勢いでずれることになる。

ということで、ピンチ移動の対応より前にまずは「中心を保つ拡大」を実装してみる。

initTouchState() {
    if (len(touches) == 1) {  // 1本指タッチならばスワイプ
        // 略
    }
    else if (len(touches) == 2) {  // 2本指タッチならばピンチ
        mode = PINCH

        // 中点の位置を保存
        firstTouchX = (touches[0].x + touches[1].x) / 2
        firstTouchY = (touches[0].x + touches[1].x) / 2
        firstDeltaX = deltaX
        firstDeltaY = deltaY

        firstDist = dist(touches[0], touches[1])
        firstZoomRatio = zoomRatio
    }
    else {
        mode = NONE
    }
}

ontouchstart(touches) {
    initTouchState()
}
ontouchmove(touches) {
    if (mode == SWIPE) {
        // 略
    } else if (mode == PINCH) {
        tmpScaleDiff = dist(touches[0], touches[1]) / firstDist

        zoomRatio = tmpScaleDiff * firstZoomRatio
        // 拡大縮小時の変位補正
        deltaX = (firstDeltaX - firstTouchX) * tmpScaleDiff
        deltaY = (firstDeltaY - firstTouchY) * tmpScaleDiff
    }
}
ontouchend(touches) {
    initTouchState()
}

このようにすると、最初のタッチ中点位置を保って拡大縮小を行うことが出来る。原理がよく分からなければ式を立てて調べてみるとよい。

ここまで来たらピンチ移動処理は少し付け加えるだけでできる。

ontouchmove(touches) {
    if (mode == SWIPE) {
        // 略
    } else if (mode == PINCH) {
        tmpScaleDiff = dist(touches[0], touches[1]) / firstDist
        touchCenterX = (touches[0].x + touches[1].x) / 2
        touchCenterY = (touches[0].y + touches[1].y) / 2

        zoomRatio = tmpScaleDiff * firstZoomRatio
        // 変位補正+ピンチ移動処理
        deltaX = touchCenterX + (firstDeltaX - firstTouchX) * tmpScaleDiff
        deltaY = touchCenterY + (firstDeltaY - firstTouchY) * tmpScaleDiff
    }
}

これでタッチ中点を保ったピンチ移動が実現できる。あとはここにもオーバースクロール処理を入れれば申し分ない。


疑似コードの全体

// 変位X, Y, 拡大率
deltaX = 0
deltaY = 0
zoomRatio = 1.0

// タッチ開始時の座標
firstTouchX
firstTouchY
// タッチ開始時の変位X,Y
firstDeltaX
firstDeltaY
// タッチ開始時の2本指間距離
firstDist
// タッチ開始時の拡大率
firstZoomRatio
// 操作モード
mode = NONE

// 距離計算
dist(touch1, touch2) {
    return sqrt((touch1.x - touch2.x) ** 2+ (touch1.y - touch2.y) ** 2);
}

// 範囲制限
clamp(val, min, max) {
    if (val < min) return min
    if (val > max) return max
    return val
}

initTouchState() {
    if (len(touches) == 1) {  // 1本指タッチならばスワイプ
        mode = SWIPE
        firstTouchX = touches[0].x
        firstTouchY = touches[0].y
        firstDeltaX = deltaX
        firstDeltaY = deltaY
    }
    else if (len(touches) == 2) {  // 2本指タッチならばピンチ
        mode = PINCH

        firstTouchX = (touches[0].x + touches[1].x) / 2
        firstTouchY = (touches[0].x + touches[1].x) / 2
        firstDeltaX = deltaX
        firstDeltaY = deltaY

        firstDist = dist(touches[0], touches[1])
        firstZoomRatio = zoomRatio
    }
    else {
        mode = NONE
    }
}

ontouchstart(touches) {
    initTouchState()
}
ontouchmove(touches) {
    if (mode == SWIPE) {
        deltaX = clamp(touches[0].x + firstDeltaX - firstTouchX, minX(), maxX())
        deltaY = clamp(touches[0].y + firstDeltaY - firstTouchY, minY(), maxY())

        if (deltaX == minX() || deltaX == maxX() || deltaY == minY() || deltaY == maxY()) {
            initTouchState()
        }
    } else if (mode == PINCH) {
        tmpScaleDiff = dist(touches[0], touches[1]) / firstDist
        touchCenterX = (touches[0].x + touches[1].x) / 2
        touchCenterY = (touches[0].y + touches[1].y) / 2

        zoomRatio = tmpScaleDiff * firstZoomRatio
        deltaX = clamp(touchCenterX + (firstDeltaX - firstTouchX) * tmpScaleDiff, minX(), maxX())
        deltaY = clamp(touchCenterY + (firstDeltaY - firstTouchY) * tmpScaleDiff, minY(), maxY())
        if (deltaX == minX() || deltaX == maxX() || deltaY == minY() || deltaY == maxY()) {
            initTouchState()
        }
    }
}
ontouchend(touches) {
    initTouchState()
}

おわりに

本稿で示したコードは、割と詳細とはいえあくまで疑似コードなのでそのままでは動かない。適宜目的のプログラミング言語に訳して使う必要がある。 またオーバースクロールや「滑り」の実装とかについては深掘りしていないし拡大率の上下限も設けていないので、その辺は目的と趣味に応じて自力でどうにかしてほしい。

それから、タッチデバイスのみを相手取る場合は本稿に示したようなコードだけ書けばいいが、今どきのPCでこうした拡縮・移動ができるUIを提供する場合はカーソル操作、マウスホイール、トラックパッドあたりにも対応しないとおそらくユーザーの期待を裏切ることになる。実装の基本的な部分は本稿で紹介したコードが流用できると思うが、多少は面倒である。

ただ最後に一つ言っておくと、こうした実装は普段ブラックボックスのように使っているものの「一人じゃ無理!」みたいな分量の実装では全然ない。必要があれば臆さず実装すればいいと思う。


コメント