ツクログネット

ネイティブアプリのようなスライドメニューを作る

eye catch

スマホアプリのG-mailやTwitterに実装されているような画面を横にスワイプすることで出現するスライドメニューをHTML+CSS+JavaScriptで作る方法です。

DEMO

ウィンドウの左端をタッチしたまま指を右へスライドすると、メニューが引き出されますよね。

尚、このDEMOはタッチ操作のみ対応しているため、デスクトップで確認する際はChrome等のDeveloper Tools等を使用してください。

HTMLを書く

始めにHTMLファイルに以下のコードを書き、全体の構造を定義します。

<div class="body-inner"> <div class="overlay"></div> <aside class="menu"> <div class="menu-inner"> <header class="header"> <div class="bg"> <div class="caption"> <div class="account-img"></div> <div class="account-info flex"> <div> <h3 class="caption-title-1">ツクログ</h3> <p class="caption-title-2">@tsukulognet</p> </div> <div class="arrow"><i class="fa fa-caret-down color-white" aria-hidden="true"></i></div> </div> </div> </div> </header> <ul class="menu-list"> <li class="menu-list-item icon icon-user icon-color-gray icon-m-r-2">プロフィール</li> <li class="menu-list-item icon icon-clone icon-color-gray icon-m-r-2">ハイライト</li> <li class="menu-list-item icon icon-bolt icon-color-gray icon-m-r-2">自分のモーメント</li> <li class="menu-list-item icon icon-list icon-color-gray icon-m-r-2">リスト</li> <li class="menu-list-item icon icon-user-plus icon-color-gray icon-m-r-2">おすすめユーザー</li> <li class="menu-list-item flex icon icon-toggle-on icon-color-gray">夜間モード</li> <li class="menu-list-item">QRコード</li> <li class="menu-list-item">設定</li> <li class="menu-list-item">ヘルプ</li> </ul> </div> </aside> <header class="global-header"> <div class="flex"> <h1 class="logo"></h1> <div class="current-item-title">ホーム</div> <button class="btn btn-search icon icon-search icon-color-sky"></button> </div> </header> <nav class="global-nav"> <ul class="global-nav-list flex"> <li class="global-nav-list-item current-item"><button class="btn btn-home icon icon-home icon-color-gray"></button></li> <li class="global-nav-list-item"><button class="btn btn-news icon icon-news icon-color-gray"></button></li> <li class="global-nav-list-item"><button class="btn btn-bell icon icon-bell icon-color-gray"></button></li> <li class="global-nav-list-item"><button class="btn btn-dm icon icon-dm icon-color-gray"></button></li> </ul> </nav> <main class="content"> <ul class="tl"> <li class="card flex"> <aside class="thumbnail"></aside> <div class="card-inner"> <div class="tweet-header flex"> <h3 class="user-name">ツクログ<span class="user-id color-gray-3">@tsukulognet</span></h3> <time class="tweet-time color-gray-3">1分前</time> </div> <div class="tweet"> <p class="tweet-text">猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。</p> <figure class="tweet-img"><img src="http://tsukulog.local/wp-content/uploads/2015/11/bsHIR96_boxneko.jpg" alt="ツイート画像" /></figure> </div> <footer class="tweet-footer"> <nav class="tweet-action flex"> <button class="btn reply icon icon-reply icon-sm icon-color-gray"></button> <button class="btn retweet icon icon-retweet icon-sm icon-color-gray"></button> <button class="btn love icon icon-heart icon-sm icon-color-gray"></button> <button class="btn dm icon icon-dm icon-sm icon-color-gray"></button> </nav> </footer> </div> </li> <li class="card flex"> <aside class="thumbnail"></aside> <div class="card-inner"> <div class="tweet-header flex"> <h3 class="user-name">ツクログ<span class="user-id color-gray-3">@tsukulognet</span></h3> <time class="tweet-time color-gray-3">1分前</time> </div> <div class="tweet"> <p class="tweet-text">猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。</p> <figure class="tweet-img"><img src="http://tsukulog.local/wp-content/uploads/2015/11/bsHIR96_boxneko.jpg" alt="ツイート画像" /></figure> </div> <footer class="tweet-footer"> <nav class="tweet-action flex"> <button class="btn reply icon icon-reply icon-sm icon-color-gray"></button> <button class="btn retweet icon icon-retweet icon-sm icon-color-gray"></button> <button class="btn love icon icon-heart icon-sm icon-color-gray"></button> <button class="btn dm icon icon-dm icon-sm icon-color-gray"></button> </nav> </footer> </div> </li> <li class="card flex"> <aside class="thumbnail"></aside> <div class="card-inner"> <div class="tweet-header flex"> <h3 class="user-name">ツクログ<span class="user-id color-gray-3">@tsukulognet</span></h3> <time class="tweet-time color-gray-3">1分前</time> </div> <div class="tweet"> <p class="tweet-text">猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。</p> <figure class="tweet-img"><img src="http://tsukulog.local/wp-content/uploads/2015/11/bsHIR96_boxneko.jpg" alt="ツイート画像" /></figure> </div> <footer class="tweet-footer"> <nav class="tweet-action flex"> <button class="btn reply icon icon-reply icon-sm icon-color-gray"></button> <button class="btn retweet icon icon-retweet icon-sm icon-color-gray"></button> <button class="btn love icon icon-heart icon-sm icon-color-gray"></button> <button class="btn dm icon icon-dm icon-sm icon-color-gray"></button> </nav> </footer> </div> </li> <li class="card flex"> <aside class="thumbnail"></aside> <div class="card-inner"> <div class="tweet-header flex"> <h3 class="user-name">ツクログ<span class="user-id color-gray-3">@tsukulognet</span></h3> <time class="tweet-time color-gray-3">1分前</time> </div> <div class="tweet"> <p class="tweet-text">猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。</p> <figure class="tweet-img"><img src="http://tsukulog.local/wp-content/uploads/2015/11/bsHIR96_boxneko.jpg" alt="ツイート画像" /></figure> </div> <footer class="tweet-footer"> <nav class="tweet-action flex"> <button class="btn reply icon icon-reply icon-sm icon-color-gray"></button> <button class="btn retweet icon icon-retweet icon-sm icon-color-gray"></button> <button class="btn love icon icon-heart icon-sm icon-color-gray"></button> <button class="btn dm icon icon-dm icon-sm icon-color-gray"></button> </nav> </footer> </div> </li> <li class="card flex"> <aside class="thumbnail"></aside> <div class="card-inner"> <div class="tweet-header flex"> <h3 class="user-name">ツクログ<span class="user-id color-gray-3">@tsukulognet</span></h3> <time class="tweet-time color-gray-3">1分前</time> </div> <div class="tweet"> <p class="tweet-text">猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。</p> <figure class="tweet-img"><img src="http://tsukulog.local/wp-content/uploads/2015/11/bsHIR96_boxneko.jpg" alt="ツイート画像" /></figure> </div> <footer class="tweet-footer"> <nav class="tweet-action flex"> <button class="btn reply icon icon-reply icon-sm icon-color-gray"></button> <button class="btn retweet icon icon-retweet icon-sm icon-color-gray"></button> <button class="btn love icon icon-heart icon-sm icon-color-gray"></button> <button class="btn dm icon icon-dm icon-sm icon-color-gray"></button> </nav> </footer> </div> </li> <li class="card flex"> <aside class="thumbnail"></aside> <div class="card-inner"> <div class="tweet-header flex"> <h3 class="user-name">ツクログ<span class="user-id color-gray-3">@tsukulognet</span></h3> <time class="tweet-time color-gray-3">1分前</time> </div> <div class="tweet"> <p class="tweet-text">猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。猫なう。</p> <figure class="tweet-img"><img src="http://tsukulog.local/wp-content/uploads/2015/11/bsHIR96_boxneko.jpg" alt="ツイート画像" /></figure> </div> <footer class="tweet-footer"> <nav class="tweet-action flex"> <button class="btn reply icon icon-reply icon-sm icon-color-gray"></button> <button class="btn retweet icon icon-retweet icon-sm icon-color-gray"></button> <button class="btn love icon icon-heart icon-sm icon-color-gray"></button> <button class="btn dm icon icon-dm icon-sm icon-color-gray"></button> </nav> </footer> </div> </li> </ul> </main> </div>

