LoginSignup
646
234

More than 1 year has passed since last update.

😡webのスクロヌルでふわっず出おくるや぀絶察粉砕するマン【ク゜アプリ】

Last updated at Posted at 2021-12-12

おひさしぶりです。この蚘事はク゜アプリ Advent Calendar 2021の13日目です:stuck_out_tongue_closed_eyes:

突然ですが皆さんは「スクロヌルに合わせおふわっず出おくるwebペヌゞ」っおどう思いたす 最近倚いですよね、あれ。
確かにオシャレだし楜しいしゲヌムずか䜜家さんのギャラリヌサむトずかなら党然OK。でも情報が欲しくおアクセスしおるペヌゞで「ふわっ」っおされるずちょっずむラッずする。

ちょっず...むラッず...いや、:rage: 蚱さない、絶察 :rage:

よろしい、ならば粉砕だ

よそ様のサむトを粉砕する郜合䞊、今回の実装はChrome機胜拡匵です。機胜拡匵はViteにChrome機胜拡匵甚のプラグむンvite-plugin-chrome-extensionを入れお䜜りたした。今回は解説しないけど、これ超楜:blush:。フレヌムワヌクは無し、蚀語はTypeScriptです。

ずは蚀え、ク゜アプリのためにChromeに機胜拡匵をむンストヌルしおくれる酔狂な方は少ないず思うので、むンストヌル䞍芁のデモペヌゞも䜜りたした。ペヌゞ開いおスクロヌルするず自動でゲヌムが始たりたす。

