PWA開発者の知るべき
アニメーションの3個

PWA Night vol. 13・
株式会社 Birchill, Brian Birtles

自己紹介

  • 2004~2019
  • SVG WG 2011~
    CSS WG 2015~
  • 2019~

とは…

  • 株式会社Birchill(バーチル)
  • 令和元年6月設立
  • 全員元Mozilla Firefoxエンジニア
  • 現在に至るまでブラウザー開発
    • C++, Rust, JS
  • Webアプリの開発
    • TypeScript, React/Preact, Redux, PouchDB
    • Tailwind CSS

以前…

  • 10 things I wish people knew about Web
      animation 10 things I wish people knew about Web animation birtles.github.io/cssconf2019/ CSSConf 2019 深セン 2019-03-30 🎞 Recording (英語)
  • 10 things I hate about animation (and how to
      fix them) アニメーションについて嫌いな10個 birtles.github.io/mozdev2019/ Mozilla Developer Roadshow 2019-11-11, 2019-11-13 🎞 YouTube (日本語) 🎞 YouTube (英語)

PWA開発者の知るべき
アニメーションの3個

① CSS Transitionを発生させるための「スタイルの変更」


.button {
  background: hsl(77, 86%, 81%);
  transition: background-color .25s,
              transform .15s;
}
.button:hover {
  background: hsl(77, 86%, 91%);
  transform: scale(1.05);
}
  

button.onclick = () => {
  // パネルを作る
  const panel = document.createElement('div');
  panel.classList.add('panel');
  panel.textContent = 'ハロー!';
  parent.appendChild(panel);

  // アニメーションさせる
  panel.style.transform = 'scale(0)';
  panel.style.transition = 'transform .5s';
  panel.style.transform = 'scale(1)';
};
  
ハロー! Frame 1 Frame 2 Style Style display: block; opacity: 0; transform: scale(1.5); display: block; opacity: 1; transform: scale(1.5); Layout Layout 120px 120px Painting Painting
Frame 1 Frame 2 Style Style display: block; opacity: 0; transform: scale(1.5); display: block; opacity: 1; transform: scale(1.5); Layout Layout Painting Painting
Frame 1 Frame 2 Style Layout Painting Script (onclick) Style

  button.onclick = () => {
    const panel = document.createElement('div');
    ...
    panel.style.transform = 'scale(0)';
    panel.style.transition = 'transform .5s';
    panel.style.transform = 'scale(1)';
  };
  

button.onclick = () => {
  // パネルを作る
  const panel = document.createElement('div');
  ...

  // アニメーションさせる
  panel.style.transform = 'scale(0)';
  panel.style.transition = 'transform .5s';
  requestAnimationFrame(() => {
    panel.style.transform = 'scale(1)';
  });
};
  
たまにChromeまたSafariでtransitionが走るけど、Chromeのエンジニアによると、これはChromeのバグです。
Frame 1 Frame 2 Style Layout Painting Script (onclick) request-AnimationFrame Style

  button.onclick = () => {
    const panel = document.createElement('div');
    ...
    panel.style.transform = 'scale(0)';
    requestAnimationFrame(() => {
      panel.style.transform = 'scale(1)';
    });
  };
  

button.onclick = () => {
  // パネルを作る
  const panel = document.createElement('div');
  ...

  // アニメーションさせる
  panel.style.transform = 'scale(0)';
  panel.style.transition = 'transform .5s';
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      panel.style.transform = 'scale(1)';
    });
  });
};
  
Frame 1 Frame 2 Frame 3 Style Layout Painting Script (onclick) request-AnimationFrame Style transformの計算値→ ‘scale(0)’ request-AnimationFrame Style transformの計算値→ ‘scale(1)’

  button.onclick = () => {
    ...
    panel.style.transform = 'scale(0)';
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        panel.style.transform = 'scale(1)';
      });
    });
  };
  

button.onclick = () => {
  // パネルを作る
  const panel = document.createElement('div');
  ...

  // アニメーションさせる
  panel.style.transform = 'scale(0)';
  getComputedStyle(panel).transform;
  panel.style.transition = 'transform .5s';
  panel.style.transform = 'scale(1)';
};
  
