4:29 AM投稿記事の長さ:日本国憲法 × 2.2個 くらい

【まさに底なし沼】あの「左側から出てくるメニュー」を普通に動かすのに奮闘した話【iosとposition: fixed;の闇】

トップイメージ

皆様、いかがお過ごしであろうか? 最近Web系の記事を書いていないが、現実世界ではそれなりにエンジニアしている野地である。

今回は、最早辺り前となっているレスポンシブデザインのサイトを作る際に意外と苦労した「左横から出てくるメニュー」の最適解を探す記事を書こうと思う。

実装するだけならさほど難易度の高くないこの機能、実はこだわればこだわるほど泥沼にはまっていく機能なのだ。

実際なにが問題で、どうやって解決するかは記事の中で説明していくが、たどり着いた最終的なコードは最後の部分に書いておくので、さっさとコードをよこせ! という人はそこを参照してほしい。

目次

  1. 要件
  2. 問題その1.背景がスクロール可能+ios版Safariの下部メニューバーが動いてしまう
  3. 問題その2.ios版Safariではposition: fixed;を適用した要素が画面外に出た途端、その部分が省略されてしまう
  4. 問題その3.bodyタグがスクロールできないと、Choromeの画面スクロールによる更新ができない
  5. 問題その4.ios版Safariではbodyタグにoverflow: hiddenを設定してもスクロールができてしまう
  6. 問題その5.スクロールをjsで禁止すると、サイドメニューもスクロールできなくなる
  7. 問題その6.bodyタグにposition: fixedを設定した瞬間、画面のスクロール位置がてっぺんまで戻ってしまう
  8. 問題その7.画面の向きを変えた瞬間、画面が崩れる
  9. 問題その8.スクロールした瞬間に消えるios版safariの下部メニューバー分の領域はしばらく計算されない
  10. そしてできあがったのがコチラ
  11. まとめ

要件

今回作りたいのはスマートフォン用のサイトやレスポンシブデザインのサイトを作るときによくある、ボタンを押すと横から飛び出してくるタイプの機能だ。

さらに詳しく条件をと絞ると「メニューは画面の横90%ぐらいまで出てきて、メニュー領域ではない部分をタップするとメニューが引っ込む」というメニューである。

言うより見る方が早いかと思うので、今回DEMO用に作ったサイトのgifを置いておく(この画面は実機ではなく、Firefox Developper Editionのレスポンシブモードで撮影したモノである)。

このようにメニューが出てきた瞬間、メニューより一つ下にコンテンツ部分を覆い隠すカーテン(今回は黒の半透明)をdivか何かで敷き、それをクリックするとメニューを引っ込めることができるタイプのサイトは誰でも一度は見たことがあるだろう。

さて、この例では画面上部にある「Menu」ボタンはメニューが出てきた後でもクリックが可能となっている。文が「Close」に変わので分かるかと思うが、このCloseボタンを押してもメニューが引っ込むのだ。

なのでわざわざ背景のカーテンをタップすることで閉じる機能を用意しなくてもいいのではないか、という意見もあるだろうが、ユーザーから見れば上部のメニューボタンまで再度指を持っていくのも面倒なので、やはりこのようなメニューを実装してあるサイトはこの機能も一緒につけていることが多い。多分、最近のネットリテラシーにおいて「関係ない部分をクリックしたら画面がデフォルトの状態に戻る」という挙動はごく自然なモノであろう。

このように、このタイプのメニューはデメリットもあるだろうが、採用する価値のあるデザインではある。

しかし、冒頭でも書いたようにこのメニュー、実装が簡単なようで、細かい抜け穴を塞ごうとすると一筋縄ではいかないのだ。

問題その1.背景がスクロール可能+ios版Safariの下部メニューバーが動いてしまう

スマホ用サイトは当然スマホで見るものである。

多くのエンジニアは開発中、ChromeやらFirefoxやらの開発者ツールを使ってスマホ画面を再現しながらデバックをしているだろうが、やはり最終アウトプット先が実機ならば実機でデバックするのがベストだ。

今回の要件で一番最初に引っかかるバグの原因が、ios版Safariの下部に出現するメニューバーの存在である。

iPhoneのSafariを常用している人ならば分かるかと思うが、SafariでWebサイトを閲覧し始めると最初はこのメニューバーが下に出ているのだが、サイトをスクロールするとこのバーが出たり引っ込んだりする。

