אנימציות ב React


המעבר לתכנות דקלרטיבי אולי עוזר לפתח ממשקים עם פחות באגים אבל מהווה אתגר רציני לכל מי שרוצה להזיז דברים על המסך. בוובינר זה ראינו איך אנימציות עבדו בדפדפן לפני React, איזה אתגרים יש לנו במעבר ל React ומהם הפיתרונות המקובלים לאתגרים אלה.

1. אנימציות רגילות בדפדפן: Transitions

יחד עם הכניסה של HTML5 ובעיקר CSS3 נכנסו לשימוש אוסף יכולות CSS חדשות ומלהיבות הקשורות לאפקטים ואנימציות: המאפיין transition איפשר לנו כמעט בלי עבודה לגרום לדברים לזוז יפה על המסך, המאפיין transform איפשר לנו לעקם אלמנטים עם כל מיני טרנספורמציות והמאפיין animation אפשר להוסיף אנימציות מדליקות ומתוחכמות הנקראות Keyframe Animation.

התהליך שאיפשר לנו להנפיש מעבר בין שני מצבים עבד כך:

  1. אנחנו מגדירים את המצב הראשוני בתור מאפייני CSS של האלמנט.

  2. אנחנו מגדירים את המצב הסופי בתור מאפייני CSS אחרים של האלמנט.

  3. אנחנו מוסיפים לאלמנט מאפיין בשם transition שמתאר את לוגיקת המעבר בין מצב ההתחלה למצב הסיום.

  4. באמצעות JavaScript אנחנו משנים את האלמנט כך שיתאים ל Selector שהגדרנו במצב הסופי.

אפשר לראות המחשה של המנגנון בקודפן הבא:

שיטת עבודה זו עדיין אפשרית בריאקט אבל לא תמיד מסתדרת עם שיטת החשיבה הריאקטית. נתבונן בדוגמא הבאה למסך בו תפריט הצד יכול להופיע או להסתתר כפונקציה של משתנה סטייט:

import React, { useState, useEffect } from "react";
import "./demo2.css";

export default function CSSTransitionDemo1() {
  const [drawerOpen, setDrawerOpen] = useState(false);

  function toggleDrawer() {
    setDrawerOpen(v => !v);
  }

  return (
    <div className="container">
      {drawerOpen &&
        <div className={`sidebar ${drawerOpen ? 'open' : 'close'} `} >
          <p>I'm some text in the sidebar</p>
          <img src="https://picsum.photos/id/237/200/300"/>
        </div>
      }
      <div className="main">
        <p>Use the button below to open / close the drawer</p>
        <button onClick={toggleDrawer}>Toggle Drawer</button>
      </div>
    </div>
  )
}

בגלל שהאלמנט נכנס ויוצא מה DOM, אי אפשר להשתמש ב transition כדי להנפיש את הכניסה והיציאה שלו. בשביל להוסיף אנימציות לדוגמא נצטרך לשנות את שיטת העבודה:

  1. נשאיר את האלמנט כל הזמן ב DOM.

  2. נשתמש במשתנה הסטייט כדי להחליף קלאסים על אותו אלמנט.

זה עובד אבל עדיין לא מושלם: יש אוסף של Callbacks על אנימציות שב JavaScript היינו רגילים לטפל בהם, למשל כדי לתת עיצוב אחד לאלמנט בזמן האנימציה ועיצוב אחר בסופה, שיהיה לנו קשה לתפוס בכתיבה ידנית של קוד אנימציות וריאקט.

2. הספריה React Transition Group

וכאן אנחנו מגיעים לספריה הראשונה בוובינר, הספריה react-transition-group. ספריה זו היתה פעם חלק מ React אבל היום מתוחזקת בתור פרויקט נפרד. הספריה לוקחת את שיטת העבודה שראינו ונותנת לה מסגרת ריאקטית נחמדה. היא תתן לנו גישה ל Callbacks של האנימציה, ותוסיף באופן אוטומטי קלאס מיוחד שיציין שאנחנו בתוך אנימציה פעילה. הספריה לא תכתוב CSS בשבילנו ולכן השימוש בה מומלץ למתכנתים שכבר מכירים את הרעיון של אנימציות ב CSS.

שימו לב לדוגמא לקוד הבא:

import React, { useState, useEffect } from "react";
import "./demo1.css";
import { CSSTransition } from 'react-transition-group';


