본문 바로가기

FrontEnd

프레이머 모션[framer motion] 기초 2부 : 레이아웃 애니메이션

반응형

이번에 배울 것

여러 컴포넌트에 애니메이션 전파하기

복잡한 레이아웃 에니메이션 오케스트레이션(조정 및 통합)

 

Propagation (애니메이션 전파)

부모 컴포넌트의 variant 변경을 자식 컴포넌트에 알림

 

  • 카드와 레이블을 덮고 있는 "보이지 않는" 모션 레이어가 있습니다. 이 레이어는 "hover" variant를 설정하는 whileHover 소품을 보유합니다.
  • "글로우" 자체도 컴포넌트이지만 정의하는 유일한 것은 호버 키가 있는 자체 variant 객체입니다.
  • 보이지 않는 레이어가 "hover"되면 hover variant를 토글하고 hover variant가  prop에 정의되어 있는 모든 하위 모션 컴포넌트는 이 변경 사항을 감지하고 해당 동작을 토글합니다.
const CardWithGlow = () => {
  const glowVariants = {
    initial: {
      opacity: 0
    },
    hover: {
      opacity: 1
    }
  }

  return (
    // 부모는 초기 상태와 whileHover 상태의 variant key를 설정합니다.
    <motion.div initial="initial" whileHover="hover">
   {/* 자식 모션 컴포넌트는 부모가 설정한 키와 일치하는 variant에 따라 애니메이션을 적용합니다. */}
      <motion.div variants={glowVariants} className="glow"/>
      <Card>
        <div>Some text on the card/div>
      </Card>
    </motion.div>
  )
}

컴포넌트 unmount시 애니메이션 적용하기

  • AnimatePresence를 활용한다.
  • exit prop을 활용한다.

레이아웃 애니메이션

지금까지 배운 것

  • 모션 컴포넌트 세트 전체에 애니메이션 전파
  • 정상적으로 마운트 해제할 수 있도록 구성 요소에 exit transition 추가

아래에서 다룰 것 외에 추가로 더 보기 Everything about Framer Motion layout animations

 

레이아웃 애니메이션이란?

레이아웃 애니메이션은 다음과 같은 레이아웃 관련 속성을 터치하는 애니메이션입니다.
  • position properties
  • flex or grid properties
  • width or height
  • sorting elements

프레이머 div에 layout 프롬 하나만 전달하면 됩니다.

이렇게 하면 별개의 레이아웃 간에 모션 컴포넌트를 애니메이션 할 수 있습니다.

(justifyContent가 전환됩니다!)

const SwitchWrapper2 = styled('div', {
  width: '50px',
  height: '30px',
  borderRadius: '20px',
  cursor: 'pointer',
  display: 'flex',
  background: '#111',
  justifyContent: 'flex-start',

  '&[data-isactive="true"]': {
    background: '#f90566',
    justifyContent: 'flex-end',
  },
});

const SwitchHandle2 = styled(motion.div, {
  background: '#fff',
  width: '30px',
  height: '30px',
  borderRadius: '50%',
});

// Simpler version of the Switch motion component using layout animation
const Switch2 = () => {
  const [active, setActive] = React.useState(false);

  return (
    <SwitchWrapper2
      data-isactive={active}
      onClick={() => setActive((prev) => !prev)}
    >
      <SwitchHandle2 layout />
    </SwitchWrapper2>
  );
};

const Component = () => (
  <div style={{ maxWidth: '300px' }}>
    Switch 2: Animating justify-content using layout animation and the layout prop.
    <Switch2 />
  </div>
);

리스트에서 삭제할 땐 layout="position"하면 크기는 바로 전환되서 자연스러움

 

Shared Layout Animation

원이 자연스럽게 이동하게 하기