ただでさえ表示領域の狭いスマホではいかに表示領域とゆとりを広くとれるかが快適さのカギのひとつなので、「ユーザが記事を読み始めたタイミング」であろうスクロールのタイミングでSafariがメニューバーを閉じるのはある意味正しい選択だ。

しかし、このメニューバーの挙動は開発者にとって二つの重大な悪影響を及ぼす。

一つ目が、js界のスタンダートといっても過言ではないjQUeryによる画面の高さを取るメソッドvar height = $(window).height();の値にズレを生じさせる問題だ。

このメソッドを使うことによってheightの中に現在のウィンドウサイズが取得できるのだが、ios版のSafariではheightに下部メニュバーの高さは含まれない。

そのせいで画面の縦幅をjsで取得し、画面一面を覆いたい要素のheightにその値を適用しても、画面をスクロールしてメニューバーが消えた途端、元々メニューバーがあった場所にぽっかり穴が開いてしまうのだ。

今回のケースで言えば、左から出てくるメニューは画面の高さとメニュー領域の高さを揃えないと、

  1. 画面の高さよりも短いとメニュー領域が途中で切れてて汚い。
  2. メニューの項目が多い場合、画面の下の方へメニュー領域がはみ出してしまい、下の項目までスクロールできない。

という問題が発生するのだが、この方法で取得した画面の高さをメニューバーのheightに適用するとメニューバーが消えた時に下部分がブチ切れて見えてしまうのである。

だが、この問題にはちゃんと回避策が存在する。

ウィンドウの高さをとるとき、jQueryによるvar height = $(window).height();ではなく、ネイティブなJavascriptが用意しているvar height = window.innerHeight;を使えば、ちゃんとメニューバー分も含めた高さが取得できるのだ。

JavaScript
  var height = window.innerHeight;
  $('#menu, #curtain').css({
    'height': height
  });
  

これにて一件落着、メニューバーが引っ込んでもちゃんと要素で画面を覆うことができる。

そう思っていた時期が私にもありました。

問題その2.ios版Safariではposition: fixed;を適用した要素が画面外に出た途端、その部分が省略されてしまう

さて、先ほどの回避策で取得した画面の高さをメニュー領域に適用すれば途中で下部メニューバーが引っ込んでも、その下にメニュー領域が表示されているハズである。

悲しかな、現実はそうなってくれない。見た目は先ほどの問題と全く同じ結果となる。

実はSafariは、パフォーマンスの為なのかposition: fixed;で画面に固定された要素の一部が画面外にはみ出ると、その時点でははみ出た部分の描写処理がされないのだ。

事実、position: fixed;は画面に要素を固定する指定なので、基本的に画面外にはみ出たその要素が表示されることはまずない。

jQueryのanimate等でその要素をズラすなのどのケースはありえるだろうが、その時はSafariが改めてその要素を描写し直すので問題無いようだ。jsでcssを計算し直した場合も同様である。

なのでposition: fixed;でサイドメニューを画面に固定している限り、この問題は常について回ることになるだろう。

一応、position: fixed;に頼らずとも、jsでどうにか画面固定を実現する手はある。

サイドメニューにposition: absolute;を適用し、画面がスクロールするたびにそのスクロールした量をサイドメニューのtopに適用してやるのだ。

JavaScript
  jQuery('body').scroll(function(){
    var scrollTop = this.scrollTop();
    jQuery('#menu').css({
      'top': scrollTop
    });
  });
  

しかしこの方法はposition: fixed;と違い、スクロールが発生するたびにサイドメニューの位置を計算し直すので、どうしてもその計算中はサイドメニューの位置がガタついてしまい、見た目ガクガクしたアニメーションのようで気持ち悪い。

そこでもう一つ考えられる回避策が、サイト閲覧中はメニューバーを引っ込めさせないようにする方法だ。

このメニューバー自体は開発者の手中にあるhtmlやcss、jsの産物ではなくブラウザの機能なので直接制御ができないのだが、実はこのメニューバー、引っ込んだりまた出てきたりする動作は画面のスクロールによって起こっている。

htmlでWebサイトを作成する場合は99.9%bodyタグを使うことになるハズだが、普通の作り方をしていれば画面を縦方向にスクロールさせるとき、実際にスクロールしているのは間違いなくこのbodyタグだ。