export default function CSSTransitionDemo1() {
  const [drawerOpen, setDrawerOpen] = useState(false);

  function toggleDrawer() {
    setDrawerOpen(v => !v);
  }

  return (
    <div className="container">
      <CSSTransition
        in={drawerOpen}
        timeout={300}
        classNames="sidebar"
        onEnter={() => console.log('Element visible')}
        onExited={() => console.log('Element invisible')}
      >

        <div className={"sidebar"}>
          <p>I'm some text in the sidebar</p>
          <img src="https://picsum.photos/id/237/200/300" />
        </div>
      </CSSTransition>

      <div className="main">
        <p>Use the button below to open / close the drawer</p>
        <button onClick={toggleDrawer}>Toggle Drawer</button>
      </div>
    </div>
  )
}

הקוד נראה מאוד דומה לדוגמא שכתבנו לבד, ובעצם ההבדל היחיד הוא קומפוננטת ריאקט חדשה בשם CSSTransition שעוטפת את תפריט הצד שלנו. קומפוננטה זו אחראית על הדבקת שמות הקלאסים המתאימים לפי השלבים של האנימציה.

המאפיין in מקבל משתנה בוליאני לפיו CSSTransition יודע אם האלמנט צריך להיות במסך או לא. במקרה שלנו זה drawerOpen.

המאפיין timeout שומר את הזמן במילי-שניות שהאנימציה תיקח.

המאפיין classNames (שימו לב ל-s בסוף) מחזיק את שם הבסיס לכל הקלאסים ש CSSTransition ייתן לאלמנט שלנו שנמצא בתוכו.

המאפיינים onEnter ו onExit מקבלים callbacks להפעלה כשהאלמנט נכנס או יוצא מהמסך (כלומר כשהאנימציה מסתיימת).

הקוד הזה לבד לא מציג שום אנימציה, ונראה שאפילו לא עושה כלום. עיקר הלוגיקה של התוכנית קורה בכלל ב CSS:

.sidebar {
    width: 300px;
    margin-left: -300px;
    transition: all 0.5s;
}

.sidebar-enter-active {
    opacity: 0.5;
}

.sidebar-enter {
    margin-left: 0px;
}

.sidebar-enter-done {
    margin-left: 0px;
}

הקלאס sidebar הוא שם הבסיס שבחרתי לאלמנט שלי, ובאופן אוטומטי קיבלתי את הקלאסים sidebar-enter, sidebar-enter-active, sidebar-enter-done ואגב בהתאמה יהיו לי גם את sidebar-exit, sidebar-exit-active ו sidebar-exit-done למרות שבקוד שלי לא הוספתי טיפול עבורם.

ל React Transition Group יש גם אופציה לטפל באנימציות על קבוצה של אלמנטים בה אלמנטים חדשים מצטרפים לרשימה ואלמנטים ישנים יוצאים ממנה. נתבונן בקוד הבא:

import React, { useState } from "react";
import { CSSTransition, TransitionGroup } from 'react-transition-group';

import "./demo2.css";

let nextId = 0;

export default function Demo() {
  const [items, setItems] = useState([]);

  function addItem() {
    setItems([...items, { id: nextId, text: `Item ${nextId}`}]);
    nextId += 1;
  }

  function deleteItem(id) {
    setItems(items.filter(item => item.id !== id));
  }

  return (
    <ul className={"items"}>
      <button onClick={addItem}>Add Item</button>
      <TransitionGroup className="todo-list"
      >
        {items.map((item) => (
          <CSSTransition
            key={item.id}
            timeout={500}
            classNames={"item"}
          >
            <li key={item.id}>
              <button onClick={() => deleteItem(item.id)}>x</button>
              {item.text}
            </li>
          </CSSTransition>
        ))}
      </TransitionGroup>
    </ul>
  )
}

הקוד מייצר רשימת פריטים כאשר בלחיצה על כפתור add נוצר פריט חדש ובלחיצה על כפתור המחיקה ליד כל פריט הפריט נמחק. בזכות שמירת כל הפריטים ב TransitionGroup הספריה react-transition-group מצליחה לזהות שאלמנטים חדשים מצטרפים ואלמנטים ישנים עוזבים, והופכת את תהליך העזיבה להדרגתי: כך למרות שאני חושב שהאלמנטים נמחקים מה DOM, בפועל הם יימחקו רק אחרי שהאנימציה תסתיים.

3. הספריה Framer Motion

למרות ש react-transition-group עוזרת לנו לכתוב קוד ריאקט נקי יותר, היא עדיין ספריה יחסית קטנה שזורקת עלינו את רוב העבודה. הספריה Framer Motion היא ספריית אנימציות שלוקחת כיוון אחר: היא משתמשת ב Inline Styles כדי לייצר אנימציות מלהיבות בלי שאנחנו נצטרך לכתוב שורת CSS אחת.