const MagicWidgetComponent = () => {
  const [selectedID, setSelectedID] = React.useState('1');

  return (
    <ul>
      {items.map((item) => (
        <li
          style={{
            position: 'relative'
          }}
          key={item.id}
          onClick={() => setSelectedID(item.id)}
        >
          <Circle>{item.photo}</Circle>
          {selectedID === item.id && (
            <motion.div
              layoutId="border" // -------------------- here!!
              style={{
                position: 'absolute',
                borderRadius: '50%',
                width: '48px',
                height: '48px',
                border: '4px solid blue';
              }}
            />
          )}
        </li>
      ))}
    </Grid>
  );
  • using the layout prop on the ListItem component to animate reordering the list
  • using the layout prop on the list itself to handle resizing gracefully when items are expanded when clicked on
  • other instances of the layout prop used to prevent glitches during a layout animation (especially the ones involving changing the height of a list item)
  • 레이아웃 그룹 안에서 레이아웃 프롭이 있는 모션 컴포넌트의 레이아웃 애니메이션은 동기화 됩니다.
const List = styled(motion.ul, {
  padding: '16px',
  width: '350px',
  background: 'var(--maximeheckel-colors-body)',
  borderRadius: 'var(--border-radius-1)',
  display: 'grid',
  gap: '16px',
});

const ListItem = styled(motion.li, {
  background: 'var(--maximeheckel-colors-foreground)',
  boxShadow: '0 0px 10px -6px rgba(0, 24, 40, 0.3)',
  borderRadius: 'var(--border-radius-1)',
  padding: '8px',
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'space-between',
  cursor: 'pointer',
  marginBottom: '0px',
});

const Button = styled('button', {
  background: 'transparent',
  border: 'none',
  shadow: 'none',
  color: 'var(--maximeheckel-colors-typeface-tertiary)',
});

const InfoBox = styled('div', {
  width: '50%',
});

const FilterWrapper = styled('div', {
  marginBottom: '16px',
  input: {
    marginRight: '4px',
  },
  label: {
    marginRight: '4px',
  },
});

const Title = motion.div;

const ARTICLES = [
  {
    category: 'swift',
    title: 'Intro to SwiftUI',
    description: 'An article with some SwitftUI basics',
    id: 1,
  },
  {
    category: 'js',
    title: 'Awesome React stuff',
    description: 'My best React tips!',
    id: 2,
  },
  {
    category: 'js',
    title: 'Styled components magic',
    description: 'Get to know ways to use styled components',
    id: 3,
  },
  {
    category: 'ts',
    title: 'A guide to Typescript',
    description: 'Type your React components!',
    id: 4,
  },
];

const categoryToVariant = {
  js: 'warning',
  ts: 'info',
  swift: 'danger',
};

const Item = (props) => {
  const { article, showCategory, expanded, onClick } = props;

  const readButtonVariants = {
    hover: {
      opacity: 1,
    },
    initial: {
      opacity: 0,
    },
  };

  return (
    <ListItem layout initial="initial" whileHover="hover" onClick={onClick}>
      <InfoBox>
        // 타이틀 높이 조절 시 부드럽게... 다른 레이아웃들과 동기화됨.
        <Title
        layout
        >
          {article.title}
        </Title>
        <AnimatePresence>
          {expanded && (
            <motion.div
              style={{ fontSize: '12px' }}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
            >
              {article.description}
            </motion.div>
          )}
        </AnimatePresence>
      </InfoBox>
      <AnimatePresence>
        {showCategory && (
          <motion.div
            layout
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
          >
            <Pill variant={categoryToVariant[article.category]}>
              {article.category}
            </Pill>
          </motion.div>
        )}
      </AnimatePresence>
      <motion.div
        layout
        variants={readButtonVariants}
        transition={{ duration: 0.25 }}
      >
        <Button
          aria-label="read article"
          title="Read article"
          onClick={(e) => e.preventDefault()}
        >
          &#8594;
        </Button>
      </motion.div>
    </ListItem>
  );
};

const Component = () => {
  const [showCategory, setShowCategory] = React.useState(false);
  const [sortBy, setSortBy] = React.useState('title');
  const [expanded, setExpanded] = React.useState(null);

  const onSortChange = (event) => setSortBy(event.target.value);

  const articlesToRender = ARTICLES.sort((a, b) => {
    const itemA = a[sortBy].toLowerCase();
    const itemB = b[sortBy].toLowerCase();

    if (itemA < itemB) {
      return -1;
    }
    if (itemA > itemB) {
      return 1;
    }
    return 0;
  });

  return (
    <>
      <FilterWrapper>
        <div>
          <input
            type="checkbox"
            id="showCategory2"
            checked={showCategory}
            onChange={() => setShowCategory((prev) => !prev)}
          />
          <label htmlFor="showCategory2">Show Category</label>
        </div>
        <div>
          Sort by:{' '}
          <input
            type="radio"
            id="title"
            name="sort"
            value="title"
            checked={sortBy === 'title'}
            onChange={onSortChange}
          />
          <label htmlFor="title">Title</label>
          <input
            type="radio"
            id="category"
            name="sort"
            value="category"
            checked={sortBy === 'category'}
            onChange={onSortChange}
          />
          <label htmlFor="category">Category</label>
        </div>
      </FilterWrapper>
       // 해당 그룹의 레이아웃 애니메이션들이 동기화 되도록 해줍니다.
      <LayoutGroup>
        <List layout>
          {articlesToRender.map((article) => (
            <Item
              key={article.id}
              expanded={expanded === article.id}
              onClick={() => setExpanded(article.id)}
              article={article}
              showCategory={showCategory}
            />
          ))}
        </List>
      </LayoutGroup>
    </>
  );
};

render(<Component />);

AnimatePresence

  • 각 아이템은 layout prop이 true로 설정된 모션 컴포넌트에 래핑됩니다.
  • 전체 리스트는 AnimatePresence로 래핑되고, 각 아이템에 exit 애니메이션이 포함될 수 있습니다.
  • 목록에서 알림을 클릭하면 알림이 제거되고 레이아웃 애니메이션 덕분에 스택이 자동으로 다시 조정됩니다.
  <motion.div
    key={item}
    onClick={() =>
      setNotifications((prev) =>
        prev.filter((notification) => notification !== item)
      )
    }
    layout
    initial={{
      y: 150,
      x: 0,
      opacity: 0
    }}
    animate={{
      y: 0,
      x: 0,
      opacity: 1
    }}
    exit={{
      opacity: 0
    }}
  >
         <Toast>{item}</Toast>
  </motion.div>

Transition 오브젝트의 layout 키 내에서 레이아웃 애니메이션의 전환을 사용자 정의할 수 있습니다.

<motion.div
  layout
  transition={{
    layout: {
      duration: 1.5,
    },
  }}
/>

Fixing distortions

컴포넌트의 크기에 영향을 주는 레이아웃 애니메이션을 수행할 때
borderRadius 또는 boxShadow와 같은 일부 속성의 전환 중에 일부 왜곡이 나타날 수 있습니다.
이러한 왜곡은 이러한 속성이 애니메이션의 일부가 아닌 경우에도 발생합니다.
 

운 좋게도 이러한 문제를 해결할 수 있는 쉬운 해결 방법이 있습니다.

다음과 같이 이러한 속성을 인라인 스타일로 설정합니다.

// CSS
.box {
  width: 20px;
  height: 20px;
  border-radius: 20px;
}

.box[data-expanded="true"] {
  width: 150px;
  height: 150px;
}
  
// JS
<motion.div
  layout
  className="box"
  data-expanded={expanded}
/>
저처럼 코드베이스에서 CSS 변수를 사용하고 있다면
borderRadius 또는 boxShadow 값에 대해 CSS 변수를 설정해도 위의 부작용이 수정되지 않는다는 점에 유의하십시오.
왜곡을 방지하려면 적절한 값을 사용해야 합니다.

layout prop에 대해 더 알아보기

레이아웃 prop을 true로 설정하면
크기나 위치와 관련된 속성을 전환하여 레이아웃 간에 내부 컴포넌트를 애니메이션할 수 있는 기능이 제공된다는 것을 방금 확인했습니다. 레이아웃 prop은 더 많은 값을 취할 수 있습니다.
  • layout="position": 위치 관련 속성만 부드럽게 전환합니다. 크기 관련 속성은 빠르게 전환됩니다.
  • layout="size": 크기 관련 속성만 부드럽게 전환합니다. 위치 관련 속성은 빠르게 전환됩니다.



 

layout="positon"이면 나머지 즉시 전환
실용적인 용도는 무엇일까요?
때로 레이아웃 애니메이션의 결과로 크기가 조정되는 컴포넌트의 콘텐츠가 "뭉쳐지거나(squashed)" "늘어질(stretched)" 수 있습니다.
레이아웃 prop을 position로 설정하기만 하면 문제를 해결할 수 있습니다.
 
 
  • 가로 목록에서 항목을 제거하면 각 컴포넌트의 크기에 영향을 줍니다. 항목을 제거하면 컴포넌트가 약간 찌그러지는 것을 알 수 있습니다. 
  • 모션 컴포넌트로 콘텐츠를 래핑하고 레이아웃을 position로 설정하면 모션 블록의 콘텐츠에서 관찰할 수 있는 모든 왜곡이 수정됩니다.
  • 각 구성 요소는 보다 자연스러운 전환으로 크기가 적절하게 조정됩니다.
<motion.div layout>
  <Label variant="success">
    <motion.div
      style={{ 
        width: '100%',
        display: 'flex',
        justifyContent: 'start',
      }}
      layout="position"
    >
      <DismissButton/>
      <span>{text}</span>
    </motion.div>
  </Label>
</motion.div>

 

Shared Layout Animation And LayoutGroup

여러 컴포넌트를 포함하는 레이아웃의 애니메이션

  • 위 둘은 관련되어 있는 것처럼 보이지만 각가 매우 뚜렷한 목적과 사용 사례를 가지고 있습니다. 
    • 공유(shared) 측면은 마치 동일한 것처럼 한 위치에서 다른 위치로 콤포넌트가가 이동하는 효과입니다.
    •  

Shared layout animations

이전에 본 레이아웃 애니메이션의 또 다른 유형이라고 생각할 수 있지만 약간의 변형이 있습니다.

틀린 말은 아니지만 정확하지도 않습니다.

 

layout prop과 직접 관련되지 않은 자체 API가 있습니다.
컴포넌트의 위치와 크기에 애니메이션을 적용하는 대신
공통 layoutId 속성이 있는 모든 컴포넌트 인스턴스에 애니메이션을 적용합니다.
 
  • 모든 Arrow 컴포넌트 인스턴스 사이에 transition을 적용합니다
  • 사용자가 새 항목을 클릭할 때 한 인스턴스에서 새로운 "활성" 인스턴스로 전환해야 함을 알려주는 공통 layoutId를 공유합니다.
shared layout

공유(shared) 측면은 마치 동일한 것처럼 한 위치에서 다른 위치로 콤포넌트가가 이동하는 효과입니다.

그 뒤에 숨겨진 "마법"은 실제로 매우 간단합니다.

  • 다른 아이템을 클릭하면 화면에 표시된 Arrow 컴포넌트가 사라지고 Arrow 컴포넌트의 새 인스턴스가 나타납니다.
  • 새로운 Arrow 컴포넌트는 목록에서 새로 선택한 요소 아래에 위치하게 될 컴포넌트입니다.
  • 그런 다음 해당 컴포넌트가 최종 위치로 transition됩니다.

 다른걸 만들어 봅시다.

  • selected indicator
  • hover highligh
  • 각 레이아웃 애니메이션은 고유의 layoutId 프롭이 필요합니다 : underline & highlight
 {focused === item ? (
    <motion.div
      transition={{
        layout: {
          duration: 0.2,
          ease: "easeOut"
        }
      }}
      style={{
        position: "absolute",
        bottom: "-2px",
        left: "-10px",
        right: 0,
        width: "140%",
        height: "110%",
        background: "#23272F",
        borderRadius: "8px",
        zIndex: 0
      }}
      layoutId="highlight"
    />
) : null}
{selected === item ? (
    <motion.div
      style={{
        position: "absolute",
        bottom: "-10px",
        left: "0px",
        right: 0,
        height: "4px",
        background: "#5686F5",
        borderRadius: "8px",
        zIndex: 0
      }}
      layoutId="underline"
    />
) : null}

한 페이지에서 같은 컴포넌트를 두번 사용한다면? > 설명하기 어려운 버그가 발생합니다.


LayoutGroup: 사용 사례에 namespace 제공

LayoutGroup에는 두 가지 사용 사례가 있습니다.
  • shared layout 애니메이션을 활용하고 동일한 페이지 내에서 해당 컴포넌트를 사용하는 재사용 가능한 컴포넌트를 빌드할 수 있는 네임스페이스 by layoutId
  • 전체 레이아웃에 영향을 줄 수 있는 고유한 레이아웃 애니메이션을 수행하는 sibilling 컴포넌트를 그룹화하여 새로 업데이트된 레이아웃에 적절하게 적응할 수 있습니다.

shared 레이아웃 애니메이션에 네임스페이스를 부여하는 것은 ayoutGroup의 유일한 사용 사례가 아닙니다.

원래 목적은 실제로 다음과 같습니다.

레이아웃 애니메이션을 함께 수행해야 하는 모션 컴포넌트를 그룹화합니다.
 
한 컴포넌트의 레이아웃 애니메이션으로 인해 페이지의 전체 레이아웃이 영향을 받을 수 있습니다.
예를 들어 목록에서 항목을 제거할 때 모든 주변 컴포넌트는 전환 또는 크기 조정을 통해 적응해야 합니다.
문제는 다음과 같은 이유로 다른 컴포넌트를 원활하게 전환할 수 있는 방법이 없다는 것입니다.
  • 반드시 모션 컴포넌트가 아닙니다.
  • 상호 작용하지 않았기 때문에 렌더링되지 않습니다.
  • 다시 렌더링하지 않기 때문에 애니메이션이 정의된 경우에도 자체적으로 레이아웃 애니메이션을 수행할 수 없습니다.
레이아웃이 true로 설정된 모션 컴포넌트의 각 sibiling 컴포넌트를 래핑하고(sibiling 컴포넌트가 모션 컴포넌트가 아닌 경우)
전체 레이아웃이 변경될 때 부드러운 전환을 수행하려는 모든 컴포넌트를 LayoutGroup으로 래핑합니다.
 
 
 
  • 첫 번째 목록에서 항목을 제거하면 첫 번째 목록 내의 항목이 부드러운 레이아웃 애니메이션을 수행하지만 두 번째 목록이 갑자기 움직이는 것을 확인하세요
  • LayoutGroup 래핑을 켜면 이제 첫 번째 목록에서 항목을 제거할 때 두 번째 목록이 대상 위치로 부드럽게 전환되는 것을 확인합니다. 
<LayoutGroup>
  <List 
    items={[...]} 
    name="List 1" 
  />
  <List 
    items={[...]}
    name="List 2"
  />
</LayoutGroup>

Reorder

드래그 앤 드롭 후 재정렬 시 부드럽게 이동하는 것은 레이아웃 애니메이션의 최고의 사용 사례 중 하나입니다.

  • Reorder.Group에 리스트, 재정렬 방향(가로 또는 세로) 및 리스트의 최신 순서를 반환하는 onReorder 콜백을 전달합니다.
  • Reorder.Item에 리스트에 있는 아이템의 값을 전달합니다.
const MyList = () => {
  const [items, setItems] = React.useState(['Item 1', 'Item 2', 'Item 3']);

  return (
    <Reorder.Group
      // Specify the direction of the list (x for horizontal, y for vertical)
      axis="y"
      // Specify the full set of items within your reorder group
      values={items}
      // Callback that passes the newly reordered list of item
      // Note: simply passing a useState setter here is equivalent to
      // doing `(reordereditems) => setItmes(reordereditems)`
      onReorder={setItems}
    >
      {items.map((item) => (
        // /!\ don't forget the value prop!
        <Reorder.Item key={item} value={item}>
          {item}
        </Reorder.Item>
      ))}
    </Reorder.Group>
  );
};
  • 각 Reorder.Item은 모션 컴포넌트입니다.
  • 목록의 각 Reorder.Item 컴포넌트는 기본적으로 레이아웃 애니메이션을 수행할 수 있습니다.
항상 드래그 중인 컴포넌트를 상단으로 위치하고 싶다면, Reorder.Item의 CSS를 relative로 설정합니다.

CSS-IN-JS의 다형성 활용하기

참고로 위는 되고 아래는 안됨

// Valid
<Reorder.Group as="span" />
<Reorder.Item as="div" />
<Reorder.Item as="aside" />

// Invalid
<Reorder.Group as={List} />
<Reorder.Item as={Card} />

 따라서 아래와 같이 씀

const Card = styled('div', {...});

// ...

// Valid Custom Reorder component
<Card as={Reorder.Item} />

배운걸 전부 써먹어보기

  • layout="position"은 항목을 선택하고 레이아웃 애니메이션을 수행할 때 왜곡을 방지하기 위해 각 항목의 내용에 사용됩니다.
  • 커스텀 React styled-component는 다형성을 통해 컴포넌트 재정렬을 사용합니다.
//...

<Card
  as={Reorder.Item}
  //...
  value={item}
>
  <Card.Body as={motion.div} layout="position">
    <Checkbox
      id={`checkbox-${item.id}`}
      aria-label="Mark as done"
      checked={item.checked}
      onChange={() => completeItem(item.id)}
    />
    <Text>{item.text}</Text>
  </Card.Body>
</Card>

//...
  • 항목 크기 조정 시 왜곡을 방지하기 위해 항목의 borderRadius 인라인 스타일이 사용됩니다.
  • position: relative가 Reorder.Item에 인라인 스타일로 추가되어 목록의 요소를 서로 드래그하는 동안 발생하는 겹침 문제를 수정했습니다.
  • AnimatePresence는 엘리먼트가 리스트에서 제거될 때 종료 애니메이션을 허용하는 데 사용됩니다.
//...
<AnimatePresence>
  {items.map((item) => (
    <motion.div
       exit={{ opacity: 0, transition: { duration: 0.2 } }}
    />
      <Card
        as={Reorder.Item}
        style={{
          position: 'relative', // this is needed to avoid weird overlap
          borderRadius: '12px', // this is set as inline styles to avoid distortions
          width: item.checked ? '70%' : '100%', // will be animated through layout animation
        }}
        value={item}
      >
        //...
      </Card>
    </motion.div>
    //...
  )}
</AnimatePresence>
//...
  • 리스트와 그 sibiling 컴포넌트는 목록이 전체 레이아웃을 업데이트하고 변경할 때 부드러운 레이아웃 애니메이션을 수행하기 위해 LayoutGroup에 래핑됩니다.

전체 구현은 : my blog's Github repository 에서 참조하세요

참고 : 

https://blog.maximeheckel.com/posts/advanced-animation-patterns-with-framer-motion/

 

Advanced animation patterns with Framer Motion - Maxime Heckel's Blog

A deep dive into Framer Motion's propagation, exit transitions and layout animation patterns through curated examples and interactive playgrounds.

blog.maximeheckel.com

https://blog.maximeheckel.com/posts/framer-motion-layout-animations/

 

Everything about Framer Motion layout animations - Maxime Heckel's Blog

A complete guide to Framer Motion layout animations showcasing every concept, from the layout prop, shared layout animations and LayoutGroup, to complex drag-to-reorder interactions.

blog.maximeheckel.com

 

반응형