この特徴に目を付けた(というか、常識?)各ブラウザ開発者は、このbodyタグのスクロールに様々なブラウザ特有のアクションを紐づけている。

そう、ios版Safariもこのメニューバーのアクションをbodyタグのスクロールに紐づけているのだ。

つまり、bodyタグをスクロールさせなければ、別の条件を満たさない限りメニューバーは引っ込まなくなる。

やり方は比較的簡単で、bodyタグにoverflow: hidden;を適用し、その直下にoverflow-y: scroll;height: 100vh;を適用したdiv等ですべての要素を包むWrapper要素を作るだけだ。

css
  body{
    overflow: hidden;
  }
  .wrapper{
    overflow-y: scroll;
    height: 100vh;
  }
  

これによってbodyタグはスクロールせずに画面へ固定され、代わりにWrapper要素の内部で画面スクロールが発生するので、メニューバーも引っ込まず、閲覧者も違和感なくスクロールができるというわけだ。

まさにWeb界におけるコロンブスの卵。これで鬱陶しいiosのバグともオサラバである。

そう思っていた時期が私にもありました。

問題その3.bodyタグがスクロールできないと、Choromeの画面スクロールによる更新ができない

前述した通り、bodyタグのスクロールには様々なブラウザの固有機能が紐づけられている。

実際はどんな機能がるのか良くわかっていなかった筆者は、ios版でのデバックを終え、androidのデバックをしている最中にこの致命的なミスを発見した。

普段はiPhone使いでSafari使いの筆者はあまり気にしていなかったのだが、ios版・android版のChromeではスクロール位置が0(サイトのてっぺん)の時にさらに上へスクロールしようとすると、サイトを更新することができる。

パソコンのようにF5キーの無いスマホでは重宝するワザなのだが、この機能も実はbodyタグのスクロールに紐づけられていたのだ。

つまり先ほどのようにbodyではなくWrapper要素をスクロールするような設計だと、この便利な機能は封印されてしまうのである。

実際のブラウザシェア率から考えて、これは無視できない問題だろう。

bodyタグをスクロールできなくすればChromeがダメ、戻せばSafariがダメ。jsで閲覧者の使っているブラウザを特定できなくもないが、スマートな方法ではないし、できれば全ての環境に対して一種類のコードで対処するのがフロントエンドの美学(?)だ。

さて、まさに前門の虎後門の狼、と言ったところだが、ちゃんとこの問題には横に抜け道がある。

bodyがスクロールするときに困るのはサイドメニューが出ているときだけだ。

つまり、普段はbodyoverflowautoにしておき、サイドメニューが出現するときだけhiddenにしてやればよいのである。

サイドメニューが出ているときはもちろん画面更新ができなくなるが、Webサイト全体でできなくなるより100倍はマシだ。

ややjsが複雑になるが、これでSafariもChromeもいい感じに問題が解決するハズだろう。

そう思っていた時期が私にもありました。

問題その4.ios版Safariではbodyタグにoverflow: hidden;を設定してもスクロールができてしまう

自分の目を疑ったが、タイトル通りである。Safariにはbodyタグに対するoverflow: hidden;が効かないのである。

時期によってはposition: relative;を適用したり、htmlタグにもoverflow: hidden;を適用すればスクロールを禁止できた時期があったようだが、現在はどちらも効果がなくなってしまっている。

問題2の時点では効いていたoverflow: hidden;だが、どうやらSafariはサイト内にあるコンテンツをoverflow: hidden;で見れなくしてしまうとbodyにかかっているソレを無効化するようにできているらしい。

たしかにユーザビリティの観点から見れば至極正しい判断であるが、開発者から見れば正直面倒な仕様だ。

人類は元来、スクロールを禁止したいときにはoverflow: hidden;を使ってきたのだが、時代は変わるものである。

できればcssだけで解決したいこの問題だが、やはり困ったときのjsほど頼もしいものはない。

探してみたらやっぱりjsでスクロール禁止する方法があった(ただし、スマホ専用)。

リンク先から引用させて頂くと、jQueryに備わる.onの第一引数には‘touchmove.noScroll’というイベントを仕込むことができ、そのイベントが発生した瞬間にe.preventDefault();で処理を無効化するとできるという。

