JavaScriptで商品画像の拡大プレビュー機能の実装

こんにちは、Webフロントエンドエンジニアの権守です。今回は弊社で開発中のサービスで実装した商品画像の拡大プレビュー機能の実装について紹介します。

概要

新サービスの商品詳細ページに次の動画のような拡大プレビュー機能を実装しました。

f:id:vasilyjp:20161020184226g:plain

このように拡大プレビュー機能があることで、デザインの細部や生地感がわかり、ユーザにとってよりよい体験を与えることができると思います。

仕様

今回、機能を実装する上で満たさなければいけなかった仕様です。

  • 画像をマウスオーバーした際に横に拡大プレビューを表示する
  • 元の画像はカルーセルで表示されている
  • 画像の大きさは予めわからない
  • 対応ブラウザはPCのモダンブラウザ(IE11, Edge, Firefox最新版, Chrome最新版, Safari最新版)

実装方法

次の順序で実装について説明していきます。

  1. ルーペの表示
  2. 拡大プレビューエリア
  3. 表示・非表示の切り替え
  4. カーソル移動に合わせたプレビュー箇所の変更

ルーペの表示実装

f:id:vasilyjp:20161020184315p:plain

まずは、ルーペの表示部分を実装します。ルーペは商品画像に重なるように表示する必要があります。そのため、商品画像に対して相対的に位置を指定したいところです。しかし、imgタグはタグを内包できないので、画像と同じ大きさを持つdivで一段くくり、それに対して相対位置を指定します。

<div class="m-lens-container">
  <img alt="商品タイトル" src="https://dummyimage.com/600x400/000/fff">
  <div class="m-lens"></div>
</div>
<div class="m-lens-container">
  <img src="https://dummyimage.com/400x600/000/fff">
  <div class="m-lens"></div>
</div>
.m-lens-container {
  display: inline-block;
  position: relative;
  margin-
}
.m-lens {
  position: absolute;
  top: 50px; /* JSで適切な値を設定する */
  left: 30px; /* JSで適切な値を設定する */
  z-index: 2;
  background: #f57716;
  opacity: 0.3;
  height: 172px;
  width: 172px;
}
.m-lens-container img {
  max-height: 344px;
  max-width: 344px;
}

f:id:vasilyjp:20161020184345p:plain

これでルーペ部分を画像に重ねて表示することはできました。しかし、完成形と比較してわかるように画像が縦横中央揃えになっていません。そこで次にdisplay: tableを使って画像の縦横中央揃えを実現します。

<ul class="slides">
  <li class="slide">
    <div class="cell">
      <div class="m-lens-container">
        <img src="https://dummyimage.com/600x400/000/fff">
        <div class="m-lens"></div>
      </div>
    </div>
  </li>
  <li class="slide">
    <div class="cell">
      <div class="m-lens-container">
        <img src="https://dummyimage.com/400x600/000/fff">
        <div class="m-lens"></div>
      </div>
    </div>
  </li>
</ul>
.slides {
  display: flex; /* カルーセルで横並びにする必要があるため */
  justify-content: space-between; /* 解説用 */
  width: 700px; /* 解説用 */
}
.slide {
  display: table;
  background: #fff; /* 解説用 */
  text-align: center; /* 横に中央揃え */
  height: 344px;
  width: 344px;
}
.cell {
  display: table-cell;
  vertical-align: middle; /* 縦に中央揃え */
}

上のように記述することで、画像を縦横中央揃えにすることができました(一部、結果が見えやすい用に解説用のCSSを加えています)。 f:id:vasilyjp:20161020184404p:plain

拡大プレビューエリアの実装

f:id:vasilyjp:20161020184416p:plain

次に、拡大プレビューエリアの実装について説明します。ここでのポイントは、拡大プレビューエリアを画像の右横に他の要素に重なるように表示することと、拡大した画像を切り抜く必要があることです。ここでは、position: absoluteを使って右横に配置し、overflow: hiddenを使って切り抜きます。

<div class="images">
  <div class="zoom-area active"><!-- JSでactiveを切り替える -->
    <img src="https://dummyimage.com/600x400/000/fff" />
  </div>
  <div class="slides-container">
    <ul class="slides">
      ...
    </ul>
  </div>