Frame 1 Frame 2 Style Layout Painting Script (onclick) Style Script (onclick) contd. transformの計算値→ ‘scale(0)’ transformの計算値→ ‘scale(1)’ Style

  button.onclick = () => {
    const panel = document.createElement('div');
    ...
    panel.style.transform = 'scale(0)';
    getComputedStyle(panel).transform;
    panel.style.transition = 'transform .5s';
    panel.style.transform = 'scale(1)';
  };
  

注意点

  • 以下の場合は計算されたスタイルはない…
    • createElementの直後
    • display: none
  • getComputedStyle(elem)だけだと、スタイルの算出は走らない!❌
    • getComputedStyle(elem).<property>👍
  • 場合によってはgetComputedStyleは重い

const PopupButton = () => {
  const [isShowing, setIsShowing] = React.useState(false);

  const panelRef = React.useRef(null);
  React.useLayoutEffect(() => {
    panelRef.current.style.transform = 'scale(1)';
  }, [isShowing, panelRef.current]);

  const buttonClick = React.useCallback(() => {
    setIsShowing(!isShowing);
  }, [isShowing]);

  return (<>
    <button onClick={buttonClick} />
    {isShowing ? (
      <div ref={panelRef}
        style={{ transition: 'transform .5s', transform: 'scale(0)' }}>
        ハロー!
      </div>
    ) : null}
  </>);
};
  

const PopupButton = () => {
  const [isShowing, setIsShowing] = React.useState(false);

  const panelRef = React.useRef(null);
  React.useLayoutEffect(() => {
    getComputedStyle(panelRef.current).transform;
    panelRef.current.style.transform = 'scale(1)';
  }, [isShowing, panelRef.current]);

  const buttonClick = React.useCallback(() => {
    setIsShowing(!isShowing);
  }, [isShowing]);

  return (<>
    <button onClick={buttonClick} />
    {isShowing ? (
      <div ref={panelRef}
        style={{ transition: 'transform .5s', transform: 'scale(0)' }}>
        ハロー!
      </div>
    ) : null}
  </>);
};
  

② CSS Transitionsが
終わらないこともある


button.onclick = () => {
  // … パネルが表示されていれば、削除する。
  panel.remove();
};
  

button.onclick = () => {
  // … パネルが表示されていれば、削除する。
  panel.style.transform = 'scale(0)';
  panel.addEventListener('transitionend', () => {
    panel.remove();
  });
};
  

もしtransitionの再生中に…

  • 要素が display:none になったら?
  • 要素が再生成されたら?
  • transition-property の計算値が変わったら?
  • 要素が削除されたら?

transitionはキャンセルされて
transitionend は発火されない!

1) Transitionが生成されていない 2) Transitionが終わらない

//...

const buttonClick = React.useCallback(() => {
  if (panelState === 'showing') {
    setPanelState('hiding');
    panelRef.current.addEventListener('transitionend', () => {
      setPanelState('hidden');
    }, { once: true });
  } else {
    setPanelState('showing');
  }
}, [panelState]);

// ...
  

新しいイベント!

  • transitionrun

    → transitionが生成された 🆕

    transitionendを待ってもオッケー 🙆‍♂️

  • transitioncancel

    → 要素が消えた🗑️ (削除されたり、再生成されたり、 display:noneになったり)

    transitionendを待たない方が良い ❌

  • animationcancel transitioncancelと同様)

transitioncancelたち

  • 53+
  • 74+ No animationcancel
  • Tech Preview
  • No animationcancel

Web Animations API
を使ってみてください

Element.animate()


button.onclick = () => {
  // パネルを作る
  const panel = document.createElement('div');
  panel.classList.add('panel');
  panel.textContent = 'ハロー!';
  parent.appendChild(panel);

  // アニメーションさせる
panel.style.transform = 'scale(0)'; getComputedStyle(panel).transform; panel.style.transition = 'transform .5s'; panel.style.transform = 'scale(1)'; panel.animate( { transform: ['scale(0)', 'scale(1)'] }, { duration: 500, easing: 'ease' } );
};
このブラウザーは Element.animate を対応していないようです。

Animation.finished


