javascript の touch イベントだけで、画像の移動、拡大縮小に挑戦


 touch操作ライブラリの定番と言えばhammer.jsです。
hammer.jsを使っている中で、気づいたことがあります。 それは、画像の拡大を行い、2本指を離すときに発見しました。具体的には、拡大したときに、ゆっくり指を1本づつ離したときに、画像が少し動くことです。皆様も、”Hammer JS Pinch Pan Zoom Image”(https://codepen.io/bakho/pen/GBzvbB)で体験することができます。

 この現象を調べることにしました。しかしながら、hammer.jsを解析することはちょっと難しいです。いろいろネットで調べたところ、丁度よいサンプルがありました。画像の拡大縮小を”javascript の touch イベントでやってみる”(https://ara-web.net/blog/android/post-3265/)では、touchstart、touchmove、touchendを丁寧に説明すると共に、シンプルながら的確なデモがありました。このデモプログラムを用いて、この現象を調査しました。  このデモプログラムでは、ダブルタッチの指アクションによる画像のズレは見られませんでした。このプログラムはdocument全体が移動・拡大するもので、今回の現象を確認できませんでした。そこで、画像要素を移動・拡大の対象としたところ、同様の現象が確認できました。

 これまでタッチイベントを理解していなかったため、まず、touchイベントを調査しました。event.touchs.lengthで、タッチしている指?の数(touch数)が分かります。event.touchs.lengthで、ダブルタッチの状況をtouchstart、touchmove、touchendのイベント毎に観察したところ、touchstartイベントは必ずevent.touchs.lengthは1。何度ダブルタッチを試みても、その瞬間は1本指でした。そりゃそうですよね、どうしてもその瞬間、人は2本の指を同時タッチできません。その後、touchmoveイベントでタッチ数は2になります。次に指を1本離すとtouchendイベントが走ります(ここでも2本指を同時離せません)。これもtouchstartと同様、何回行ってもtouchendイベントではevent.touchs.lengthは1となります。touchendイベントで、すべての指アクションがなくなれば問題ないのですが、タッチ状態がまだ残っていることで、継続してtouchmoveイベントが生じる、これによって指を離すとき、画像がズレる現象となっているが分かりました。  この調査によって、touchendイベント後、touchmoveイベントを削除することによって、画像がズレることを防止できることができました。具体的なコード、デモは次の通りです。

コードの説明
・サンプルプログラムは、タッチイベントが動作するデバイスのみで作動します。
・動作は、android、iPhone、surfaceで確認しました。
・サンプルプログラムで、onをタッチすると、pan、zoomが作動します、offをタッチすると、pan、zoomが停止します。
・デバイスが有しているzoom機能は、<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"> と、styleのtouch-action: none;で制御しています。
・拡大・縮小は、ダブルタッチの重心を拡大縮小の起点として描画します。
・拡大・縮小は、指が20ドット以上離れたときに描画します。移動も同様、20ドット以上、指が動いたときに描画します。

・addEvent()でイベントを登録し、removeEvent()でイベントを削除します。
・touchmoveイベントの登録は、touchstartイベントが発生したときに登録し、touchendイベントが発生したときに削除します。
 touchendイベント時にtouchmoveイベントを削除することによって、hammer.jsで生じていた画像のズレを解消することができました。
・なお、ゆっくりzoomすると、画像がブルブルします。

デモ 移動・拡大が確認できます。

ソースコード
<!DOCTYPE html>
<html>
<head lang="ja">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
<title>pan and zoom for touch event</title>

<style>
body, html {
	text-align: center;
	touch-action: none;
}
</style>
</head>

<body>

<button onclick="on();">on</button> <button onclick="off();">off</button> 
<div>test pan ans zoom  <span id ="mes"/></div>

<script>
class PanZoom {
X = 0;
Y = 0;
Scale = 1;

constructor(el, func) {
	this.el = el;
	this.style = this.el.style;
	this.func = func;
	this.touchStart = this.touchStart.bind(this);
	this.touchMove = this.touchMove.bind(this);
	this.touchEnd = this.touchEnd.bind(this);
	this.W = this.el.width;
	this.H = this.el.height;
	this.L = this.el.offsetLeft;
	this.T = this.el.offsetTop;
console.log(this.W, this.H);
}

addEvent() {
	this.el.addEventListener('touchstart', this.touchStart);
	this.el.addEventListener('touchend', this.touchEnd);
}

removeEvent() {
	this.el.removeEventListener('touchstart', this.touchStart);
	this.el.removeEventListener('touchend', this.touchEnd);
}

touchStart(e) {
	if (!this.t_mode && e.touches.length > 1) {//multi touch
		this.t_mode = 'multi';
		this.touchstart_flg = true;
		this.touchstart_bar = 0;
		this.touchmove_bar = 0;
		//touch size
		this.w_abs_start = e.touches[1].pageX - e.touches[0].pageX;
		this.h_abs_start = e.touches[1].pageY - e.touches[0].pageY;
		//touch distance
		this.touchstart_bar = Math.sqrt(this.w_abs_start * this.w_abs_start + this.h_abs_start * this.h_abs_start);
		this.cx = this.w_abs_start / 2 + e.touches[0].pageX;//center of double touch
		this.cy = this.h_abs_start / 2 + e.touches[0].pageY;

		this.scale = 1;
		this.w = this.el.width;
		this.h = this.el.height;
		this.sx = this.el.offsetLeft;
		this.sy = this.el.offsetTop;

		this.dx = this.cx - this.sx;//touch position in element
		this.dy = this.cy - this.sy;
	}
	else if (e.touches.length == 1) {//single touch
		this.sx = this.el.offsetLeft;
		this.sy = this.el.offsetTop;
		this.touchstart_flg = true;
		this.mstartx = e.touches[0].pageX;
		this.mstarty = e.touches[0].pageY;
	}
	this.el.addEventListener("touchmove", this.touchMove);
}

touchMove(e) {
	if (this.t_mode == 'multi' && e.touches.length > 1) {//multi touch
		//touch size
		this.w_abs_move = e.touches[1].pageX - e.touches[0].pageX;
		this.h_abs_move = e.touches[1].pageY - e.touches[0].pageY;
		//touch distance
		this.touchmove_bar = Math.sqrt(this.w_abs_move * this.w_abs_move + this.h_abs_move * this.h_abs_move);
		if (this.touchmove_bar < 25) return;

		//difference between start and move
		this.dist_bar = this.touchstart_bar - this.touchmove_bar;
		if (this.dist_bar < 0) {//large
			this.scale *= 1.02;
			if (this.Scale > 3) this.scale *= 0.98;
		}
		else if (this.dist_bar > 0) {//small
			this.scale *= 0.98;
			if (this.Scale < 0.3) this.scale *= 1.02;
		}

		this.touchstart_bar = this.touchmove_bar;
		this.el.width = this.w * this.scale;
		this.el.height = this.h * this.scale;
		this.dmx = (1 - this.scale) * this.dx;
		this.dmy = (1 - this.scale) * this.dy;

		this.el.style.left = (this.sx + this.dmx) + 'px';
		this.el.style.top = (this.sy + this.dmy) + 'px';
	}
	else if (e.touches.length == 1) {//single touch
		this.t_mode = 'single';
		this.dmx = e.touches[0].pageX - this.mstartx;
		this.dmy = e.touches[0].pageY - this.mstarty;
		if (this.dmx * this.dmx + this.dmy * this.dmy < 400) return;

		this.style.left = (this.sx + this.dmx) + 'px';
		this.style.top = (this.sy + this.dmy) + 'px';
	}
	this.func();
}

touchEnd() {
	this.t_mode = null;
	this.touchstart_flg = false;
	this.el.removeEventListener('touchmove', this.touchMove);
}

}

// -----------------------------------------

let pz;
let img = new Image();
img.onload = function() {
	img.id = 'test';
	img.style.position = 'absolute';
	img.style.left = '100px';
	img.style.top = '100px';
	document.body.appendChild(img);

	pz = new PanZoom(img, displayTransform);
}
img.src = 'neko.jpg';

function on() {
console.log('on');
	pz.addEvent();
}

function off() {
console.log('off');
	pz.removeEvent();
}

function displayTransform() {
	this.X = this.el.offsetLeft - this.L;
	this.Y = this.el.offsetTop - this.T;
	this.Scale = this.el.width / this.W;
	document.getElementById("mes").innerHTML = JSON.stringify({x : this.X, y: this.Y, scale: this.Scale});
}
</script>
</body>
</html>

作成 2020年2月2日
修正 2020年2月3日
大坂 哲司