</div>
.images {
  position: relative;
}
.slides-container { /* カルーセル表示領域 */
  width: 344px;
  overflow: hidden;
}
.zoom-area {
  display: none;
  position: absolute;
  top: 0;
  left: 369px;
  border: 1px solid #ccc;
  height: 520px;
  width: 520px;
  overflow: hidden;
}
.zoom-area.active {
  display: block;
}
.zoom-area img {
  width: 1040px; /* JSで適切な値を設定する */
  margin-top: -30px; /* JSで適切な値を設定する */
  margin-left: -60px; /* JSで適切な値を設定する */
}

position: absoluteを使って画像の右横に設置したいですが、slides-container内にzoom-areaを入れるのは不自然なので、ここでもdiv (images)で一段くくり、それに対しposition: relativeを設定し、プレビューエリアの表示位置の基準値とします。そして、zoom-areaにposition: absoluteを設定し、leftを使って必要な量のオフセットを水平方向に設定します。 次に、画像の切り抜きについてです。これは、zoom-area内に拡大後のサイズの画像を入れて、エリアから溢れた部分を非表示にすることで実現できます。溢れた部分の非表示はoverflow: hiddenで、表示位置の指定はネガティブマージンで実装できます。

f:id:vasilyjp:20161020184441p:plain

後ほど解説しますが、拡大後の画像サイズやネガティブマージンの量の調節、zoom-areaのactiveの切り替えは、JS側で行う必要があります。

表示・非表示の切り替え

ここまでで、CSSによるレイアウトなどの設定はできたので、いよいよ動きの部分を作っていきます。 まずは、ルーペと拡大エリアの表示・非表示の切り替えから説明していきます。

.m-lens {
  display: none;
}
.m-lens-container:hover .m-lens {
  display: block;
}

まず、CSSでm-lensにdisplay: noneを設定することでルーペをデフォルト非表示にします。そして、m-lens-containerのhover時に中のm-lensにdisplay: blockを設定することで、画像をホバーした際にルーペが表示されるようにします。

(function(){
  var zoomArea = document.querySelector('.zoom-area');
  var zoomImage = zoomArea.querySelector('img');
  var size = 172;
  var scale = 520 / size;
  Array.prototype.forEach.call(document.querySelectorAll('.m-lens-container'), function(container){
    var lens = container.querySelector('.m-lens');
    var img = container.querySelector('img');
    container.addEventListener('mouseenter', function(){
      var image = container.querySelector('img');
      zoomArea.classList.add('active');
      zoomImage.setAttribute('src', image.src);
      zoomImage.style.width = (image.offsetWidth * scale) + 'px';
    });
    container.addEventListener('mouseleave', function(){
      zoomArea.classList.remove('active');
    });
  });
})();

次に、JSで画像のホバー時に拡大エリアを表示するようにします。拡大エリアを表示するにはzoom-areaにactiveのクラスを追加すればよいので、m-lens-containerのmouseenterイベントに対して、zoomArea.classList.add('active')の処理を結びつけます。このとき同時に拡大エリアに表示する画像のパスと拡大後のサイズの指定を行います。拡大後の画像のサイズは、左に実際に表示されているサイズに拡大率をかけることで求まります。また、拡大率は拡大エリアのサイズをルーペのサイズで割ることで求まります(ここでは簡単化のためにルーペと拡大エリアのサイズを直接指定していますが、汎用性を高めるならJSでサイズを取得してもよいと思います)。 そして、拡大エリアの非表示は、画像からカーソルが離れた際に、zoom-areaのactiveを解除すればよいので、m-lens-containerのmouseleaveイベントに対して、zoomArea.classList.remove('active')の処理を結びつけることで実装できます。

(注)拡大プレビューエリアの実装の説明時の例では、zoom-areaに対し、activeをデフォルトで指定していましたが、本来はデフォルトでactiveは不要なので削除の必要があります。

f:id:vasilyjp:20161020184559g:plain

カーソル移動に合わせたプレビュー箇所の変更

最後に、カーソル移動に合わせたプレビュー箇所の変更を実装します。

Array.prototype.forEach.call(document.querySelectorAll('.m-lens-container'), function(container){
  ...
  container.addEventListener('mousemove', function(e){
    var rect = container.getBoundingClientRect() ;
    var mouseX = e.pageX;
    var mouseY = e.pageY;
    var positionX = rect.left + window.pageXOffset;
    var positionY = rect.top + window.pageYOffset;
    var offsetX = mouseX - positionX;
    var offsetY = mouseY - positionY;
    var left = offsetX - (size / 2);
    var top = offsetY - (size / 2);

    lens.style.top = top + 'px';
    lens.style.left = left + 'px';
    zoomImage.style.marginLeft = -(left * scale) + 'px';
    zoomImage.style.marginTop = -(top * scale) + 'px';
  });
});