全てのコンテンツを見せようと奮闘するSafariには悪いが、これでbodyタグのスクロールを禁止することができた。

これにてやっと、このサイドバー問題から次のステップに進めるだろう。

そう思っていた時期が私にもありました。

問題その5.スクロールをjsで禁止すると、サイドメニューもスクロールできなくなる

Webサイトの多くがスクロールする理由は、そのページで表示できるコンテンツがその画面内に収まらないからだ。当たり前である。

ここでサイドメニューを考えてみよう。

スマホはPCと違い、カーソルによるクリックのではなく指によるタップで操作するため、リンクやボタン類は最低44px×44pxのタップ領域を持つのが良いとされる。

サイドメニューの項目数が少なければいいが、項目数が10、20と増えてしまったら、画面の中にそれら全てを収めるのは至難のワザだ。

なので画面に収まらないメニュー項目はスクロールさせることによって表示させるのが自然なハズなのだが、先ほどスクロールを禁止させてしまったので、それができなくなってしまった。

先述した例ではスクロールを禁止している対象がwindowであるが、これをbodyタグに変えても、サイドメニューはbodyタグ内にあるので一緒にスクロールできなくなってしまうし、bodyタグより下層のタグをスクロール禁止にしても、肝心のbodyタグがスクロール可能だと当初の問題がぶり返してしまう。

サイドメニューの項目が少ない場合は前の章で紹介した方法でOKだが、項目数が多いとき、もしくは将来的に項目数が増えそうなときは別の解決方法を考える必要があるだろう。

もはや前門の虎どころではなく、四面楚歌状態なのは否めないが、Webの世界は広い。

昨日の敵は今日の味方である。

そう、overflowでもpreventDefaultでもダメなら、さんざん我々を悩ませているposition: fixed;を使ってやればいいのだ。

具体的には、サイドメニューを開くタイミングでbodyタグにposition: fixed;を適用すればいい。

ご存じの通り、position: fixed;を適用した要素は画面に固定される。それによってbodyを固定してやれば、自動的にスクロールが聞かなくなるのだ。

そして、サイドメニューを閉じるタイミングでそのposition: fixed;を解除してやれば、再びbodyタグはスクロール可能となる。

JavaScript
  function open(){
    /*略*/
    $('body').css({
      'position'; 'fixed'
    });
  }
  function close(){
    /*略*/
    $('body').css({
      'position'; ''
    });
  }
  

かつて何度も敵として目の前に立ちはだかった存在が真のピンチに味方としてやってくる。

そんなまるで少年漫画のような熱い展開にピークを迎えたこの戦いは好敵手との共闘で幕を閉じるだろう。

そう思っていた時期が私にもありました。

問題その6.bodyタグにposition: fixedを設定した瞬間、画面のスクロール位置がてっぺんまで戻ってしまう

いくら一度味方に付いたからと言って、やはり敵は敵。こういうヤツは戦いが終わった瞬間にいきなり我々へ牙をむいてくるものだ。

bodyに適用したposition: fixed;だが、このまま解除をしないと当然スクロールはできない。

なので、当然position: static;poistion: relative;に再度設定をし直すのだが、この時bodyタグのスクロール位置がてっぺんまで強制的に戻ってしまうのだ。

しかも、position: fixed;を適用した瞬間のbodyタグをよく見てみると場合によってはこの時点でてっぺんに戻ってしまっている場合もある。

ほとんどコンテンツの無いようなWebサイトならまだしも、ただでさえ横幅の狭いスマートフォン用のサイトで縦スクロールしないデザインは少数派だろう。

そんなサイトの下の方を見ていた時、うっかりメニューボタンを押してサイトが一番上までスクロールしてしまってはユーザー体験は散々なモノになってしまう。

これを回避するためにはメニューを出すイベントが発生した時点でbodyタグのスクロール量を変数に保存しておき、メニューを閉じた瞬間にこの値まで画面をスクロールさせるのがよいだろう。

ついでに、position: fixed;を適用した直後にもtopの値にマイナスを掛けたスクロール量を指定すると、メニューを開いている最中もスクロール位置が保存され(正確にはそう見えるだけ)、さらにスマートだ。

JavaScript
  var scroll = 0;
  function open(){
    /*略*/
    scroll = $(window).scrollTop();
    $('body').css({
      'position'; 'fixed',
      'top': -scroll
    });
  }
  function close(){
    /*略*/
    $('body').css({
      'position'; ''
    });
    $(window).scrollTop(scroll);
  }
  

