JSでiOSにも配慮した背景固定なスクロール対応のモーダルウィンドウ

スクロール対応のモーダルウィンドウを作ろうと思って何か良いライブラリやプラグインがあれば利用しようと思っていたのですが、いいなと思うものが無かったので自作することにしました。

デモ

基本構造

HTML


<body>

<ol class="timeline">
  <li>...</li>
  <li>...</li>
  ...
  <li>...</li>
</ol>

<div class="overlay">
  <div class="container">
    <div class="inner">
      <section class="modal">
        ...
        <button class="button" type="button">閉じる</button>
      </section>
    </div>
  </div>
</div>

</body>
</html>

例として、Twitterのようにつぶやきをクリックするとモーダルウィンドウが表示されるようにします。.overlay 要素は必ず <body> 直下に配置します。

CSS


* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
.overlay {
  display: none;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, .45);
  overflow: hidden;
  overflow-y: auto; /* scrollにはしないことでスクロールの必要がないときはスクロールバーを表示させない */
  -webkit-overflow-scrolling: touch;
}
.container {
  display: table;
  width: 100%;
  height: 100%;
}
.inner {
  display: table-cell;
  padding: 2.7em 2em; /* モーダル外側の余白 */
  vertical-align: middle;
}
.modal {
  margin: 0 auto;
  padding: 1.9em 2em; /* モーダル内側の余白 */
  max-width: 550px;
  background-color: #fff;
}

最低限必要なCSSのみ抜き出しています。上下中央は以下の記事に詳しく説明がありますが、table を使うことで高さが自由に伸縮するようにしています。

モーダルウィンドウの表示


// つぶやきをクリック
$('.timeline > li').on('click', function() {
  // モーダルをフェードイン
  $('.overlay').fadeIn(300);
});

表示処理はとても簡単です。つぶやきをクリックしたら表示するようにします。

モーダルウィンドウの非表示


// モーダルを閉じる処理
var closeModal = function() {
  // モーダルをフェードアウト
  $('.overlay').animate({
    opacity: 0
  }, 300, function() {
    // モーダルを一番上にスクロールしておく
    $('.overlay').scrollTop(0).hide().removeAttr('style');
  });
};

閉じる処理は使い回すことが多いので関数にします。閉じる際にモーダルウィンドウを一番上へスクロールしておきます。


// オーバーレイをクリック
$('.overlay').on('click', function(event) {
  // モーダルの領域外をクリックで閉じる
  if (!$(event.target).closest('.modal').length) {
    closeModal();
  }
});

// 閉じるボタンクリックでモーダルを閉じる
$('.button').on('click', function() {
  closeModal();
});

モーダルウィンドウの領域外(黒い半透明の部分)とモーダルウィンドウ内のボタンの両方で閉じることができるようにします。

モーダル表示中は背景を固定

モーダルウィンドウ自体がスクロール可能だとスクロール位置が上端または下端に達したときにページ全体がスクロールできてしまい、非常に使いづらくなってしまいます。


html, body {
  overflow: hidden;
}

ほとんどのブラウザでページ全体のスクロールを止めるためにはたったこれだけのCSSを記述すればよいだけです。


// つぶやきをクリック
$('.timeline > li').on('click', function() {
  // スクロール禁止
  $('html, body').css('overflow', 'hidden');
  
  // モーダルをフェードイン
  $('.overlay').fadeIn(300);
});