React.useLayoutEffect(() => {
  let animation;
  if (panelState === 'showing') {
    animation = panelRef.current.animate(
      { transform: ['scale(0)', 'scale(1)'] },
      { duration: 500, easing: 'ease' }
    );
  } else if (panelState === 'hiding') {
    animation = panelRef.current.animate(
      { transform: ['scale(1)', 'scale(0)'] },
      { duration: 500, easing: 'ease' }
    );
    // animationがキャンセルされたらfinishedがrejectされる
    animation.finished.then(() => {
      setPanelState('hidden');
    });
  }
  return () => {
    if (animation) { animation.cancel() }
  };
}, [panelState, panelRef.current]);
  
このブラウザーは Animation.finished を対応していないようです。

CSS Transitions ♡ Web Animations


// transitionend
button.onclick = () => {
  panel.style.transform = 'scale(0)';
  panel.addEventListener('transitionend', () => {
    panel.remove();
  });
};

// Web Animationsのfinished Promise
button.onclick = () => {
  panel.style.transform = 'scale(0)';
  const transition = panel.getAnimations()[0];
  transition.finished.then(() => {
    panel.remove();
  });
});
rgb(255, 0, 0)
rgb(?, ?, ?) rgb(128, 64, 0)
rgb(0, 128, 0)
😧
hsl(0, 100%, 50%)
hsl(60, 100%, 37.5%)
hsl(120, 100%, 25%)

CSSTransition.setKeyframes()


document.addEventListener('transitionrun', evt => {
  if (evt.propertyName !== 'fill') {
    return;
  }

  const transition = evt.target
    .getAnimations()
    .find(animation => animation.transitionProperty === 'fill');

  const keyframes = transition.effect.getKeyframes();
  const hslKeyframes = generateHslKeyframes(
    keyframes[0].fill,
    keyframes[1].fill
  );

  transition.effect.setKeyframes(hslKeyframes);
});
このブラウザーは getAnimationsもしくはCSSTransition.setKeyframesを対応していないようです。

Web Animations

Element.animate() 48+ 36+ 3月?
getAnimations() Nightly, 75+ Experimental Web Platform features, 4月? 3月?

おまけアニメーションできる
ようにDOMを常備する

Single page apps


react.render(
  <Router>
    <Route exact path="/" component={Photos} />
    <Route path="/polls" component={Polls} />
    <Route path="/finder" component={Finder} />
  </Router>
);

Single page apps


<Router>
  <Route render={({ location }) => (
    <TransitionGroup>
      <CSSTransition key={location.pathname}
        timeout={400} classNames="slide">
        <Switch location={location}>
          <Route exact path="/" component={Photos} />
          <Route path="/polls" component={Polls} />
          <Route path="/finder" component={Finder} />
        </Switch>
      </CSSTransition>
    </TransitionGroup>
  )}/>
</Router>

クリックすると…

  1. クリックイベントハンドラー
  2. DOMの要素生成
  3. スタイル算出
    • <CSSTransition> の場合は何度もスタイル算出およびレイアウトの処理をさせる)
  4. レイアウト
  5. 描画(特に新しい要素の領域)
  6. transitionが始まる

What about…


<Router>
  <Route render={({ location }) => (
    <Photos active={location.pathname === '/'}/>
    <Polls active={location.pathname === '/stories'}/>
    <Finder active={location.pathname === '/notes'}/>
  )}/>
</Router>

クリックすると…

  1. クリックイベントハンドラー
  2. DOMの要素生成
  3. スタイル算出
    • <CSSTransition> の場合は何度もスタイル算出およびレイアウトの処理をさせている)
  4. レイアウト
  5. 描画
  6. transitionが始まる
Before (~100ms)
After (~30ms)

まとめ

  • ① CSS Transitionを発生させるためにはスタイルの変更が必要 requestAnimationFrame, getComputedStyle, CSS animations, Element.animate
  • ② CSS Transitionsが終わらないこともある transitioncancel, transitionrun, Animation.finished
  • Web Animations API を使ってみて Element.animate, getAnimations
  • Bonus アニメーションがすぐできるようにDOMを常備する

その他のWebブラウザ最前線

ご清聴ありがとうございました