הנה דוגמת הריבוע שהופך לעגול בתרגום ל Framer Motion:

import React, { useState } from "react";
import { motion, useMotionValue } from "framer-motion";

export default function CSSTransitionDemo1() {
  const [isRound, setIsRound] = useState(false);

  function toggleRound() {
    setIsRound(v => !v);
  }

  const style = {
    width: '100px',
    height: '100px',
    background: 'blue',
  };

  return (
    <motion.div
      style={style}
      onClick={toggleRound}
      animate={{ borderRadius: isRound ? '50px' : '0px' }}
    >
    </motion.div>
  )
}

הספריה Framer Motion מביאה איתה אוסף של אלמנטים שכולם מתחילים במילה motion והם אלמנטים שיכולים לזוז. אני לקחתי מכאן את motion.div שזהו div שאפשר להנפיש חלק מהמאפיינים שלו. את בחירת האנימציה עצמה עושה Framer Motion לפי כל מיני היוריסטיקות שלו כדי שיראה יפה (וכן אתם יכולים לשלוט בכל הפרמטרים).

המאפיין animate מקבל אוביקט עם כל ה Properties שצריכים להנפיש, וכל שינוי באוביקט זה בעצם יבוצע באנימציה. במקרה של הריבוע המאפיין שאנחנו רוצים להנפיש את השינוי בו הוא border-radius ולכן זה המאפיין שהעברתי באוביקט ה animate.

ל Framer Motion יש טיפול אוטומטי באלמנטים שנכנסים ויוצאים מהמסך באמצעות אלמנט נוסף שנקרא AnimatePresence. כשאתם עוטפים אלמנט motion ב AnimatePresence אתם יכולים להוסיף מאפיין בשם exit שיקבע את אנימציית היציאה. בקוד דוגמת המגירה שלנו הופכת לזה:

import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import "./demo3.css";

export default function CSSTransitionDemo1() {
  const [drawerOpen, setDrawerOpen] = useState(false);

  function toggleDrawer() {
    setDrawerOpen(v => !v);
  }

  return (
    <div className="container">
      <AnimatePresence>
        {drawerOpen && <motion.div
          className={`sidebar ${drawerOpen ? 'open' : 'close'} `}
          animate={{marginLeft: '0px'}}
          exit={{ marginLeft: '-300px' }}
          initial={{ marginLeft: '-300px' }}
        >
          <p>I'm some text in the sidebar</p>
          <img src="https://picsum.photos/id/237/200/300"/>
        </motion.div>}
      </AnimatePresence>
      <div className="main">
        <p>Use the button below to open / close the drawer</p>
        <button onClick={toggleDrawer}>Toggle Drawer</button>
      </div>
    </div>
  )
}

אנחנו יכולים לחזור לחשוב ב React: להוסיף ולהוריד אלמנטים מהעץ לפי משתנים בוליאנים, ו AnimatePresence ידאג לעשות את ההתאמות כדי להוסיף להם את הקלאסים המתאימים ולעשות את כל השינויים באנימציה.

הטיפול בקבוצות של אלמנטים נראה בדיוק כמו טיפול באלמנט בודד שנכנס ויוצא מהמסך, ולכן דוגמת הפריטים שמתווספים באנימציה נראית כעת כך:

import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";

let nextId = 0;

export default function Demo() {
  const [items, setItems] = useState([]);

  function addItem() {
    setItems([...items, { id: nextId, text: `Item ${nextId}`}]);
    nextId += 1;
  }

  function deleteItem(id) {
    setItems(items.filter(item => item.id !== id));
  }

  return (
    <ul className={"items"}>
      <button onClick={addItem}>Add Item</button>
        <AnimatePresence>
          {items.map((item) => (
            <motion.li
              key={item.id}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0, color: 'red' }}
              transition={{ duration: 0.2 }}
            >
              <button onClick={() => deleteItem(item.id)}>x</button>
              {item.text}
            </motion.li>
          ))}
        </AnimatePresence>
    </ul>
  )
}

הספריה Framer Motion מספקת לנו גישה הרבה יותר React-ית לאנימציות בהשוואה לפיתוח אנימציות לבד או לשימוש ב CSS Transition Group וזו סיבה די טובה להשתמש בה. יחד עם זאת היא דורשת מכם ללמוד קצת יותר תחביר חדש ועשויה להיראות זרה לאנשים שכבר מכירים או כתבו אנימציות ב CSS.