細かいこだわりだが、完璧を求めるのは開発者の性であり、ユーザーが見ているのはコードでなく画面である。

多少の手間がかかったとしても、成果が出せればそれに越したことはないのだ。

今回のこの問題にかかった手間は多少どころではない気もするが、それでも数々の試練を乗り越えて、こうしてゴールができたわけである。

そう思っていた時期が私にもありました。

問題その7.画面の向きを変えた瞬間、画面が崩れる

通常、スマートフォン上で動くブラウザはウィンドウの大きさを変えることはない。

……と、完全に油断していたのだが、実はスマートフォンでもウィンドウのリサイズは起こる。

画面の向きを横にした時だ。

モーダルウィンドウ的な処理を自作する場合、スマートフォンだからといって完全に画面が固定されいると思い込んでいると痛い目を見るのがこのパターンだ。

処理を始めるタイミングで取得したウィンドウの縦幅と横幅を元に作られたモーダルウィンドウは当然、リサイズごとに値を書き換えないと変形してくれない。

Webブラウザの特性上、widthには100%を当てることで要素いっぱいの横幅を与えやすいが、heightには100%が効かないケースがほとんどである。

今まで縦画面でメニューを見ていたユーザーがいきなり画面を横にした場合、例えばwidth: 80%;を指定していたメニューならそのwidthの割合は保持されるが、heigthはスマートフォンの画面縦幅100%のまま画面下方向に突き抜けて行ってしまうのだ。

これに対処するには画面のリサイズイベントを拾って再度、対象要素の高さを調節するしかない。

しかし、リサイズイベントは画面サイズが変わり続けている間無数に発生するので、ここではひと手間加えてリサイズが終わった瞬間にだけ関数が動作するようにしてみよう

JavaScript
  var resizeTimer = false;
  
  function sizing(){
    wh = window.innerHeight;
    $('#menu, #curtain').css({
      'height'; wh
    });
  }
  
  $(function(){
    sizing();
    $(window).resize(function(){
      if(resizeTimer){
        clearTimeout(resizeTimer);
      }
      resizeTimer = setTimeout(function(){
        sizing();
      }, 200);
    });
  });
  

このように、最初にメニューと背景のheightを調節する部分を関数にしてしまい、リサイズ時に使いまわすことによってスムーズなリサイズ対応ができる。

メニューを開いている最中に画面の向きを変えるなど、見づらいデザインのメニューでもない限りする目的が無いハズだが、ユーザーは何をするかわからない。

このように不測の事態にも備えておけるのがイケてるエンジニアなのだ。

流石に、これで不測の事態は全て回避できただろう。

そう思っていた時期が私にもありました。

問題その8.スクロールした瞬間に消えるios版safariの下部メニューバー分の領域はしばらく計算されない

本当に、ユーザーは何をするかわからない。

エンジニア的な考え方では、「メニューを閉じたい」という願望を叶えるためにユーザーが取りうる行動は「閉じるボタンを押す」や「空白(背景)をタップする」等が考えられるだろう。

しかし、その時もし、空白をタップするであろうタイミングで「タップ」ではなく「タップした直後にスクロール」の動作をされてしまったらどうなるか。

そう、メニューバーが引っ込むアニメーションをしている間、メニューバーと背景の高さはそのままで、出っ放しと思われていたiosの下部メニューバーも一緒になって下がってしまい、問題2と同じく下部メニューバー分の穴が発生してしまうのだ。

この現象はメニューバーを閉じるアニメーションが終わった段階で終了するため、細かいことを気にしなければ問題にならないかもしれない。

しかし、クリエイターたるもの、こういう細かい部分もどうにかしないと気が済んではならないのである。

さて、この問題を解決するには、画面を閉じるイベントが発生したらすぐにbodyのpositionstaticに直すのではなく、多少遅延させるのが有効だ。

つまり、スクロールされてもアニメーションしている間はbodyのpositionfixedのままにしておけばスクロールイベントが無効化され、下部メニューバーが引っ込まなくなるのである。

JavaScript
  function close(){
    /*略*/
    setTimeout(function(){
      $('body').css({
        'position': 'static'
      })
      $(window).scrollTop(scrollTop);
    }, 200);
  }
  

