はじめに
こんにちは。CMA制作部スタッフNyaayamaです。
PCでは完璧に動いているのに、iPadで見ると挙動がおかしくなる現象に遭遇した経験はあるでしょうか?
私は先日実装中に、スクロールに連動するパララックス(視差効果)を実装した際、背景に隠れているはずのコンテンツがスクロールするとiPadの実機でちらつく、という現象に遭遇しました。
深掘りしてみると、iOS/iPadOS特有のレンダリング事情が見えてきたので、備忘録として共有します。
【現象】
スクロールすると、背景に隠れているはずのコンテンツがちらつく
※実際に実機で起きましたが、今回は同じ現象をMacのSimulatorで再現しています。(iPad : iOS18.3)
【Before】バグが発生していた時のコード
直感的に「画面に入ったらスクロールイベントを開始する」という実装です。
const lastCircle = document.querySelector('.c-last_circle');
const items = document.querySelectorAll('.js-parallax ._pho');
let parallaxActive = false;
let sectionTop = 0;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// 画面に入ったら発動
if (entry.isIntersecting && !parallaxActive) {
parallaxActive = true;
// 基準位置を取得
sectionTop = lastCircle.getBoundingClientRect().top + window.scrollY;
window.addEventListener('scroll', () => {
const scrollY = window.scrollY - sectionTop; // 基準からのスクロール量
// スマホ
const isSP = window.innerWidth <= 768;
const speed = isSP ? 0.1 : 0.2;
items.forEach((img, index) => {
const direction = (index % 2 === 0) ? -1 : 1;
const move = scrollY * speed * direction;
img.style.transform = `translateY(${move}px)`;
});
});
}
});
}, {
root: null,
threshold: 0 // 50%見えたら発動(0なら1pxでも発動)
});
observer.observe(lastCircle);
この実装の問題点
PCやPCのデベロッパーツールでは特段問題がないように見えましたが、iPadの実機で検証すると問題が発生しました。
このコードの何が悪かったのでしょうか。
① イベントの重複登録
removeEventListener がないため、条件によってはスクロールリスナーが重なって登録され、処理が競合する可能性がありました。
② レンダリングサイクルの無視
スクロールイベントの発生頻度と、ブラウザの描画(リフレッシュレート)が同期しておらず、iPadの高リフレッシュレート環境で計算が追いつかなくなっていました。
③ CPUへの負荷
translateY による2D変形はCPU負荷が高くなりやすく、iOS Safariでは描画のスキップ(コマ落ち)を招きます。
【After】改善後のコード
function onScroll() {
latestScrollY = window.scrollY;
if (!ticking) {
// ブラウザの描画タイミングに合わせる
requestAnimationFrame(updateParallax);
ticking = true;
}
}
function updateParallax() {
const scrollY = latestScrollY - sectionTop;
const speed = window.innerWidth <= 768 ? 0.1 : 0.2;
items.forEach((img, index) => {
const direction = index % 2 === 0 ? -1 : 1;
const move = scrollY * speed * 0.2 * direction;
// translate3dでGPU加速を有効にする
img.style.transform = `translate3d(0, ${move}px, 0)`;
});
ticking = false;
}
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting && !parallaxActive) {
parallaxActive = true;
sectionTop = lastCircle.getBoundingClientRect().top + window.scrollY;
window.addEventListener('scroll', onScroll, { passive: true });
} else if (!entry.isIntersecting && parallaxActive) {
// セクションから離れたら停止
parallaxActive = false;
window.removeEventListener('scroll', onScroll);
}
});
}, { threshold: 0 });
なぜこれで直ったのか?
① requestAnimationFrameの活用
スクロールイベントのたびに計算するのではなく、ブラウザが「次の画面を描画する準備ができた」瞬間に処理を実行します。これにより、iPad Proなどの画面でも滑らかに同期します。
② translate3d による描画の最適化(GPU活用)
translateY ではなく translate3d(0, 0, 0) を使うことで、ブラウザに「この要素はGPU(ハードウェア)で処理して!」と明示的に伝えます。iOS Safariにおいて、描画のチラつきを抑えるための鉄板の手法だそうです。
③ イベントリスナーの適切なライフサイクル管理
IntersectionObserver を使い、画面外にいるときは removeEventListener で処理を完全に停止させます。メモリの節約だけでなく、予期せぬ挙動の防止に直結します。
まとめ
今回のバグの背景にはブラウザ(特にモバイルSafari)のレンダリングの仕組みが深く関わっていました。
・アニメーションは requestAnimationFrame で描画を予約する
・iOS向けの動きには translate3d でGPUに対応する
この2点を守るだけで、iPadでのUXは劇的に向上します。
そして改めて、実機での確認は必須だと思いました。
同じ現象で悩んでいる方の参考になれば幸いです!
