これでできる! クロスブラウザJavaScript入門

第22回JavaScriptによるUIの実装:ドラッグ

こんにちは。前回に引き続き、JavaScriptでUIを実装する方法を紹介していきます。

要素のドラッグ

ウェブアプリでよく使われるインタフェースのひとつ、ドラッグを実装してみます。ドラッグを使用するケースは幾つかありますが、今回は投稿用フォームをドラッグで移動できるようにするという使い方を想定してみます。

投稿フォームで入力する際、そのページのコンテンツを見ながら入力したいということはよくあります。フォームを好きな位置に移動できればそれが実現できます。

ドラッグの基本HTML
<div class="js-drag" id="js-drag-1">
  <form class="js-drag-form" onsubmit="return false;">
    <textarea></textarea>
    <input class="submit" type="submit" value="送信">
  </form>
</div>

そして、CSSは次のとおりです。ドラッグする要素のpositionをabsoluteにして、margin・padding・borderなどは0にしています。これらを0にすることで、これらの値に対するブラウザ間の差を考えなくてもよくなるので実はこれが重要なポイントです。

ドラッグの基本CSS
.js-drag{
  position:absolute;
  background:#555;
  display:block;
  margin:0;
  padding:0;
  border:0;
}
.js-drag form{
  margin:15px;
}
.js-drag textarea{
  width:400px;
  height:240px;
  display:block;
}
.js-drag input.submit{
  width:400px;
  height:30px;
}

さて、ドラッグを実現するJavaScriptです。まずはなるべくシンプルに実装してみます。

ドラッグの基本JavaScript
var element = document.getElementById('js-drag-1');
// 要素の現在位置を取得
var rect = element.getBoundingClientRect();
// 要素をbody直下に移動
document.body.appendChild(element);
// この時、要素の位置がページ最下部に移動してしまう
var root = document.documentElement;
// 現在のスクロール量を取得
var left = window.pageXOffset || root.scrollLeft;
var top  = window.pageYOffset || root.scrollTop;
// 要素の位置を戻す
element.style.left = (rect.left + left) + 'px';
element.style.top = (rect.top + top) + 'px';

var dragging = false;
element.onmousedown = function(evt){
  dragging = true;
};
document.onmouseup = function(evt){
  dragging = false;
};
document.onmousemove = function(evt){
  if(dragging){
    if(!evt){
      evt = window.event;
    }
    var left = window.pageXOffset || root.scrollLeft;
    var top  = window.pageYOffset || root.scrollTop;
    element.style.left = left + evt.clientX + 'px';
    element.style.top = top + evt.clientY + 'px';
  }
};

ドラッグの下準備として、要素をbodyの直下に移動しています。これをしておくことでページの基点と要素の基点が一致して移動させやすくなります。

肝心のドラッグ処理のコードは至ってシンプルですね。マウスダウンでドラッグを開始し、アップでドラッグ停止、移動中はスクロール量を加味したマウスの位置をドラッグ対象の要素に適用してあげるだけです。下記のボタンを押すと下記のフォームをドラッグできるようになります。

しかし、なんとかドラッグはできるものの、色々と問題・改善点があります。

  • ドラッグを開始すると要素の位置がずれる(要素の左上を掴んだ状態になる)
  • ドラッグ中にテキストを選択してしまう
  • テキストエリア内の文字を選択できない
  • スクロールに追従しない(必ずしも追従したほうがよいというわけではありませんが、今回は追従させます)

といった問題があげられます。

まず、ドラッグを開始時に位置がずれる件は、マウスダウンしたときの要素とマウスの相対位置を記憶しておき、ドラッグ中もその位置関係を残すようにしましょう。

ドラッグ中にテキストを選択してしまう問題については、IE以外のブラウザはmousedownの時にデフォルトの挙動をキャンセルする(onmousedownでreturn falseとするか、preventDefaultを呼び出す)ことで防ぐことができます。IEの場合、mousemoveでデフォルトの挙動をキャンセルする(onmousemoveでreturn falseとするか、returnValueにfalseを設定する)ことでテキスト選択を防ぐことができます。

テキストエリア内の文字を選択できない件については、ドラッグを開始する要素を限定することで対応できます。