このようにsetTimeoutを使うか、もしくは引っ込むアニメーションのコールバック時にcssの変更を実行すれば、アニメーション中のスクロールも怖くないわけである。

これにて、やっと、やっと全ての問題が解決した。

しかし、現在進行形でそう思っている私を過去の者にするのは、今記事をご覧のあなたかもしれません。

そしてできあがったのがコチラ

やっとのことでできた知恵を使ってできたサンプルをここに載せておく。もし必要なら参考にして欲しい。

html
  <!DOCTYPE html>
  <html>
    <head>
      <title>menu sample</title>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0" />
      <link rel="stylesheet" type="text/css" href="style.css" />
    </head>
    <body class="body">
      <div id="curtain" class="curtain"></div>
      <nav id="menu" class="menu">
        <ul class="menuBox">
          <li class="menuList">menuList</li>
          <li class="menuList">menuList</li>
          <li class="menuList">menuList</li>
          <li class="menuList">menuList</li>
          <!--略-->
          <li class="menuList">menuList</li>
        </ul>
      </nav>
      <header class="header">
        <button id="menuBtn" class="menuBtn">Menu</button>
      </header>
      <div class="content">
        <article class="contentBody">
          <h1>メニューサンプル</h1>
          <section class="contentSection">
            <h2>タイトル1</h2>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
          </section>
          <section class="contentSection">
            <h2>タイトル2</h2>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
          </section>
          <!--略-->
          <section class="contentSection">
            <h2>タイトル8</h2>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
            <p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
          </section>
        </article>
      </div>
      <script type="text/javascript" src="jquery.min.js"></script>
      <script type="text/javascript" src="index.js"></script>
    </body>
  </html>
  
css
  .body{
    margin: 0;
    padding: 0;
    font-size: 16px;
    position: relative;
    background: #fafafa;
  }
    .curtain{
    display: none;
    position: fixed;
    top: 60px;
    width: 100%;
    background: rgba(0, 0, 0, 0.8);
  }
  .header{
    width: 100%;
    height: 60px;
    background: #333;
    position: fixed;
    top: 0;
  }
  .menuBtn{
    font-size: 24px;
    min-width: 44px;
    min-height: 44px;
    padding: 0;
    position: absolute;
    left: 8px;
    top: 8px;
    border: 0;
    background: transparent;
    color: #fafafa;
    cursor: pointer;
    border: 0;
  }
  .content{
    padding: 60px 10px 0 10px;
    line-height: 160%;
  }
  .menu{
    position: fixed;
    top: 60px;
    left: -90%;
    width: 85%;
    background: #fff;
    box-shadow: 0 0 10px 3px #666;
    overflow-y: scroll;
  }
  