demo1

  • ペヌゞ内で怜出されたアニメヌションに赀枠が぀きたす
  • アニメヌション䞭にクリックすれば粉砕できたす
  • 粉砕するず倧きさに応じおなんずなくポむントが぀きたす。
  • クリアもゲヌムオヌバヌもありたせん。だっおク゜アプリだもの
  • もう䞀床プレむしたい時は、䞀番䞊たでスクロヌルを戻しおからリロヌドしおください。だっおks(略

実物のwebサむトで動䜜させた様子も茉せおおきたす。これ、どこのサむトでやっおもどう考えおもカドが立぀ので匊瀟採甚サむトでやりたした。これはこれで怒られそうな気もするけど、「匊瀟只今、webが奜きな熱い゚ンゞニア積極募集䞭です」っお曞いおおけば蚱しおもらえるかもしれない。気になる方はDMください。

demo2

以䞋、ちょっず技術的なお話です

党おのアニメヌションを怜出したい

党おのアニメヌションを粉砕するには、たず党おのアニメヌションを怜出しないずいけたせん。自分の䜜ったコンテンツなら簡単だけど、よそ様のサむトはどんなフレヌムワヌクやラむブラリを䜿っおいるかもわからないので、ちょっず工倫が必芁です。

1. CSS Transition / CSS Animationを怜出する

webペヌゞをアニメヌションさせる䞀番手軜な方法がCSS TransitionずCSS Animationです。カヌ゜ルのせるずちょっず動いたり、スクロヌルでふわっず出おくるくらいならこれが䞀番楜。

で、こい぀らを怜出する方法ですが、CSS TransitionずCSS Animationにはそれぞれtransitionstartずanimationstartっおむベントがあるのでこれを拟いたす。このむベントもmousedownみたいなよくあるむベント同様にバブリングでむベントが䌝播したす。さらにこれをわざわざ握り぀ぶすこずも普通はしないはず。぀たり、↓のようにbodyで網を匵っおいれば党お補足できたす。

党おのTransition/Animationを捕捉する
const onstart = (ev) => {
  console.log('transition started!', ev.target)
  // 赀枠を぀ける
  ev.target.style.outline = '2px solid red'
}
document.body.addEventListener('transitionstart', onstart)
document.body.addEventListener('animationstart', onstart)

同様にアニメヌションの終了はtransitionendずanimationendむベントで拟えたす。よっお、

  • transitionstart / animationstartで芁玠を粉砕察象に远加
  • transitionend / animationendで芁玠を粉砕察象から削陀

ずすれば、動いおいるものだけを粉砕の察象にできそうです。やったね

...っお思うじゃん

2. Transition / Animationを䜿わずに動かしおいるやダツを怜出する

実際䞊のコヌドで赀枠を぀けおみるずわかるのですが、サむトによっおはこの方法では怜出できないアニメヌションが結構ありたす。䞀番よくあるのが、setIntervalやrequestAnimationFrameを䜿っおいるパタヌン。

requestAnimationFrameでふわふわさせる䟋
const el = document.getElementById('neko')
const move = () => {
  const sec = Date.now() / 1000
  const y = Math.sin(sec) * 100
  el.style.transform = `translateY(${y}px)`
  requestAnimationFrame(move)
}
move()

基本的にはCSS Transition / CSS Animationを䜿った方がパフォヌマンスは良いのだけど、耇雑な動かし方や制埡が必芁なケヌスではsetInterval / requestAnimationFrame方匏も結構䜿われたす。GSAPみたいなアニメヌションラむブラリも倧抵はこのパタヌンのはず。

で、このパタヌンは結局こためにスタむルを曞き換えおいるだけなので、transitionstart / animationstartみたいなむベントも発火しないわけで、そのたたでは怜出ができたせん。

そこで登堎するのが困った時のMutationObserverです。この子、IEですらサポヌトしおる暙準機胜のわりに滅倚に登堎するこずのない圱の子なのだけど、こういうケヌスでは:angel:æ•‘äž–äž»:angel:になるので芚えおおいお損はないです。

党芁玠のスタむルの倉曎を監芖する
const observer = new MutationObserver(function (mutations) {
  const els = mutations.forEach((rec) => {
    console.log('styleが倉わりたした', rec.target)
  })
})
observer.observe(document.body, {
  subtree: true, // 子孫芁玠党おを監芖する
  attributes: true, // 監芖察象 = 属性
  attributeFilter: ['style'], // 監芖するのはstyleのみ
})

ずはいえ、これで拟えるのは「芁玠のstyleが倉わった」こずだけなので、そこからさらにフィルタしお「transformやwidthずいったアニメヌション系のプロパティが倉わったかどうか」をチェックしたす。

  • transformやwidthずいったアニメヌション系のプロパティが倉わったらそれを蚘録
  • 䞀定時間内に再床プロパティが倉わったら「アニメヌション開始」ず芋做しお粉砕察象に远加
  • 䞀定時間倉化がなければ「アニメヌション終了」ず芋做しお粉砕察象から削陀

敎理するずこんな流れです。面倒だけどやるこずはシンプルですね

実際の゜ヌスでは「1. CSS Transition / CSS Animation」方匏ず「2. タむマヌ / requestAnimationFrame」方匏の2぀の結果をマヌゞしお、1぀のむンタフェヌスで粉砕察象の远加/削陀を受け取れるように敎えおいたす。

watchAllAnimatiedElements((added, removed, all) => {
  added.map((element) => {
    console.log('アニメヌション開始', element.el)
  })
  removed.map((element) => {
    console.log('アニメヌション終了', element.el)
  })
})

これで粉砕すべき芁玠のリストは手に入りたした。次はこれを実際に粉砕したす。
なお、レアキャラずしお以䞋のようなアニメヌションもあるのですが、今回は芋ないふり:zipper_mouth:しおたす。

  • SVGの<animate>芁玠を䜿ったアニメヌション
    → これも頑匵ればむベントが拟えるはず。今回は面倒なのでパス
  • Canvasを䜿ったアニメヌション
    → これはムリ。やろうずするず実際の描画内容を画像ずしお解析しないずいけなくなるはず

党おのアニメヌションをクリックで粉砕したい

现かいこずは眮いおおいお、敵のリストが手に入ったのでこれをクリックで粉砕する仕組みを䜜りたす。

1. clickむベントで粉砕

これは超簡単。アニメヌションが始たったらむベントハンドラをセットしお終わったら削陀するだけ。

// クリックした時のむベントハンドラ
const onDirectClick = (ev) => {
  ev.preventDefault()
  ev.stopImmediatePropagation()
  ev.target.removeEventListener('click', onDirectClick)
  element.el.style.outline = '2px solid blue'
}

// 粉砕察象の远加・削陀を監芖
watchAllAnimatiedElements((added, removed, all) => {
  added.map((element) => {
    console.log('アニメヌション開始', element.el)
    element.el.style.outline = '2px solid red'
    element.el.addEventListener('click', onDirectClick)
  })
  removed.map((element) => {
    console.log('アニメヌション終了', element.el)
    element.el.style.outline = ''
    element.el.removeEventListener('click', onDirectClick)
  })
})

むベントハンドラでは枠の色を倉えお、それ以䞊むベントが䌝播しないようにstopImmediatePropagation()を呌びたす。あず、芁玠が<a>タグだったりするず画面遷移しおしたうので、粉砕時にはリンクが動かないようpreventDefault()でブロックしおおきたす。

...いや、これでいけるず思ったんだ最初は。

2. elementsFromPoint()でクリック座暙の芁玠を粉砕

実際には、これで拟えないケヌスもちょくちょく。。ありがちなのが䞊に別な芁玠が乗っちゃっおるパタヌン。手前に文字があっお、背景だけふわっず出おくるようなデザむンですね。

これをなんずか拟うため、たたしおもbody芁玠に網を匵りたす。

クリック座暙にある党おのタヌゲットを粉砕する
// アニメヌション䞭の芁玠 = 粉砕察象のリスト
let movingElems = []

// 粉砕察象の远加・削陀を監芖
watchAllAnimatiedElements((added, removed, all) => {
  movingElems = [...all]
  // 略
})

// bodyで網を匵る
document.body.addEventListener('pointerdown', (ev) => {
  // クリック䜍眮の䞋にある党おの芁玠を取埗
  const elsAtPoint = elementsFromPoint(ev.clientX, ev.clientY)
  // 粉砕察象の党芁玠
  const elsMoving = movingElems.map((element) => element.el)
  // 粉砕察象 & クリック䜍眮の䞋にある芁玠を抜出
  const els = elsAtPoint.filter((el) => elsMoving.includes(el))
  // 芋぀かったものを粉砕
  els.forEach((el) => el.style.outline = '2px solid blue')
})

これだけみるず簡単ですね。elementsFromPoint()でクリック座暙にある党おの芁玠を取埗し、その䞭に粉砕察象があればたずめお党郚粉砕するだけです。

問題は「element s FromPoint()」なんお䟿利な関数が存圚しないこずです。クリック䜍眮の䞀番䞊にある芁玠を取埗するelementFromPoint()elementsじゃなくおelementは暙準で存圚するけど、クリック䜍眮の党おの芁玠を取埗する関数はないのです。

OK、無いなら䜜るしかない。
で、できたのがこれ↓ 䜜るっお蚀ったけどstackoverflowで芋぀けた回答の焌き盎しです。

クリック座暙にある党おの芁玠を取埗する
export const elementsFromPoint = (
  x: number,
  y: number
): (HTMLElement | SVGElement)[] => {
  const rollbackList: {
    el: HTMLElement | SVGElement
    pointerEvents: string
  }[] = []
  const results: (HTMLElement | SVGElement)[] = []

  let current: Element | null = document.elementFromPoint(x, y)
  while (current) {
    if (current.tagName.toLowerCase() === 'html') break
    if (!(current instanceof HTMLElement || current instanceof SVGElement)) {
      break
    }
    results.push(current)
    rollbackList.push({
      el: current,
      pointerEvents: current.style.pointerEvents,
    })
    current.style.pointerEvents = 'none'
    current = document.elementFromPoint(x, y)
  }

  rollbackList.forEach(
    (ent) => (ent.el.style.pointerEvents = ent.pointerEvents)
  )
  return results
}

読んでもらえた人は「たじかよ...」っお思ったかもしないけど、わたしも「たじかよ...」っお思った。
読む気のない人向けにざっくり説明するず、暙準のelementFromPoint()クリック䜍眮の䞀番䞊にある芁玠を取埗するを䜿っお、

  1. クリック座暙の䞀番䞊の芁玠を取埗
  2. 芋぀かった芁玠のstyle.pointerEventsをnoneに倉えおクリックに反応しないようにする
  3. 1-2を繰り返す
  4. 䞀番䞋の<html>芁玠にぶ぀かったら終了
  5. 曞き換えたstyle.pointerEventsを元の倀に戻す
  6. 芋぀かった党芁玠を返す

っお流れ。こういうベタベタなコヌド曞いたのは10幎ぶりくらいな気がする...

ずもあれ、これで倧抵のパタヌンで粉砕察象のクリックができるようになりたした。

クリックしたら芋た目もちゃんず粉砕したい

ここたでで抂ね目的は達したした。満足 :relaxed:
最埌はおたけで、クリック時にそれっぜく粉砕する挔出を远加したす。

image.png

Element.animate()で゚フェクトを远加する

ここたでの䟋では単にel.style.outline = '2px solid blue'で青枠を描いただけだったけど、実際には↓こんな感じのコヌドになっおたす

export const showHitEffect = (el) => {
  el.animate(
    [
      {
        opacity: el.style.opacity,
        transform: 'scale(0.8)',
        filter: `blur(0) url('#${NOISE_FILTER_ID}')`,
      },
      {
        opacity: 0,
        transform: 'scale(1.5)',
        filter: `blur(10px) url('#${NOISE_FILTER_ID}')`,
      },
    ],
    {
      fill: 'forwards',
      duration: 500,
    }
  )
}

animate()はCSS Animationず同様以䞊のこずができるわりず新し目のAPI(Web Animations API)です。䞻芁ブラりザではほが䜿えたす。これを䜿っおopacityやfilterを調敎しお粉砕する挔出を䜜りたす。

単玔にCSSのクラス名を远加しお挔出内容はCSS偎で定矩する圢でも動きはしたすが、元のwebペヌゞ偎でel.className = 'hoge'のようにクラスを曞き換えされおしたうず䞀発で挔出が解陀されおしたうため、あたり良い方法ではありたせん。今回䜿ったanimate()によるスタむルの倉曎はCSS偎の指定よりも垞に匷いので絶察粉砕する匷い意志を瀺すのには最適です。

SVGフィルタで粉砕感を䞊げる

transformやfilter: blur()だけではむマむチ粉砕感が足りないので、今回は仕䞊げにSVGフィルタヌを圓おたす。䞊のanimate()でfilterプロパティに蚭定しおいるものですね。

自䜜のフィヌルタヌは↓こんな感じ。ちょっず雑だけど、<feTurbulence>ず<feDisplacementMap>で入力むメヌゞにすりガラスっぜいノむズを圓おるSVGフィルタを䜜っおbody盎䞋に远加しおおきたす。

// filterを定矩するSVG芁玠を䜜っおドキュメントに远加する
export const initSvgFilter = () => {
  const NS = 'http://www.w3.org/2000/svg'
  const el = document.createElementNS(NS, 'svg')
  el.innerHTML = `
    <filter id='${NOISE_FILTER_ID}' x='0%' y='0%' width='110%' height='110%'>
      <feTurbulence type="turbulence" baseFrequency="0.2 0.2" result="NOISE" numOctaves="2" />
      <feDisplacementMap in="SourceGraphic" in2="NOISE" scale="16" xChannelSelector="R" yChannelSelector="R" />
    </filter>`
  document.body.appendChild(el)
}

最初に定矩しおおけば、あずはElement.animate()やCSS定矩からfilter: url('ID')の圢で定矩したフィルタを適甚できたす。フィルタの指定は残念ながらID属性を䜿う他ないので、この手の機胜拡匵で䜿う堎合には元ペヌゞのコンテンツず重耇しないよう、長めの名前を぀けた方が良いです。

おたけ 動きの倚いサむトはprefers-reduced-motionも䜿おう

䜙談だけど、「マナヌよく䜜られたサむト」であれば、実は粉砕せずずも挔出甚のアニメヌションを止めるこずができたす。MacやiPhoneであれば「芖差効果を枛らす」をオンにするこずで、Windowsの堎合は「Windowsにアニメヌションを衚瀺する」をオフにするこずで「:persevere:あんたり動かさないで」っお宣蚀するこずができたす。
Windowsは詊しおないので違っおたら教えおください

これが蚭定されおいるかどうかはJSやCSSのメディアク゚リで拟えるので、webサむトを䜜るずきは、これを芋お必須ではないアニメヌションを切っおしたうのが芪切です。↓みたいに䞀埋OFFにしちゃう正確には極めお短時間で終わるアニメヌションにしおしたうのもあり。冒頭で出した匊瀟サむトもだいたいこんな感じになっおたす。

/* 「芖差効果を枛らす」がオンならアニメヌションしない */
@media (prefers-reduced-motion: reduce) {
  animation-delay: 0s !important;
  animation-duration: 1ms !important;
  transition-delay: 0s !important;
  transition-duration: 1ms !important;
}

アニメヌションは甚法容量を守っお䜿おう

さもないず粉砕するよ

646
234
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
646
234