スクロールの追従については第20回で扱ったposition:fixedで実現します。

改良版ドラッグのJavaScript
var IE6 = !window.XMLHttpRequest && window.ActiveXObject;
var element = document.getElementById('js-drag-2');
var rect = element.getBoundingClientRect();
var root = document.documentElement;
document.body.appendChild(element);
var left = window.pageXOffset || root.scrollLeft;
var top  = window.pageYOffset || root.scrollTop;
element.style.left = (rect.left + left) + 'px';
element.style.top  = (rect.top  + top ) + 'px';

var dragging = false;
var x, y; // 相対位置を保存しておく変数
element.onmousedown = function(evt){
  if(!evt){
    evt = window.event;
  }
  var target = evt.target || evt.srcElement;
  if (target === element) {
    x = evt.offsetX || evt.layerX;//相対位置
    y = evt.offsetY || evt.layerY;
    dragging = true;
    // ドラッグ中はabsoluteに
    var rect = element.getBoundingClientRect();
    var left = window.pageXOffset || root.scrollLeft;
    var top  = window.pageYOffset || root.scrollTop;
    element.style.left = left + rect.left + 'px';
    element.style.top  = top  + rect.top  + 'px';
    element.style.position = 'absolute';
    if (IE6){
      element.style.removeExpression('behavior');
    }
    return false;
  }
};
document.onmouseup = function(evt){
  dragging = false;
  // ドラッグが終わったらfixedに
  var rect = element.getBoundingClientRect();
  element.style.left = rect.left + 'px';
  element.style.top  = rect.top  + 'px';
  element.style.position = 'fixed';
  if (IE6) {
    element.style.position = 'absolute';
    var L = 'document.documentElement.scrollLeft';
    var T = 'document.documentElement.scrollTop';
    element.style.setExpression('behavior', 
       'this.style.left=('+rect.left+'+'+L+')+"px",'+
       'this.style.top =('+rect.top +'+'+T+')+"px"');
    document.body.style.backgroundImage='url(about:blank)';
    document.body.style.backgroundAttachment = 'fixed';
  }
};
document.onmousemove = function(evt){
  if(!evt){
    evt = window.event;
  }
  if(dragging){
    var left = window.pageXOffset || root.scrollLeft;
    var top  = window.pageYOffset || root.scrollTop;
    element.style.left = left + evt.clientX - x + 'px';
    element.style.top  = top  + evt.clientY - y + 'px';
    return false;
  }
};

コードはやや複雑になりましたが、第20回のfixed対応、第19回で取り上げたスクロール量の取得に、getBoundingClientRectなど、これまで取り上げた内容ばかりで特に新しいトピックはありません。

さて、ここまでのコードではひとつの要素に決め打ちでドラッグしていたので、もう少し汎用的に使えるように考えてみます。

まず、document.onmouseup、document.onmousemoveなどはほかのスクリプトから上書きされてしまう可能性が高く、逆にほかのスクリプトを上書きしてしまう可能性もあります。こういった場合は、やや久しぶりの登場となるaddEvent関数の出番ですね。

また、mousemoveなどはイベントが大量に発生するので、監視は最小限に抑えたいところです。実際、mousemoveを監視するのはドラッグを開始してから終了するまでの間だけでよいので、mousedownで監視を始め、mouseupで監視を解除するようにしましょう。

イベントを解除するためにはイベントリスナーとして登録する関数を参照可能な状態にする必要があります。具体的には、次のようなコードになります。

イベントの登録と解除
var addEvent = document.addEventListener ?
  function(node,type,listener){
    node.addEventListener(type,listener,false);
  } :
  function(node,type,listener){
    node.attachEvent('on'+type, listener);
  }

var removeEvent = document.removeEventListener ?
  function(node,type,listener){
    node.removeEventListener(type,listener,false);
  } :
  function(node,type,listener){
    node.detachEvent('on'+type, listener);
  }

addEvent(document,'mousedown', function(evt){
  addEvent(document, 'mousemove', mousemove);
});
addEvent(document, 'mouseup', function(evt){
  removeEvent(document, 'mousemove', mousemove);
});
function mousemove(evt){
}

イベントを登録する際にはmousemoveという関数がそのまま登録され、解除する際もやはりmousemoveに対して解除を行います。

