-
Notifications
You must be signed in to change notification settings - Fork 1
React 컴포넌트에 애니메이션을 적용해보자 🏃🏻💨
n-ryu edited this page Dec 18, 2022
·
27 revisions
- 사용자가 추가한 Todo들의 선후관계를 플로우차트 형태로 보여주는 다이어그램 뷰를 구 현했다.
- 다이어그램 뷰의 X축은 전체적인 우선순위 순으로 정렬되고, Y축은 Todo간 선후관계의 위계를 나타낸다. (선후관계가 있다면, 나중에 해야하는 Todo의 Y 위치가 더 크다)
- 토글 버튼을 통해서 다이어그램에 이미 완료된 Todo도 함께 표시할지, 아직 하지 않은 Todo들만 표시할지 결정할 수 있다.
- 앞으로 다이어그램 뷰 내에서도 Todo를 생성, 수정하고, 선후관계도 즉석에서 추가, 제거가 가능한 기능을 추가할 예정이다.
- 다이어그램 뷰에 표시되는 Todo의 목록이 바뀔 때 어떤 변화가 일어나는 것인지 알기가 어렵다.
- 지금은 이미 완료된 Todo 표기를 바꿀 때만 이런 트랜지션이 있지만, 다이어그램 뷰 내에서 추가, 삭제, 편집이 가능하다면 어떤 변화가 일어난 것인지 사용자에게 보여주어야 사용자가 자신의 입력 제대로 반영된 것인지 판단하기 쉽다.
- 다이어그램을 구성하는 Todo Block과 선후관계 vertex들이 다이어그램에 추가되고 삭제될 때 애니메이션 있으면 사용자가 이해하기도 쉽고 미려한 UX를 제공할 수 있을 것이다!
-
TodoBlock
컴포넌트와TodoVertex
컴포넌트의 스타일에transition: transform 1s
를 추가했다. - 기본적으로 리액트에서
Array.map
을 통해 생성된 컴포넌트들은key
값을 통해서 동일 컴포넌트인지 판별이 되므로, 표시 데이터 변화 전과 후 모두에 존재하는 컴포넌트들은 그 위치가 자연스럽게 트랜지션 애니메이션이 적용되는 것을 확인할 수 있다. - 하지만 기존에 없었던 컴포넌트가 생기거나 있었던 컴포넌트가 없어지는 경우, 그냥 추적할 객체 자체가 없었거나 사라지므로 애니메이션 없이 갑자기 Todo Block이나 Vertex가 사라지는 것을 확인할 수 있다.
-
현재 데이터 저장 방식의 문제
- 기존에는
TodoList API
에서 받아온 데이터를 다이어그램을 그리기 위한 데이터로 변환하고, 그 데이터를 직접<Diagram>
컴포넌트에 상태로 보관했다. - 그리고 상태로 저장된 데이터를
.map()
으로 JSX 컴포넌트로 변환해서 여러 다이어그램 요소들을 그려주게 된다. - 하지만 이렇게 데이터를 바로 상태로 보관을 하면, 새로운 요소가 추가되거나 삭제되는 것을
<Diagram>
컴포넌트는 전혀 모르므로, 생성이나 제거 등의 변화를 추적할 수 없다!
- 기존에는
-
변화를 고려한 데이터 저장 방식
- 요소의 추가, 삭제를 추적하려면 데이터를 바로 상태로 보관하는 것이 아니라, 어떤 데이터가 새로 생겼고, 없어졌는지를 판별하고, 판별 결과와 정보들을 묶고 그 결과의 합집합을 통째로 상태로 저장해 두어야 한다.
- 이렇게 저장을 한 뒤, 새로 생겨나는 요소들은 생성 트랜지션(애니메이션)을, 없어지는 요소들은 제거 트랜지션(애니메이션)을 적용시켜주면 생성, 제거 애니메이션을 구현할 수 있다.
- 변경 이전과 이후 데이터의 합집합을 변화상태(생성, 삭제, 불변)와 함께 보관하면 요소들의 변화 추적이 가능하므로, 애니메이션을 넣을 수 있을 것이다!
-
일단 기존 상태와 새로운 상태를 받아서 요소들의 변화를 추적해보자
const mountData = [...incomingData].filter(([key]) => !currentData.has(key)); const idleData = [...incomingData].filter(([key]) => currentData.has(key)); const unmountData = [...currentData].filter(([key]) => !incomingData.has(key));
- HashMap 구조로 인자로 입력되는 신규 데이터와 기존 데이터를 비교해서 생성되는 요소들, 유지되는 요소들, 제거되는 요소들을 우선 분류해보았다.
-
새로운 변화가 있어도 이전의 변화도 알고는 있어야 한다
- 위의 데이터 관리 로직 그림에서는 새로운 변화 (기존 데이터와 신규 데이터의 차이)만을 저장하한다. 하지만 트랜지션이 1초가 걸린다고 한다면 그 사이에 변화가 두 번, 세 번, 심지어 만 번도 일어날 수 있다는 문제가 있다!
- 따라서 이전에 일어났던 변화도 기억을 해 두어야한다. 이전의 변화 상태값을 그대로 두고 사용하고, 신규 변화만을 이전 변화 상태값에 업데이트 하는 방식으로 구현하면 이전의 변화가 지워지지않고 유지되고, 같은 요소에 대해 변화 상태가 달라지면 이 역시도 갱신되게 된다.
const resultTransitionData = new Map([...currentTransitionData]); idleData.forEach(([key, value]) => { const target = resultTransitionData.get(key); if (target === undefined) return; resultTransitionData.set(key, { ...target, props: value }); });
- 위의 코드처럼, 기존의 데이터를 우선 변화 상태를 얕은 복사를 하고, (
idle=불변
인 요소들에 대해서는 기존 값을 그대로 복사해주도록 했다.)
-
언젠가 변화 상태가 스스로 변해야 한다
- 요소가
mount=생성
변화상태이거나,unmount=제거
변화상태라면 입력 데이터의 변화가 없어도 언젠가 트랜지션이 완료되면 스스로 상태를idle=불변
으로 업데이트 하거나, 스스로를 전체 상태리스트에서 제거해야한다. -
mount
요소의 경우, 일단mount
상태를 적용해서 초기 style 값을 주고,setTimeout
으로 직후에 바로idle
상태를 적용해서 트랜지션이 일어나게 했다. (transition-duration
이 style에 정의도어 있으므로, 생성 직후에 초기값을 주입하고, 바로 변화 목표값을 주입하면 duration대로 변화가 일어난다.)mountData.forEach(([key, value]) => { setTimeout(() => { setTransitionData((prev) => { const newState = new Map([...prev]); newState.set(key, { aniState: 'idle', props: value }); return newState; }); }, 0); resultTransitionData.set(key, { aniState: 'mount', props: value }); });
-
unmount=제거
요소의 경우,unmount
상태를 적용해서 제거되는 목표 style을 적용시켜서idle
style에서unmount
style로 트랜지션이 발생하도록 했다.unmount
요소의 경우 트랜지션이 끝난 이후에는 전체 데이터 리스트에서 제거되어야 하므로, 트랜지션 시간 이후에 스스로 전체 목록에서 제거되도록setTimeout
을 설정해 주었다.unmountData.forEach(([key, value]) => { setTimeout(() => { setTransitionData((prev) => { const newState = new Map([...prev]); newState.delete(key); return newState; }); }, duration); resultTransitionData.set(key, { aniState: 'unmount', props: value });
- 요소가
-
변화 중인 요소가 또 변화한다면, 기존 timeout을 제거해야 한다
- 만약
unmount
중인 데이터가 다시 입력되어서mount
가 된다면, 그냥 상태를mount
로 만드는 것으로는 불충분하다.앞선
unmount
업데이트 과정에서 일정 시간 후에 스스로를 전체 데이터 리스트에서 제거하도록setTimeout
을 설정해 두었으므로,mount
가 된 이후에 갑자기 요소가 사라져버리게 된다! - 따라서
setTimeout
을 설정할 때에, 해당 timeout을 데이터와 함께 저장해 두었다가, 다른setTimeout
을 신규 등록해야 하는 차례가 오면clearTimeout
으로 이전에 등록했던 timeout을 제거해주도록 코드를 추가했다.mountData.forEach(([key, value]) => { const target = resultTransitionData.get(key); if (target !== undefined) clearTimeout(target.timeout); const timeout = setTimeout(() => { setTransitionData((prev) => { const newState = new Map([...prev]); newState.set(key, { aniState: 'idle', props: value }); return newState; }); }, 0); resultTransitionData.set(key, { aniState: 'mount', props: value, timeout }); });
unmountData.forEach(([key, value]) => { const target = resultTransitionData.get(key); if (target !== undefined) clearTimeout(target.timeout); const timeout = setTimeout(() => { setTransitionData((prev) => { const newState = new Map([...prev]); newState.delete(key); return newState; }); }, duration); resultTransitionData.set(key, { aniState: 'unmount', props: value, timeout });
- 만약
- 결과물을 다이어그램 뷰에 적용시켜 보았다. 생성, 제거 트랜지션은 단순하게 opacity값만을 조정했다.
- 의도한 대로, Todo Block과 Vertex가 생성되고 추가할 때에 뿅 나타나는 것이 아니라 투명도가 변하면서 서서히 나타나고 서서히 없어지게 구현이 잘 되었다!
-
Vertex가 생성 추가될 때, 연관된 Todo를 위치를 추적하도록 하자
- 위의 영상을 잘 보면, Todo Vertex가 생성되거나 제거될 때에 연결된 Todo Block의 위치는 따라가지 않는 것을 확인할 수 있다.
- 이는 Vertex의 시작점과 도착점이 당시의 Data(Transition Data가 아닌 순수 Data)를 기준으로만 계산되기 때문으로, 이 변화를 적용시켜 주려면, 한단계의 작업이 더 필요하다.
- Vertex가 새로 생성되었을 때,
mount
상태의 초기 시작점/도착점 위치는 이전 상태 Todo Block을 참조해서 계산되어야 한다. (Vertex가 새로 생겼으므로 스스로의 이전 위치는 존재하지 않는다.) - Vertex가 제거 될 때,
unmount
상태의 초기 시작점/도착점 위치는 이후 상태 Todo Block을 참조해서 계산되어야 한다. (Vertex가 제거될 것이므로 스스로의 이후 위치 정보는 존재하지 않는다.)
- Vertex가 새로 생성되었을 때,
- Vertex가 자신만의 정보뿐만 아니라 Todo Block의 정보까지 참조하도록 코드를 수정하니, Vertex가 생성, 삭제 시에도 자신과 연관된 Todo Block을 잘 쫓아다니는 것을 확인할 수 있다!
- 이후 다이어그램 뷰의 편집기능을 추가했을 때도, 별도의 코드 수정 없이 편집기능과도 잘 연동되어 트랜지션이 일어나는 것을 확인했다.
-
가끔씩 애니메이션이 스킵되는 문제가 있다?
-
아래의 첫번째 영상을 보면, 가끔씩 새 Todo Vertex가 생성 될 때에 애니메이션이 제대로 적용되지 않고 뿅하고 요소가 생성되는 것을 확인할 수 있다.
- Vertex의 연결은 물론, opacity 또한 처음부터 1로 뚜렷하게 나타는 것으로 보아 아예
mount
변화상태가 적용되지 않고, 즉시idle
로 덮어씌워지는 것으로 보인다. - 팀원들과 함께 분석해본 결과, 렌더링 시간이 오래걸려서 상태 업데이트를 하는
setTimeout(,0)
이 실제 렌더링이 일어나기 전에 호출된 것으로 보인다. 즉, 리액트가mount
상태를 적용된 결과를 렌더링하는 도중에 eventLoop이 한바퀴 돌았고, 화면 표시 전에setTimeout
으로 인해 상태가 다시idle
로 덮어씌워졌다는 것이다
- Vertex의 연결은 물론, opacity 또한 처음부터 1로 뚜렷하게 나타는 것으로 보아 아예
-
이를 해결하기 위해서는 여러가지 접근법이 제안되었다.
-
onTransitionEnd
이벤트 이용하기 (unmount
의 경우)- 컴포넌트에 직접
onTransitionEvent
의 이벤트 핸들러를 달아서 다음 상태를 적용시켜주는 방법.onTransitionEvent
가 끝난 트랜지션의 종류 또한event
인자에 넣어서 넘겨주기 때문에 가장 정확한 방법일 듯 하다. - 게다가 변화 상태가 업데이트 될 때에
onTransitionEvent
도 바꿔 달아주므로 앞서 있었던clearTimeout
과 같은 불편한 과정이 필요하지 않다. - 또한, transition 시간을 style 한군데에서만 조절할 수 있다는 것도 커다란 장점이다.
- 컴포넌트에 직접
-
useEffect
이용하기 (mount
의 경우)- 생성 이전에 값이 덮어씌워지므로, 생성 하자마자 값을 바꿔주려면
useEffect
가 가장 적절해보인다. - 하지만 이런 방식의 사용이 가능한지, 리액트 렌더링 과정에서
useEffect
의 호출시점이 실제 paint이후인지 명확하지 않아 추가적인 공부와 실험이 필요하다.
- 생성 이전에 값이 덮어씌워지므로, 생성 하자마자 값을 바꿔주려면
-
setTimeout
의 duration 더 주기- 가장 무식하지만 수정이 가장 없기에 일단 채용한 방식
-
mount
상태 이후에idle
상태로 업데이트 하는 일에 최소한의 대기시간을 주면 그 간격 이내에만 렌더링이 된다면 정상적으로 트랜지션이 일어난다. - 또, 반대로 말하면 정적 상태를 계산하는 렌더링 시간이 너무 오래걸리면 애니메이션을 스킵하므로, 성능이 안좋다면 애니메이션을 스킵하게 되어, 오히려 유저 경험상 좋을 수도 있다는 생각이 들었다.
- 결과적으로는 이 방법을 채택해서 약 50ms정도의 최소 지연시간을 주었고, 아래의 두번째 영상처럼 트랜지션이 스킵되지 않게 되었다.
-
-
- OaO 환경설정 A to Z
- CRLF 너가 뭔데 날 힘들게 해?
- Github Issue 똑똑하게 사용하기
- OAO! CI CD 적용기 with release 자동화
- 매번 다른 import문
- 못생긴 상대경로에서 간zlzl존 절대경로로😎
- TodoList API 개발기
- 의존성 주입으로 DB를 바꿔보자
- 렌더링 최적화 서막: useNavigate를 추가한 순간 리렌더 범위가 확장된 건에 대하여
- 렌더링 최적화 1탄: 렌더링 범위에 대하여 (by 최적화무새)
- 렌더링 최적화 2탄: 잘못된 custom hook 사용,, 전체 리렌더링을 부르다,,
- 렌더링 최적화 3탄: Todo 상세 좀 봤다고 테이블 전체가 재렌더링 되는건을 고치기😌
- 렌더링 최적화 4탄: 다이어그램 편
- 🐁 마우스 상대위치 계산은 이상해
- React 컴포넌트에 애니메이션을 적용해보자 🏃🏻💨
- 컴포넌트 재사용성을 높여보자: Modal 분리기 🌹
- 선후관계를 자동완성으로 추가해보자 🔎