HTMLで重要となる要素はメニュー本体.menuとオーバーレイ.overlayです。この2つをJavaScriptで制御します。

CSS(SCSS)を書く

次にSCSSファイルに以下のコードを書き、全体の見た目やスタイルを定義します。

$gray-base:#333; $gray-light-1:lighten($gray-base, 10%); $gray-light-2:lighten($gray-base, 30%); $gray-light-3:lighten($gray-base, 40%); $gray-lighter:lighten($gray-base, 70%); $gray-lightest:lighten($gray-base, 75%); $white:lighten($gray-base, 80%); $black:darken($gray-base, 20%); $blue:#40AAEF; * { box-sizing: border-box; } html { font-size: 4vw; @media all and (min-width: 768px) { font-size: 100%; } } body { background: $gray-lightest; color: $gray-light-1; font-family: "Noto Sans Japanese"; height: 1000px; margin:0; } img { height: auto; max-width: 100%; } /* メニュー */ .menu { background: $white; height: 100%; position: fixed; left: -80%; //初めは画面左外へ隠す overflow-y: visible; width: 80%; z-index: 2000; -webkit-user-select: none; //要素の文字列や画像を選択できないように -webkit-overflow-scrolling: touch; //慣性スクロールを適用 /* メニューの摘み */ &::after { content: ''; background-color:rgba(#333, .4); display: block; height: 100%; position: absolute; top: 0%; left: 100%; width: 20%; } &-list { background: $white; padding: 0.7rem 0; &-item { font-weight: 500; padding: 0.8rem 1.1rem; &:nth-child(5) { border-bottom: 1px solid $gray-lighter; padding-bottom: 1.4rem; } &:nth-child(6) { align-items: baseline; justify-content: space-between; padding-top: 1.4rem; } } } } /*メニューのすまみを削除するスタイル */ .change { &::after { content: ''; display: none; } } .header { height: 12rem; //max-height: 300px; } .bg { background: url("http://tsukulog.local/wp-content/uploads/2015/11/bsHIR96_boxneko.jpg") no-repeat; background-size: cover; height: 100%; position: relative; &::before { background: rgba($black, 0.4); content: ''; height: 100%; position: absolute; top: 0; left: 0; width: 100%; } } .caption { padding: 1.1rem; position: absolute; left: 0; bottom: 0; width: 100%; &-title { &-1 { color: $gray-lightest; font-size: 1rem; margin: 0; margin-bottom: 0.4rem; } &-2 { color: $gray-lighter; font-size: 0.8rem; margin: 0; } } } .color { &-white { color: $white; } &-gray { &-2 { color: $gray-light-2; } &-3 { color: $gray-light-3; } } } .account { &-img { background: url("http://tsukulog.local/wp-content/uploads/2015/11/bsHIR96_boxneko.jpg") no-repeat; background-size: cover; border-radius: 50%; height: 4rem; margin-bottom: 1.2rem; width: 4rem; } &-info { align-items: center; justify-content: space-between; } } .overlay { background: rgba($gray-base, 0.7); opacity: 0; //初めは透明に position:fixed; top: 0; left: 0; right:0; bottom:0; visibility: hidden; //初めは非表示。display:none;ではtransitionが効かない。 z-index: 1; } ul { list-style: none; margin: 0; padding: 0; } .icon { &::before, &::after { font-family: "FontAwesome"; font-size: 1.4rem; display: inline-block; width: 1.4rem; } &-color { &-gray { &::before, &::after { color: $gray-light-2; } } &-white { &::before, &::after { color: $white; } } &-sky { &::before, &::after { color: $blue; } } } &-m { &-r { &-2 { &::before, &::after { margin-right: 2rem; } } } } &-user { &::before { content: '\f007'; } &-plus { &::before { content: '\f234'; } } } &-list { &::before { content: '\f03a'; } } &-clone { &::before { content: '\f24d'; } } &-bolt { &::before { content: '\f0e7'; } } &-toggle-on { &::after { content: '\f205'; } } &-down { &::before { content: '\f0d7'; width: 0; } } &-search { &::before { content: '\f002'; } } &-home { &::before { content: '\f015'; } } &-news { &::before { content: '\f1ea'; } } &-bell { &::before { content: '\f0f3'; transform: rotate(30deg); } } &-dm { &::before { content: '\f0e0'; } } &-reply { &::before { content: '\f112'; } } &-retweet { &::before { content: '\f079'; } } &-heart { &::before { content: '\f004'; } } &-sm { &::before { font-size: 1rem; } } } .flex { display: flex; } /* タイムラインのスタイル */ .global-header { background: $white; width: 100%; >div { align-items: center; padding: 0.8rem; } } .logo { background: url("http://tsukulog.local/wp-content/uploads/2015/11/bsHIR96_boxneko.jpg") no-repeat; background-size: cover; border-radius: 50%; height: 2rem; margin: 0; width: 2rem; } .current-item { border-bottom: 0.2rem solid $blue; &-title { margin-left: 1.5rem; margin-right: auto; } } button { background: none; border: none; outline: none; } .global-nav { background: $white; border-bottom: 1px solid $gray-lighter; position: sticky; top: 0; &-list { &-item { flex: 1; padding: 0.6rem 0; text-align: center; } } } .card { background: $white; border-top: 1px solid $gray-lighter; padding: 1rem 0.6rem; &-inner { flex: 1; padding: 0 0.6rem; } >.thumbnail { background: url("http://tsukulog.local/wp-content/uploads/2015/11/bsHIR96_boxneko.jpg") no-repeat; background-size: cover; border-radius: 6px; height: 4rem; width: 4rem; } } .user { &-name { margin: 0; } &-id { font-size: 1rem; font-weight: normal; margin-left: 0.5rem; } } .tweet { &-header { justify-content: space-between; } &-text { margin: 0.3rem 0; } &-img { border-radius: 5px; margin: 1rem 0; overflow: hidden; } &-action { >.btn { margin-right: 1.8rem; } } }