さてもう一つ、今まではドラッグする要素をbodyの直下に移動することで位置の計算を単純にしていました。今回はそのままの位置で移動させてみます。ドラッグする要素の親・祖先要素がposition:relativeなどのスタイルを持つ場合、その位置からの絶対位置になるため、計算がかなり複雑になってしまいます。そこで、getBoundingClientRectで絶対値を求め、offsetLeft/offsetTopとの差を取ることで簡単に位置関係を求めることができます。

例えば、100px、100pxの位置にposition:relativeな要素Aがあり、その子孫要素Bはさらに200px、200pxの位置に存在するとして、⁠スクロールしていない状態で)getBoundingClientRectはleft:300、top:300を返します。その時BはoffsetLeft:200、offsetTop:200となります。要素Bを絶対値で動かすには、100pxの差を加味する必要があります。

最後に、position:fixedの場合は常にページ基点からの配置になるので、上記の差は消えてくれます。つまり、fixedのときは単純に画面左上からの相対位置で動かすだけでよいということです(直感的にもfixedのときのドラッグが簡単であることは予想できると思います⁠⁠。

汎用版ドラッグのJavaScript
function getStyle(element){
  return window.getComputedStyle ? 
    getComputedStyle(element, '') :
    element.currentStyle;
}
var IE6 = !window.XMLHttpRequest && window.ActiveXObject;
var root = document.documentElement;

var dragging = false;
var fixed = false;
var x, y, dx, dy;
var element;
addEvent(document,'mousedown', function(evt){
  if(!evt){
    evt = window.event;
  }
  var target = evt.target || evt.srcElement;
  if (target.className === 'js-drag') {
    x = evt.offsetX || evt.layerX;
    y = evt.offsetY || evt.layerY;
    dragging = true;
    element = target;
    var style = getStyle(element);
    var rect = element.getBoundingClientRect();
    var left = window.pageXOffset || root.scrollLeft;
    var top  = window.pageYOffset || root.scrollTop;
    if (style.position === 'fixed'){
      fixed = true;
      dx = 0;
      dy = 0;
    } else {
      fixed = false;
      dx = (rect.left + left) - element.offsetLeft;
      dy = (rect.top  + top ) - element.offsetTop;
    }
    if (IE6){
      element.style.removeExpression('behavior');
    }
    if (evt.preventDefault){
      evt.preventDefault();
    } else {
      evt.returnValue = false;
    }
    addEvent(document, 'mousemove', mousemove);
  }
});
addEvent(document, 'mouseup', function(evt){
  if(dragging){
    dragging = false;
    removeEvent(document, 'mousemove', mousemove);
    var rect = element.getBoundingClientRect();
    element.style.left = rect.left + 'px';
    element.style.top  = rect.top  + 'px';
    element.style.position = 'fixed';
    if (IE6) {
      element.style.position = 'absolute';
      var L = 'document.documentElement.scrollLeft';
      var T = 'document.documentElement.scrollTop';
      element.style.setExpression('behavior',
 'this.style.left=(' +(rect.left-dx)+ '+' + L + ' )+"px",'+
 'this.style.top =(' +(rect.top -dy)+ '+' + T + ' )+"px"');
     document.body.style.backgroundImage='url(about:blank)'
     document.body.style.backgroundAttachment = 'fixed';
    }
  }
});
function mousemove(evt){
  if(!evt){
    evt = window.event;
  }
  if(dragging){
    var left= fixed? 0: window.pageXOffset||root.scrollLeft
    var top = fixed? 0: window.pageYOffset||root.scrollTop
    element.style.left= left+ evt.clientX - x - dx + 'px';
    element.style.top = top + evt.clientY - y - dy + 'px';
    if (evt.preventDefault){
      evt.preventDefault();
    } else {
      evt.returnValue = false;
    }
  }
}

まとめ

今回はJavaScriptを使ったUIとしてドラッグの実装方法を紹介しました。固定配置(fixed)と非固定配置を切り替えできるようにするなど、改良できるところはまだまだあるので、是非改良を加えてみてください。

次回も引き続きJavaScriptを使ったクロスブラウザなUIの実装を見ていきたいと思います。

おすすめ記事

記事・ニュース一覧