先程のJSにmouseenter, mouseleaveに加え、mousemoveのイベント処理を追加します。ここで必要な処理は、マウスカーソルの移動の度にその座標を取得し、ルーペの表示位置と拡大エリアのプレビュー箇所を計算し、指定することです。 座標の取得については、当初、e.offsetXe.offsetYを用いる予定でしたが、要素が重なっているせいなのか、m-lens-containerではなくルーペの基準値からの距離を取得してしまったため、pageXとpageYを使う実装に変更しました。 pageXとpageYはページの左上からの距離を取得するので、m-lens-containerの左上からの距離に変換する必要があります。そのために、getBoudingClientRectとwindow.pageXOffset, window.pageYOffsetを使う必要があります。まず、rect = container.getBoudingClientRect()で、矩形オブジェクトを取得します。そして、rect.leftrect.topでウィンドウの左上からの距離をそれぞれ取得します。これはあくまで、ウィンドウで表示されている領域からの距離なので、これらの値とpageX, pageYから座標を計算してしまうと、ページがスクロールされている場合に、スクロール量の分だけ計算がずれてしまいます。 そこで、window.pageXOffsetwindow.pageYOffsetを使って、スクロール量も計算に入れます。まとめると次のようになります。

コンテナの左上からの距離 = ページの左上からの距離 - スクロール量 - ウィンドウの左上からの距離

これでカーソル位置を計算することができましたが、このままの値をルーペの表示位置にしてしまうとカーソルがルーペの左上にきてしまいます。そこで、ルーペのサイズの半分を、計算結果から引いてあげます。これでカーソルを中心にルーペが表示できます。 まだ、拡大エリアのプレビューの表示箇所の変更の処理が残っていますが、ここまでくれば後は簡単です。コンテナの左上からのルーペまでの距離と拡大エリアで非表示にしたい左上の領域の比率は同じなので、先程計算したコンテナの左上からのルーペの距離を拡大比率でかけたものをネガティブマージンに設定するだけで処理は完了です。

f:id:vasilyjp:20161020184707g:plain

ここまでで拡大機能は一通りできましたが、上の動画を見てもらうと画像外の範囲もプレビューしてしまって使いづらいことがわかると思います。 そこで、次に、プレビューエリアの限界値の設定を行います。

var xmax, ymax;
img.addEventListener('load', function(){
   xmax = img.offsetWidth - size;
   ymax = img.offsetHeight - size;
});
container.addEventListener('mousemove', function(e){
  ...
  var left = offsetX - (size / 2);
  var top = offsetY - (size / 2);

  if(left > xmax){
    left = xmax;
  }
  if(top > ymax){
    top = ymax;
  }
  if(left < 0){
    left = 0;
  }
  if(top < 0){
    top = 0;
  }
  lens.style.top = top + 'px';
  lens.style.left = left + 'px';
  zoomImage.style.marginLeft = -(left * scale) + 'px';
  zoomImage.style.marginTop = -(top * scale) + 'px';
});

画像の読み込みが終わったタイミングで画像の表示サイズを取得します。そして、ルーペの座標は左上を基準としていることを考慮すると、表示サイズからルーペの大きさを引いた値がルーペの座標の最大値になることがわかります。 後は、ルーペの表示位置の計算結果が限界値を超えた際に、限界値にする処理を入れればルーペが画像をはみ出ることはなくなります。

f:id:vasilyjp:20161020185045g:plain

以上が今回実装した拡大プレビュー機能の実装方法です。

今回、解説時に作ったサンプルをgistに公開してあるので、コード全体を見たい方はこちらを参照してください。

まとめ

今回は新サービスの商品詳細ページで実装した商品画像の拡大プレビュー機能の実装について紹介しました。ECサイト上での買い物では、商品の細部がわからず困るということがよくありますが、この機能を実装することによってそれを少し軽減でき、ユーザにとってより使いやすいサイトになったのではないかと思います。

最後に

VASILYでは、ユーザがより気持ちよくサービスを使えるUIなどを作っていける仲間を募集しています。少しでもご興味のある方は以下のリンク先をご確認ください。

www.wantedly.com