CSSで重要となるクラス名は以下の通りです。

.menu

.menuではメニューのスタイルを指定しています。メニューの幅はオーバーレイの領域を確保するため80%にします。

更に、position: fixed; + left: -80%;で初期状態ではメニューが左端に隠れるようにします。

また、メニューをスライド中にメニュー内の文字や画像が選択されないように-webkit-user-select: none;を指定します。

.menu::afterはメニューを引き出すための領域であり、ここをタッチしながら右へスライドするとメニューが引き出されます。

slidemenu1

.change

.changeはメニューに追加されるとツマミが削除されるクラスであり、メニューが開いたときにJavaScriptで追加します。ツマミを削除しないとオーバーレイに被ってしまい、クリックができません。

.overlay

.overlayではオーバーレイのスタイルを指定しています。初期状態ではopacity: 0;で透明にします。ですが、透明にしただけでは要素が残ったままとなってしまうためdisplay: none;としたいところですが、これを指定しまうとtransitionが効かないので、代わりにvisibility: hidden;を指定します。

JavaScript(TypeScript)を書く

最後にJavaScriptを書いてメニューの操作を可能にします。

始めに以下の順で3つのクラスを作成します。

Menuクラス

メニューに関するMenuクラスを作成します。

class Menu { //メニュー.menu要素の参照 private _elem: HTMLElement = document.querySelector('.menu'); //メニューのborderの幅 private _clientLeft: number = this._elem.clientLeft; private _clientTop: number = this._elem.clientTop; //閉じたメニューの親要素の左端からの相対x座標 private _offsetLeft: number = this._elem.offsetLeft; private _offsetTop: number = this._elem.offsetTop; //開いたメニューの親要素の左端からの相対x座標 private _openOffsetLeft: number = null; //メニューの幅 private _width: number = this._elem.clientWidth; constructor() { //ウィンドウに対してresizeイベントが発生するとresizeメソッドが実行されるようにする window.addEventListener('resize', (e) => this.resize(e)); } //他のクラスから参照するためにgetterで定義 get elem() { return this._elem; } get offsetLeft() { return this._offsetLeft; } get offsetTop() { return this._offsetTop; } get openOffsetLeft() { return this._openOffsetLeft; } set openOffsetLeft(value) { this._openOffsetLeft = value; } get clientLeft() { return this._clientLeft; } get clientTop() { return this._clientTop; } //ウィンドウのリサイズ時にメニューの幅を更新する set width(value) { this._width = value; } get width() { return this._width; } //オーバーレイがクリックされたときにメニューをフェードアウトするメソッド public fadeOut() { //メニューを0.5sかけてフェードアウトさせる this._elem.style.left = ''; this._elem.style.transform = ''; this._elem.style.transition = 'transform, left 0.5s ease'; //フェードアウト後にresetメソッドを実行 setTimeout(() => this.reset(), 500); } //フェードアウト後にメニューに追加されていたクラスやスタイルを全て削除するメソッド public reset() { this._elem.classList.remove('open'); //ツマミを表示 this._elem.classList.remove('change'); this._elem.removeAttribute('style'); } //ウィンドウのリサイズ毎にメニューの幅を再取得するメソッド private resize(e) { this.width = this._elem.clientWidth; } } const menu = new Menu;

このクラスが持つプロパティは.menuの参照elemとメニューの幅widthとそのborderの幅clientLeft(clientTop)、メニューの親要素からの相対座標offseetLeft(offsetTop)、そして開いたメニューのオフセットx値openOffsetLeftです。

またこのクラスはオーバーレイがクリックされたときに自信をフェードアウトするfadeOutメソッドとフェードアウト後にメニューをリセットするresetメソッド、更にウィンドウのリサイズに応じてメニューの幅を再取得するresizeメソッドを持ちます。

Overlayクラス

オーバーレイに関するOverlayクラスを作成します。

class Overlay { //.overlay要素の参照を取得 private _elem: HTMLElement = document.querySelector('.overlay'); constructor() { //オーバーレイがクリックされたときにオーバーレイとメニューがフェードアウトするようにする this._elem.addEventListener('click', (e) => { this.fadeOut(); menu.fadeOut(); }); } get elem() { return this._elem; } //オーバーレイをフェードアウトするメソッド public fadeOut() { //オーバーレイを0.5sかけてフェードアウトさせる this._elem.style.opacity = ''; this._elem.style.transition = "opacity 0.5s ease"; //フェードアウト後にresetメソッドを実行 setTimeout(() => this.reset(), 500); } //オーバーレイとbodyに追加されていたスタイルを削除 public reset() { this._elem.removeAttribute('style'); document.body.removeAttribute('style'); } } const overlay = new Overlay;

このクラスは.overlayの参照elemと自身をフェードアウトするfadeOutメソッド、自身をリセットするresetメソッドを持ちます。

オーバーレイをクリックすると自身とメニューをフェードアウトし、フェードアウト後に自身をリセットします。フェードアウトと同時にリセットを行なってしまうと一瞬でパッと消えてしまいます。なので、setTimeoutメソッドでフェードアウトにかかる時間(500ms)後500msにリセットすることでフェードアウトが行われます。

fadeout

Pointerクラス

メニューの操作に関するPointerクラスを作成します。このクラスが今回最も重要となります。

class Pointer { //.menuをタッチしたときの指の座標 private dx: number = 0; private dy: number = 0; //タッチしたまま動かしているときの指の座標 private mx: number = 0; private my: number = 0; //dx(dy)からmx(my)までの距離 private distanceX: number = 0; private distanceY: number = 0; //ドラッグしているかどうか public static isDragging: boolean = false; //distanceXとdistanceYからなるラジアン private radian: number; //ラジアンから変換された角度 private deg: number; private start: number; private progress: number; //縦スクロールしているかどうか private isScrolling: boolean = false; //横スライドしているかどうか private isSliding: boolean = false; constructor() { //ドラッグ処理 menu.elem.addEventListener('touchstart', (e) => this.down(e)); //menu.elemにするとデスクトップではドラッグできなかった document.body.addEventListener('touchmove', (e) => this.move(e)); document.body.addEventListener('touchend', (e) => this.up(e)); document.body.addEventListener('touchleave', (e) => this.up(e)); /* menu.elem.addEventListener('mousedown', (e) => this.down(e)); document.body.addEventListener('mousemove', (e) => this.move(e)); document.body.addEventListener('mouseup', (e) => this.up(e)); document.body.addEventListener('mouseleave', (e) => this.up(e)); */ } //メニューをタッチしたときに実行されるメソッド private down(e) { //タッチ・マウスイベントの差異を吸収 let event; if(e.type === "mousedown") { event = e; }else { event = e.changedTouches[0]; } this.start = +new Date(); //.menuをタッチしたときの指の座標を取得 this.dx = event.pageX; this.dy = event.pageY; //ドラッグ開始 Pointer.isDragging = true; } //タッチしたまま指を動かしたときに実行されるメソッド private move(e) { //スムーズにメニューがスライドされるようにスクロールイベントを無効 this.on_event(); const now = +new Date(); this.progress = now - this.start; //ドラッグを開始しているときのみ実行 if(Pointer.isDragging) { console.log(`move中!!!!!!!!`); //タッチ・マウスイベントの差異を吸収 let event; if(e.type === "mousemove") { event = e; } else { event = e.changedTouches[0]; } //タッチしたまま動かしているときの指の座標 this.mx = event.pageX; this.my = event.pageY; //dx(dy)からmx(my)までの距離 this.distanceX = this.mx - this.dx; this.distanceY = this.my - this.dy; //メニューが開いているときの処理 if(menu.elem.classList.contains('open')) { //distanceXとdistanceYからなるラジアン this.radian = Math.atan2(-this.distanceY, this.distanceX); this.deg = Math.abs(Math.floor(this.radian * 180 / Math.PI)); //縦スクロールしていないときのみ実行される if(!this.isScrolling) { //水平方向にスライドされたとき if(this.deg > 135 && this.deg <= 180 || this.deg > 0 && this.deg < 45){ //スムーズにメニューがスライドされるようにスクロールイベントを無効 this.on_event(); //縦スクロールではない this.isScrolling = false; //横スライドした this.isSliding = true; } } //横スクロールしていないときのみ実行 if(!this.isSliding) { //縦スクロールされたとき if(this.deg >= 45 && this.deg <= 135) { //スクロールイベントの無効を解除 this.off_event(); //横スライドではない this.isSliding = false; //縦スクロールした this.isScrolling = true; } } console.log(`scrolling: ${this.isScrolling}`); console.log(`sliding: ${this.isSliding}`); //縦スクロールであればmoveメソッドを抜ける if(this.isScrolling) { return; } //横スライドであればdistancecXの変化に応じてメニューとオーバーレイを制御 //distanceXの変化に合わせてメニューが左方向へ動くようにする menu.elem.style.transform = `translate3d(${Pointer.clamp(this.distanceX, menu.offsetLeft, menu.openOffsetLeft)}px, 0, 0)`; //opacityをメニューが閉じるまでに1~0に変化するようにする overlay.elem.style.opacity = `${1 - Math.abs(this.distanceX) / menu.width}`; //メニューが開いているときに更に右へスライドされたときにオーバーレイの変化を防ぐ if(this.distanceX > 0) { overlay.elem.style.opacity = `${1}`; } //閉じているメニューをドラッグしたときの処理 }else{ //メニューの引き出しに応じてオーバーレイをフェードイン //スクロールイベントを無効 e.preventDefault(); //メニューのスクロールバーを表示 menu.elem.style.overflowY = 'auto'; //distanceXの変化に合わせてメニューが右方向へ動くようにする menu.elem.style.transform = `translate3d(${Pointer.clamp(this.distanceX, menu.clientLeft, menu.clientLeft + menu.width)}px ,0,0)`; //メニューが開き切るまでの間にopacityを0~1に変化させる overlay.elem.style.opacity = `${Pointer.clamp(this.distanceX / menu.width, menu.clientLeft, menu.clientLeft + menu.width)}`; //オーバーレイを表示 overlay.elem.style.visibility = 'visible'; } } } //指を離したときに実行される private up(e) { console.log(e.target); console.log(`up!!!!!!!!!!1`); console.log(`ディスタンス: ${Math.abs(Math.floor(this.distanceX))}`); //ドラッグ中のみ実行される if(Pointer.isDragging) { //ドロップした Pointer.isDragging = false; //スクロールイベントの無効を解除 this.off_event(); //開いているメニューから指を離したとき if(menu.elem.classList.contains('open')) { console.log(`openup`); //縦スクロール中に横スライドが発生してもメニューは閉じないようにする if(!this.isScrolling) { //メニューを閉じるのに十分な移動量でなければ if(this.distanceX >= -(menu.clientLeft + menu.width * .5)) { //メニューは閉じない menu.elem.style.transform = ``; overlay.elem.style.opacity = `${1}`; //十分であれば }else { //メニューを閉じる document.body.removeAttribute('style'); menu.elem.removeAttribute('style'); overlay.elem.removeAttribute('style'); menu.elem.classList.remove('open'); menu.elem.classList.remove('change'); } } //閉じていたメニューから指を離したとき }else { console.log(`uuuuuuuuup`); //メニューが開くのに十分な移動量でなければ if(this.distanceX <= menu.clientLeft + menu.width * .5) { //メニューを閉じる menu.elem.removeAttribute('style'); //オーバーレイを非表示 overlay.elem.removeAttribute('style'); //十分な移動量であれば }else { //メニューを開く document.body.style.overflow = `hidden`; menu.elem.style.transform = ``; menu.elem.style.left = `${0}px`; menu.openOffsetLeft = menu.elem.offsetLeft //オーバーレイを表示 overlay.elem.style.visibility = 'visible'; overlay.elem.style.opacity = `${1}`; menu.elem.classList.add('open'); menu.elem.classList.add('change'); } } //リセット this.isSliding = false; this.isScrolling = false; //メニューを閉じた後にツマミをクリックしただけで開かないように this.distanceX = 0; this.distanceY = 0; console.log(`isSliding: ${this.isSliding}`); console.log(`isScrolling: ${this.isScrolling}`); } } //distanceXを制御するためのユーティリティメソッド private static clamp(number, min, max) { return Math.max(min, Math.min(number, max)); } //スクロールイベントを無効にするメソッド private on_event() { document.body.addEventListener('mousemove', this.myHandler, false); document.body.addEventListener('touchmove', this.myHandler, false); } //スクロールイベントの無効を解除するメソッド private off_event() { document.body.removeEventListener('mousemove', this.myHandler, false); document.body.removeEventListener('touchmove', this.myHandler, false); } //何度も呼ばれるためメソッドにしている private myHandler(e){ e.preventDefault(); } } const pointer = new Pointer;

このクラスでは、スワイプでメニューを開閉できるようにしています。

メニューがタッチされると

始めにメニューがタッチされるとdownメソッドが実行され、メニューをタッチしたときの指の座標dx(dy)を取得します。

メニューをタッチしたまま動かすと

次にメニューをタッチしたまま動かすとmoveメソッドが実行され、タッチしたまま動かしているときの指の座標mx(my)と、dx(dy)からmx(my)までの距離distanceX(distanceY)を取得します。

閉じているメニューを開くのであれば現在の位置から右へスライドするようにします。

指の動きに合わせてメニューを動かすにはdistanceXtranslate3dに与えます。更にメニューが必要以上に移動してはいけませんので、clampメソッドで移動範囲を決めてdistanceXを制御します。

menu.elem.style.transform = `translate3d(${Pointer.clamp(this.distanceX, menu.clientLeft, menu.clientLeft + menu.width)}px ,0,0)`;

clampメソッドは以下のようになっています。

private static clamp(number, min, max) { return Math.max(min, Math.min(number, max)); }

同時にメニューの動きに合わせてオーバーレイの透明度を変化させます。透明度はopacityの値を0から1に変化させるので、distanceXmenu.widthで割ってメニューが開くまでの間に0から1に変化するようにします。しかしこのままではdistanceXが1を越えてしまいます。なのでclampメソッドで1を越えないように変化の範囲を制限します。

overlay.elem.style.opacity = `${Pointer.clamp(this.distanceX / menu.width, menu.clientLeft, menu.clientLeft + menu.width)}`;

反対に、開いているメニューを閉じるのであれば現在の位置から左へスライドするようにしますが、それだけではいけません。何故なら開いているメニューは閉じるだけでなく縦スクロールすることもあり、GmailやTwitterアプリのスライドメニューは縦スクロール中はメニューが動かないようになっています。逆も同じで横スライド中は縦スクロールが無効化されます。

この仕様を再現するために次のことを行います。

Math.atan2メソッドでdistanceXdistanceYのラジアンradianを取得し、それを角度degに変換します。

角度が135度より大きく180度以下か0度より大きく45度より小さければ横スライドされたと判断し、on_eventメソッドでスクロールイベントの無効を有効にします。

反対に角度が45度以上135度以下であれば縦スクロールされたと判断し、return;moveメソッドを抜けて横スライドを無効にします。

slidemenu-3

また、上記の判断が同時に行われないための対策として、前者の判断前にはif(!this.isScrolling) {}の条件を、後者の判断前にはif(!this.isSliding) {}の条件を加えます。

※上記の対策を行なってもぐちゃぐちゃに指を動かすと、両方の条件がtrueとなって横スライドと縦スクロールが同時に起こってしまうことが分かりました。解決策が分かり次第追記します。

メニューから指を離すと

メニューから指を離すとupメソッドが実行され、閉じているメニューから指を離したのであればメニューを開くかどうかを決め、開いているメニューから指を離したのであればメニューを閉じるかどうかを決めます。

閉じているメニューから指を離したら

閉じているメニューから指を離したときにメニューが開くのに十分な移動量であればメニューを開き、オーバーレイを表示します。また、このときメニューに.changeクラスを追加してオーバーレイに被っているメニューのツマミ.menu::afterを削除します。

十分な移動量でなければメニューを閉じてオーバーレイを非表示にします。

開いているメニューから指を離したら

開いているメニューから指を離したときにメニューが閉じるのに十分な移動量であればメニューを閉じて、オーバーレイを非表示にします。また、このときメニューから.changeクラスを削除してメニューのツマミ.menu:afterを表示します。

十分な移動量でなければメニューを開いてオーバーレイを表示します。

最後に

以上で、【JavaScript】ネイティブアプリのようなスライドメニューを作る方法を終わります。

参考文献