🤖

Next.jsでページ遷移アニメーション〜popstateでもスクロール位置を復元したい〜

2023/01/12に公開

はじめに

next.js案件をすることになりテンプレをつくっているのですが、ページ遷移アニメーションの実装でちょっとつまづいたので、備忘録として残しておこうと思います。
※実装途中なので、随時更新予定。

Linkでアニメーションを呼び出す

まずはLinkコンポーネントで、クリックイベントにfalseを返すことで遷移を中断して、ページ遷移アニメーションを呼び出します。

export function TransitionLink({ href, children, className, isAnim = true }) {
   const router = useRouter();

   const handleClick = async (e) => {
      if (!isAnim) return;
      e.preventDefault();
      pageTransAnimCallbyNorm(router, href);
   };

   return (
      <Link className={className} href={href} onClick={handleClick}>
         {children}
      </Link>
   );
}

ページ遷移のアニメーションはGSAPのタイムラインにしてLeaveアニメーションとEnterアニメーションでそれぞれpromiseを返すようにします。

export const pageTransAnimCallbyNorm = async (router, url) => {
   //ノーマル遷移
   await TLOBJ.pageLeaveTL.restart();
   await router.push(url);
   await TLOBJ.pageEnterTL.restart();
};

ページ遷移アニメーションの設定

TL自体はページ遷移アニメーションコンポーネントをつくって、レンダリングされたタイミングでrefをTLにAddする感じにしました。ページによってアニメーションを変えたかったりする場合はTL自体を別にして呼び出し関数を別でつくればいいんじゃないかなと思います。

export function TransitionLayout({ children }) {
   const footer = useRef();
   const main = useRef();

   useEffect(() => {
      /*===============================================
	タイムライン設定
	===============================================*/
      const target = [main.current, footer.current];
      TLOBJ.pageLeaveTL.add(pageLeaveAnim(target, false));
      TLOBJ.pageEnterTL.add(pageEnterAnim(target, false));
      //メニュー開いた状態で遷移する時だけeaseを変えたい
      TLOBJ.pageLeaveTLinMenu.add(pageLeaveAnim(target, true));
   }, []);

   return (
      <>
         <div className={style.wrapper}>
            <Header />
            <main ref={main} className={style.main}>
               {children}
            </main>
            <Footer ref={footer} />
         </div>
      </>
   );
}

popstateに対応したい。。

ここまででページ遷移のアニメーション自体はできたのですが、popstateの時に問題があって

問題点

  • 遷移アニメーションの最初にscroll位置でrestrationされることで一瞬トップにもどっちゃう
  • Linkのscrollをfalseにするとスクロール位置を保存できない

やりたいのは

  • popstate時も遷移アニメーションを発火させたい
  • Leaveアニメの時はスクロール位置固定で、enter後に記憶した位置に戻したい
  • 前と次を判定してそれぞれ位置を復元させたい

そこでやったのは

① router.beforePopstateにfalseを返す

router.beforePopstateにfalseを返すことでLink同様遷移をpreventすることができます

export function MenuStateProvider({ router, children }) {
   //メニューの開閉stateと更新用関数
   const [isMenuOpen, menuOpenToggle] = useReducer(
      (isMenuOpenState) => !isMenuOpenState,
      false
   );
   /*===============================================
	isMenuOpenが更新されるたびに更新
	===============================================*/
   useEffect(() => {
      // popstateでrouterにfalseを返す
      router.beforePopState(({ url }) => {
         //menu open時は smMenuToggle を待つ
         pageTransAnimCallbyPopstate(router, url, isMenuOpen);
         return false;
      });
   }, [isMenuOpen]);

   return (
      <>
         <MenuStateContext.Provider value={[isMenuOpen, menuOpenToggle]}>
            {children}
         </MenuStateContext.Provider>
      </>
   );
}

※ハンバーガーメニューの開閉のstateを子孫に受け渡したかったのでcontextにしています。あとメニュー開いた状態でpopstateした時のためにrouter.beforePopStateイベントを再設定してます。

② history.scrollRestorationをmanualにする

manualにすることでデフォルトのスクロール位置保存を無効にします。これで一瞬トップに戻るのは回避できます。

//popstateイベントを登録
   window.addEventListener("popstate", () => {
      isPopstate = true;
      //スクロール位置の復元を禁止※leaveアニメーションの発火前に復元してしまうため
      if (history.scrollRestoration) {
         history.scrollRestoration = "manual";
      }
   });

③ルートの変更タイミングでscrollYを取得する

routeChangeStartでscrollYの取得して、routeChangeCompleteのタイミングで保存した位置をreturnする

router.events.on("routeChangeStart", handleRouteChangeStart);
router.events.on("routeChangeComplete", handleRouteChangeComplete);
/*===============================================
変数
===============================================*/
let isPopstate = false;
let scrollY = 0;

/*===============================================
ルート変更開始時にscrollYを取得
===============================================*/
export const handleRouteChangeStart = () => {
   scrollY = window.pageYOffset || document.documentElement.scrollTop;
};

/*===============================================
ルートの変更終了時点で次のscrollYを受け取る
===============================================*/
export const handleRouteChangeComplete = () => {
   const yPos = memoryYPos(history.state.key, isPopstate, scrollY);
   window.scrollTo({ top: yPos });
   isPopstate = false;
};

④scrollYを保管する

前と次の位置をそれぞれ配列に入れて保管しています。
前と次の判定がデフォルトでできないっぽかったので、history.state.keyでhistoryのkeyを取得して、判定してみました。

const backPosY = [];
const forwardPosY = [];
export const keysArr = [];
let nextKey;

/*===============================================
スクロール位置の記憶
===============================================*/
const memoryYPos = (key, isPopstate, scrollY) => {
   /********************
	ノーマル遷移
	********************/
   if (isPopstate === false) {
      backPosY.push(scrollY);
      keysArr.push(key);
      return 0;
   }
   /********************
	popstate
	********************/
   if (keysArr[keysArr.length - 2] === key) {
      //戻る
      forwardPosY.push(scrollY);
      nextKey = keysArr.pop();
      return backPosY.pop() ?? 0;
   } else if (key === nextKey) {
      //進む
      backPosY.push(scrollY);
      keysArr.push(key);
      return forwardPosY.pop() ?? 0;
   } else {
      //0を返す
      return 0;
   }
};

export { memoryYPos };

まとめ

一応動いてますが、まだ実務で試してないので、さらに検証してみたいと思います。
フレームワーク上でぶっとんだアニメーションとかしようとすると、結構無理やりするしかないのかな。。?

Discussion