// モーダルを閉じる処理
var closeModal = function() {
  // overflow: hidden; を消す
  $('body').removeAttr('style');
  
  // モーダルをフェードアウト
  $('.overlay').animate({
    opacity: 0
  }, 300, function() {
    // モーダルを一番上にスクロールしておく
    $('.overlay').scrollTop(0).hide().removeAttr('style');
    // overflow: hidden を消す
    $('html).removeAttr('style');
  });
};

しかし、この方法ではiOSでスクロールを無効にすることはできません。そこで、touchEvent を使って対応します。

iOSでもスクロールを止めたい


$(window).on('touchmove', function(event) {
  event.preventDefault();
});

iOSでスクロールを止める方法も実は簡単で、touchmoveevent.preventDefault(); してやるだけです。しかし、これだけ記述してしまうと問題が発生します。何が問題かというと、モーダルウィンドウ内のスクロールまで禁止されてしまうのです。それを避ける方法が下記になります。


var touch_start_y;

// タッチしたとき開始位置を保存しておく
$(window).on('touchstart', function(event) {
  touch_start_y = event.originalEvent.changedTouches[0].screenY;
});

// つぶやきをクリック
$('.timeline > li').on('click', function() {
  // スワイプしているとき
  $(window).on('touchmove.noscroll', function(event) {
    var current_y = event.originalEvent.changedTouches[0].screenY,
        height = $('.overlay').outerHeight(),
        is_top = touch_start_y <= current_y && $('.overlay')[0].scrollTop === 0,
        is_bottom = touch_start_y >= current_y && $('.overlay')[0].scrollHeight - $('.overlay')[0].scrollTop === height;
    
    // スクロール対応モーダルの上端または下端のとき
    if (is_top || is_bottom) {
      // スクロール禁止
      event.preventDefault();
    }
  });
  
  // スクロール禁止
  $('html, body').css('overflow', 'hidden');
  
  // モーダルをフェードイン
  $overlay.fadeIn(300);
});

// モーダルを閉じる処理
var closeModal = function() {
  // overflow: hidden; と padding-right を消す
  $('body').removeAttr('style');
  // イベントを削除
  $(window).off('touchmove.noscroll');
  
  // モーダルをフェードアウト
  $('.overlay').animate({
    opacity: 0
  }, 300, function() {
    // モーダルを一番上にスクロールしておく
    $('.overlay').scrollTop(0).hide().removeAttr('style');
    // overflow: hidden を消す
    $('html').removeAttr('style');
  });
};

モーダルウィンドウをスクロールしていて、スクロール位置が上端または下端に達したら preventDefault() してスクロールを止めます。また、つぶやきをクリックする度にイベントが登録されてしまうので off() でイベントを削除します。詳しくは以下の記事に分かりやすい説明があります。

スクロールバーの対応


var scrollbar_width = window.innerWidth - document.body.scrollWidth;

// つぶやきをクリック
$('.timeline > li').on('click', function() {
  // スワイプしているとき
  $(window).on('touchmove.noscroll', function(event) {
    var current_y = event.originalEvent.changedTouches[0].screenY,
        height = $('.overlay').outerHeight(),
        is_top = touch_start_y <= current_y && $('.overlay')[0].scrollTop === 0,
        is_bottom = touch_start_y >= current_y && $('.overlay')[0].scrollHeight - $('.overlay')[0].scrollTop === height;
    
    // スクロール対応モーダルの上端または下端のとき
    if (is_top || is_bottom) {
      // スクロール禁止
      event.preventDefault();
    }
  });
  
  // スクロール禁止
  $('html, body').css('overflow', 'hidden');

  // スクロールバーがあるとき
  if (scrollbar_width) {
    // その分padding-rightを追加
    $('html').css('padding-right', scrollbar_width);
  }
  
  // モーダルをフェードイン
  $('.overlay').fadeIn(300);
});

スクロールバーがある場合はその分 padding-right で余白を作らないとモーダルウィンドウ表示時ににずれます。

IEのバグ

IEで表示を確認してみると、スクロールの際にオーバーレイ(黒い半透明の部分)の上端下端がとぎれて、オーバーレイの下のコンテンツが見切れるという現象が起きます。


.overlay {
  display: none;
  position: fixed;
  top: 0;
  top: -10px;
  left: 0;
  right: 0;
  bottom: 0;
  bottom: -10px;
  background-color: rgba(0, 0, 0, .45);
  overflow: hidden;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
.container {
  display: table;
  padding: 10px 0;
  width: 100%;
  height: 100%;
}

そこで、オーバーレイを上下 -10px にして、その子要素である .containerpadding で元に戻しています。これで見切れる現象は解決できます。

iOSにおける fixed の不安定な挙動

iOSで position: fixed; の要素に background-color が指定されているときにスクロールすると上端下端がチラついてしまう現象が起きます。


.overlay {
  display: none;
  position: fixed;
  top: -10px;
  left: 0;
  right: 0;
  bottom: -10px;
  background-color: rgba(0, 0, 0, .45);
  overflow: hidden;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
}

backface-visibility を指定するとCPUからGPU処理になり、指定する前よりはチラつきを抑えることができます。

ソースコード

HTML


<ol class="timeline">
  <li>
    <div class="avatar"></div>
    <section class="content">
      <span>江戸川乱歩</span>
      <p>推理の興味を充分満足させながら、リアルな小説を書くということです。それが理想です。長編の『点と線』などは、その理想に近づいている。ぼくがあなた(松本清張)の出現を画期的といったのはその意味ですよ。</p>
    </section>
  </li>
  <li>
    <div class="avatar"></div>
    <section class="content">
      <span>江戸川乱歩</span>
      <p>孤独に徹する勇気もなく、犯罪者にもなれず、自殺するほどの強い情熱もなく、結局偽善的に世間と交わって行くほかはなかった。</p>
    </section>
  </li>
  <li>
    <div class="avatar"></div>
    <section class="content">
      <span>江戸川乱歩</span>
      <p>男というものは、少々陰険に見えても、根性はあくまでもお人よしにできているものだ。そして、女というものは、表面何も知らないねんねえのようであっても、心の底には生まれつきの陰険が巣くっているものだ。</p>
    </section>
  </li>
  <li>
    <div class="avatar"></div>
    <section class="content">
      <span>江戸川乱歩</span>
      <p>結局、妥協したのである。もともと生きるとは妥協することである。</p>
    </section>
  </li>
  <li>
    <div class="avatar"></div>
    <section class="content">
      <span>江戸川乱歩</span>
      <p>たとえ、どんなすばらしいものにでも二度とこの世に生れ替って来るのはごめんです。</p>
    </section>
  </li>
  <li>
    <div class="avatar"></div>
    <section class="content">
      <span>江戸川乱歩</span>
      <p>「なぜ神は人間を作ったか」というレジスタンスの方が、戦争や平和や左翼よりも百倍も根本的で、百倍も強烈だ。</p>
    </section>
  </li>
  <li>
    <div class="avatar"></div>
    <section class="content">
      <span>江戸川乱歩</span>
      <p>恋愛ばかりでなく、すべての物の考え方が誰とも一致しなかった。</p>
    </section>
  </li>
  <li>
    <div class="avatar"></div>
    <section class="content">
      <span>江戸川乱歩</span>
      <p>中学一年生のころだったと思う。憂鬱症みたいな病気に罹って、二階の一間にとじこもっていた。暗い中で天体のことなどを考えていた。</p>
    </section>
  </li>
  <li>
    <div class="avatar"></div>
    <section class="content">
      <span>江戸川乱歩</span>
      <p>会話を好まず、独りで物を考える、よくいえば思索癖、悪くいえば妄想癖が、幼年時代からあり、大人になっても、それがなおらなかった。</p>
    </section>
  </li>
  <li>
    <div class="avatar"></div>
    <section class="content">
      <span>江戸川乱歩</span>
      <p>小説というものが、政治論文のように積極的に人生をよくするためにのみ書かれなければならないとしたら、彼は多分「現実」とともに「小説」をも厭わしいものに思ったに違いない。</p>
    </section>
  </li>
</ol>

<div class="overlay">
  <div class="container">
    <div class="inner">
      <section class="modal">
        <p>さて、何から書き初めたらいいのか、余りに人間離れのした、奇怪千万な事実なので、こうした、人間世界で使われる、手紙という様な方法では、妙に面おもはゆくて、筆の鈍るのを覚えます。でも、迷っていても仕方がございません。兎も角も、事の起りから、順を追って、書いて行くことに致しましょう。</p>
        <p>私は生れつき、世にも醜い容貌の持主でございます。これをどうか、はっきりと、お覚えなすっていて下さいませ。そうでないと、若し、あなたが、この無躾な願いを容いれて、私にお逢あい下さいました場合、たださえ醜い私の顔が、長い月日の不健康な生活の為ために、二ふた目と見られぬ、ひどい姿になっているのを、何の予備知識もなしに、あなたに見られるのは、私としては、堪たえ難がたいことでございます。</p>
        <p>私という男は、何と因果な生れつきなのでありましょう。そんな醜い容貌を持ちながら、胸の中では、人知れず、世にも烈はげしい情熱を、燃もやしていたのでございます。私は、お化ばけのような顔をした、その上極ごく貧乏な、一職人に過ぎない私の現実を忘れて、身の程知らぬ、甘美な、贅沢ぜいたくな、種々様々の「夢」にあこがれていたのでございます。</p>
        <p>私が若し、もっと豊な家に生れていましたなら、金銭の力によって、色々の遊戯に耽ふけり、醜貌しゅうぼうのやるせなさを、まぎらすことが出来たでもありましょう。それとも又、私に、もっと芸術的な天分が、与えられていましたなら、例えば美しい詩歌によって、此世このよの味気あじきなさを、忘れることが出来たでもありましょう。併しかし、不幸な私は、何いずれの恵みにも浴することが出来ず、哀れな、一家具職人の子として、親譲りの仕事によって、其日そのひ其日の暮しを、立てて行く外ほかはないのでございました。</p>
        <p>私の専門は、様々の椅子いすを作ることでありました。私の作った椅子は、どんな難しい註文主にも、きっと気に入るというので、商会でも、私には特別に目をかけて、仕事も、上物じょうものばかりを、廻して呉くれて居りました。そんな上物になりますと、凭もたれや肘掛ひじかけの彫りものに、色々むずかしい註文があったり、クッションの工合ぐあい、各部の寸法などに、微妙な好みがあったりして、それを作る者には、一寸ちょっと素人の想像出来ない様な苦心が要るのでございますが、でも、苦心をすればした丈け、出来上った時の愉快というものはありません。生意気を申す様ですけれど、その心持ちは、芸術家が立派な作品を完成した時の喜びにも、比ぶべきものではないかと存じます。</p>
        <button class="button" type="button">閉じる</button>
      </section>
    </div>
  </div>
</div>

</body>
</html>

CSS


* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
.timeline {
  margin: 3em auto;
  max-width: 500px;
  list-style: none;
  background-color: #fff;
  box-shadow: 0 3px 20px rgba(184, 184, 141, 0.3);
}
.timeline > li {
  padding: 1.4em 1.5em 1.4em 5em;
  cursor: pointer;
}
.timeline > li + li {
  border-top: 1px solid #eaeae0;
}
.timeline > li::after {
  display: table;
  content: '';
  clear: both;
}
.timeline > li:hover {
  background-color: #fdfdf6;
}
.avatar {
  float: left;
  margin-left: -3.5em;
  width: 54px;
  height: 54px;
  border-radius: 100%;
  background-color: #eee;
}
.content {
  margin-left: .9em;
}
.content > span {
  display: inline-block;
  margin-bottom: .2em;
  font-weight: bold;
}
.content > p {
  text-align: justify;
  text-justify: inter-ideograph;
  line-height: 1.4;
  font-size: .95em;
}
.overlay {
  display: none;
  position: fixed;
  top: -10px;
  left: 0;
  right: 0;
  bottom: -10px;
  background-color: rgba(0, 0, 0, .45);
  overflow: hidden;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
}
.container {
  display: table;
  padding: 10px 0;
  width: 100%;
  height: 100%;
}
.inner {
  display: table-cell;
  padding: 2.7em 2em;
  vertical-align: middle;
}
.modal {
  margin: 0 auto;
  padding: 1.9em 2em;
  max-width: 550px;
  text-align: justify;
  text-justify: inter-ideograph;
  border-radius: 7px;
  background-color: #fff;
  box-shadow: 0 1px 5px rgba(0, 0, 0, .2);
}
.modal::after {
  display: table;
  content: '';
  clear: both;
}
.modal > p {
  text-indent: 1em;
  line-height: 1.7;
}
.button {
  float: right;
  margin-top: .8em;
  padding: .5em 1.4em;
  color: #fff;
  font-size: .95em;
  border: 0;
  border-radius: 4px;
  outline: 0;
  background-color: #e57373;
  cursor: pointer;
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
}
.button:hover {
  background-color: #dd7979;
}

JavaScript


$(function() {

var $window = $(window),
    $html = $('html'),
    $body = $('body'),
    $overlay = $('.overlay'),
    scrollbar_width = window.innerWidth - document.body.scrollWidth,
    touch_start_y;

$window.on('touchstart', function(event) {
  touch_start_y = event.originalEvent.changedTouches[0].screenY;
});

$('.timeline > li').on('click', function() {
  $window.on('touchmove.noscroll', function(event) {
    var overlay = $overlay[0],
        current_y = event.originalEvent.changedTouches[0].screenY,
        height = $overlay.outerHeight(),
        is_top = touch_start_y <= current_y && overlay.scrollTop === 0,
        is_bottom = touch_start_y >= current_y && overlay.scrollHeight - overlay.scrollTop === height;
    
    if (is_top || is_bottom) {
      event.preventDefault();
    }
  });
  
  $('html, body').css('overflow', 'hidden');
  
  if (scrollbar_width) {
    $html.css('padding-right', scrollbar_width);
  }
  
  $overlay.fadeIn(300);
});

var closeModal = function() {
  $body.removeAttr('style');
  $window.off('touchmove.noscroll');
  
  $overlay.animate({
    opacity: 0
  }, 300, function() {
    $overlay.scrollTop(0).hide().removeAttr('style');
    $html.removeAttr('style');
  });
};

$overlay.on('click', function(event) {
  if (!$(event.target).closest('.modal').length) {
    closeModal();
  }
});

$('.button').on('click', function() {
  closeModal();
});

});