JavaScript
  (function(){
    var OPENCLASS = 'open';//サイドメニューが開いているときにのみサイドメニューへ付与されるクラス名
    var LEFTVALUE = '90%';//サイドメニューがウィンドウの左側何%を陣取るかの値
    var ANIMATINOSPEED = 300;//アニメーションにかかる時間。0.3秒
    var resizeTimer = false;//リサイズ処理を大量に行わないために使用する変数
    var scrollTop = 0;//スクロール量を保存しておく変数

    /**
     * メニューバーの高さ(height)を設定するための関数
     * 一番最初と、画面のリサイズが起こった際に使用
     * 画面の高さをwindow.innerHeightで取得することでiosのメニューバー分も含めた高さを取得できる
     * 60を引いているのはheader部分の高さを引いているため。すでにtopには60pxが適用されている
     */
    function sizing(){
      var wh = window.innerHeight;
      $('#menu, #curtain').css({
        'height': wh - 60
      });
    }

    /**
     * メニュー開閉をする関数
     * これを呼ぶことで特に今の状態を気にすることなくメニューの開閉ができる
     * メニューが出ている、引っ込んでいるという判断は#menuが.openを持っているかどうか
     * ボタンを押した時の些細なアニメーションとして、一瞬だけボタンのmargin-topを変化させている
     * 
     */
    function menuAction(){
      var btn = $('#menuBtn');//メニューボタン
      var menu = $('#menu');//メニュー本体
      var curtain = $('#curtain');//メニューの後ろに引かれる黒い幕
      //ボタンアニメーション
      btn.css({
        'margin-top': 2
      });

      //ボタンアニメーション
      setTimeout(function(){
        btn.css({
          'margin-top': ''
        });
      }, 100);

      //メニュー開閉
      if(menu.hasClass(OPENCLASS)){//openクラスを持っているので、閉じる動作をする
        //openクラス削除
        menu.removeClass(OPENCLASS);

        //メニューを画面外へ戻すアニメーション
        menu.animate({
          'left': '-' + LEFTVALUE
        }, ANIMATINOSPEED);

        //背景の黒い幕を消すアニメーション
        curtain.animate({
          opacity: 'hide'
        }, ANIMATINOSPEED);

        //ボタンのテキストを「Menu」に戻す
        btn.text('Menu');

        //処理発動から0.2秒後にbodyタグのpositionをrelativeに戻し、元のスクロール位置に帰還
        setTimeout(function(){
          $('body').css({
            'position': 'relative'
          })
          $(window).scrollTop(scrollTop);
        }, 200);
      }
      else{//openクラスを持っているので、開く動作をする
        //openクラス追加
        menu.addClass(OPENCLASS);

        //画面外にあったメニューを引っ張り出すアニメーション
        menu.animate({
          'left': 0
        }, ANIMATINOSPEED);

        //display: none;で消していた背景の黒幕を出現させるアニメーション
        curtain.animate({
          opacity: 'show'
        }, ANIMATINOSPEED);

        //ボタンのテキストを「Close」に変更
        btn.text('Close');

        //メニューバーを開いた瞬間のスクロール位置を変数に保存
        scrollTop = $(window).scrollTop();

        //position: fixed;でbodyのスクロールを禁止。スクロール位置もそのままで固定
        $('body').css({
          'position': 'fixed',
          'top': -scrollTop
        });
      }
    }
    $(function(){
      //画面の読みこみが終わったらメニューバーの高さ(height)を設定
      sizing();

      //ウィンドウのリサイズ(主にスマートフォンの向き回転)に対応する処理
      $(window).resize(function(){
        if(resizeTimer){//resizeTimerに後述のタイマーが入っている場合はtrue扱いになる
          //タイマーにセットされている処理をキャンセルする
          clearTimeout(resizeTimer);
        }

        //リサイズするたびにresizeTimerへ処理が予約される
        resizeTimer = setTimeout(function(){
          //画面リサイズ
          sizing();
        }, 200);
      });

      //メニューボタンを押して、メニューを開閉する
      $('#menuBtn').click(function(){
        menuAction();
      });

      //背景の黒い幕に触れた瞬間、メニューを閉じる(スマートフォン専用)
      $('#curtain').on('touchstart', function(){
        menuAction();
      });

      //背景の黒い幕に触れた瞬間、メニューを閉じる(PC用)
      $('#curtain').click(function(){
        menuAction();
      });
    });
  }());
  
GitHub

リポジトリ
https://github.com/Go-Noji/sideMenuBarAction

Webページ(スマホ実機専用。エミュレーターでは正しく動作しない可能性アリ)
https://go-noji.github.io/sideMenuBarAction/

まとめ

今回も例に漏れず大長編でお送りした記事、いかがだったろうか。

数々の有名なWebサービスが採用しているこのUIだが、意外と実装には数々の試練が待ち構えているのが分かっていただけたかと思う(もしかしたら筆者が見落としているだけで、もっとスマートな方法があるのかもしれないが)。

しかし、エンジニアとはいわゆる縁の下の力持ちだ。

一般ユーザーはイカしたデザインには反応するかもしれないが、イカしたコードにはなかなか気づいてくれない。彼らにとって、これら機能は「動いて当然」なのだ。

当然のことを当然にするまでの長い長い旅路はモノを作る人だけが知る神話の世界だ。

しかし、当然が当然であるからこそ、素晴らしいモノは素晴らしいと評価される。

神はお前だ! とクライアントに言われるその日まで、我々は当然の仕事をするまでなのである。

Comments

  1. たまに会社に行く人 より:

    そんでまたOSがバージョンアップすると仕様が変わる、と。

    • noji より:

      たまに会社に行く人さん

      そうなんですよね……この記事も定期的に追記を入れる羽目になりそうです(笑)

コメントを付ける

入力エリアすべてが必須項目です。

内容をよくご確認の上、送信してください