posted: 2019/11/04

React Hooksを利用してCSS Grid上の要素をアニメーションする

CSS Gridは本来grid-rowgrid-columnsはアニメーション対象ではなく、Grid上を移動するようなものをそのまま作るのは難しい。
今回計算用の疑似要素を配置することでアニメーションさせる方法を見つけたのでメモする。
今回はUpLabsのSmoothBottomBarを再現してみた。

作り方

1. まずは基礎部分用意

最初は素の感じでGridを作る。Iconの利用だけ配列を使っているところだけ若干トリッキーかもしれない
主要なコードとしてはこんな感じ。今回はreact-iconsを利用した。いろんなアイコン盛り合わせでとても良かった
import { FiUser, FiHome, FiInbox } from "react-icons/fi"

export const Menu1 = () => {
  const icons = [FiHome, FiInbox, FiUser]

  return (
    <Container>
      <MenuGrid>
        {icons.map((Icon) => (
          <IconWrap>
            <Icon />
          </IconWrap>
        ))}
      </MenuGrid>
    </Container>
  )
}

2. 計算用のCellを追加

次に計算用のCellを追加する。動くとこんな感じになる。
2
この辺からhooksが出てくる。
まず先程のiconをGridのCellとして配置するように書き換える。
const Item = styled.div`
  ${({ x }) => css`
    grid-column: ${x};
  `}
  grid-row: 1;
`

export const Menu2 = () => {
  const [gridPosition, setGridPosition] = useState<number>(1)

  // ...
        {icons.map((Icon, i) => (
          <Item x={i + 1} onMouseOver={(e) => setGridPosition(i + 1)}>
            <IconWrap>
              <Icon />
            </IconWrap>
          </Item>
        ))}
  // ...
}
そして計算用のGridCellも配置する。今回はPositionCalcuratorと名付けた。
// これもItemを継承しているので、colとrowが設定される
const PositionCalcurator = styled(Item)`
  border: 1px solid red; // debug用のborder
`

export const Menu2 = () => {
  const [gridPosition, setGridPosition] = useState<number>(1)
  const calcuratorRef = useRef<HTMLElement>(null)

  const icons = [FiHome, FiInbox, FiUser]

  return (
    <Container>
      {/* ... */}
        <PositionCalcurator ref={calcuratorRef} x={gridPosition} />
      {/* ... */}
    </Container>
  )
}
ここまで来るとこんな具合に動く様子が確認できる

3. カーソル部分を追加

計算用のセルにカーソルのセルを追加してこれをアニメーションさせる。
3
2で配置した計算用refsから今度はuseEffectで計算用のcellの矩形を取得する。
この結果はcursorRectと名付けたstateへ格納する。
また、effectのdependencyとしてgridPositionを利用している(が、もしかしたらこれはeslint-plugin-react-hooksに怒られるかもしれない)
export const Menu3 = () => {
  const [gridPosition, setGridPosition] = useState<number>(1)
  const [cursorRect, setCursor] = useState<null | Rect>(null) // append
  const calcuratorRef = useRef<HTMLElement>(null)
  useEffect(() => {
    if (!calcuratorRef.current) return
    const top = calcuratorRef.current.offsetTop
    const left = calcuratorRef.current.offsetLeft
    const width = calcuratorRef.current.clientWidth
    const height = calcuratorRef.current.clientHeight
    const cursor = { top, left, width, height }
    setCursor(cursor)
  }, [gridPosition])
  
あとはこの矩形をCursorと名付けたコンポーネントに与えて描画させる。
position:absolute;にしたGridから独立した要素なのでアニメーションができる
const Cursor = styled.div`
  position: absolute;
  transition: 0.4s;
  transition-timing-function: ease-in-out;
  background: green;
  opacity: 0.5;
  ${({ top, left, width, height }) => css`
    top: ${top}px;
    left: ${left}px;
    width: ${width}px;
    height: ${height}px;
  `};
`
export const Menu3 = () => {
  const [cursorRect, setCursor] = useState<null | Rect>(null) // append
  
  // ...
  return (
    <Container>
      {/* 先頭に置く! */}
      {cursorRect && <Cursor {...cursorRect} />}

4. アイコンに文字を出す

ほぼこれで完成に近いが、あとは本家に習って文字を出すのも追加してみる。
4
こんな具合でanimationさせる要素を追加するだけになる。
hoverの疑似クラスだけだとカーソルと合わなくなるため、activeというpropsを利用することにした。
export const Menu4 = () => {
  const [gridPosition, setGridPosition] = useState<number>(1)
  const isActive = useCallback((x) => gridPosition === x, [gridPosition])
  const icons = [["Home", FiHome], ["Inbox", FiInbox], ["Profile", FiUser]]
  return (
    {/* ... */}
    {icons.map(([text, Icon], i) => (
      <Item x={i + 1} onMouseOver={(e) => setGridPosition(i + 1)}>
        <AnimateIcon
          x={i + 1}
          onMouseOver={(e) => setGridPosition(i + 1)}
          text={text}
          active={isActive(i + 1)}
        >
          <Icon />
        </AnimateIcon>
      </Item>
    ))}
  )
AnimateIcon周りはこんな感じ
const AnimateIconInner = styled(IconWrap)`
  transition: 0.5s;
  ::after {
    font-size: 0.6em;
    transition: 0.5s;

    overflow: hidden;
    content: attr(data-text);
    ${({ active }) => css`
      width: ${active ? "100%" : "0px"};
    `}
  }
`

const AnimationContainer = styled.div`
  width: auto;
`

const AnimateIcon = ({ x, onMouseOver, active, children, text }) => {
  return (
    <Item x={x} onMouseOver={onMouseOver}>
      <AnimateIconInner active={active} data-text={text}>
        <AnimationContainer>{children}</AnimationContainer>
      </AnimateIconInner>
    </Item>
  )
}

5. 諸々調整する

あとは色々とパラメータを調整したバージョンが下記になる。
5

まとめ

CSS Gridのアニメーションについて考察してみた。やはりどうしてもhooksに多くの部分を頼ってしまうことにはなるが、Flexなどでやるよりは等間隔であることを保ちやすかったり、素のjavascriptですべて計算するよりはメリットが大きそうな感覚がある。
ちょっと他にも3x3のロックマンっぽいおパターンなども作ってみた。
https://amination-grid-menu.netlify.com/
色々応用も効いて楽しいのでおすすめしたい。
Edit on Github
|