[Flutter] 선언형 애니메이션, 그게 뭔데?

Cody Yun
13 min readMay 13, 2024

--

2022년 연말에 플러터를 처음 접하고 Dart 문법, 다양한 위젯, HTTP, JSON Serialize, Navigation, Storage, State Management, Architecture, Test 등 다양한 주제를 깊이 있게 공부했습니다. 하지만 실무 경험 부족과 더불어 플러터 학습의 명확한 방향성을 잃어버려 ‘플러터 권태기’를 겪게 되었습니다.

roadmap.sh의 플러터 로드맵을 참고해보니 프로젝트 배포, 운영, 플러터 내부 구조 학습 등이 필요해 보였지만, 당장 실무에 적용할 기회가 없어 적절한 목표 설정이 어려웠습니다. 그러던 중 플러터 컨퍼런스 발표 주제를 논의하다가, 노트님으로 부터 관심 있는 분야를 묻는 질문에 ‘애니메이션’이라고 답했습니다. 그러자 노트님은 제 상황을 꿰뚫어 보는 듯한 제목을 지어주셨습니다.

“OO 애니메이션, 그게 뭔데?” — note11g

<출처 : 나무위키>

선언형 애니메이션

플러터의 가장 큰 특징은 선언형 UI입니다. UI를 화면에 표시하는 방식을 일일이 지시하는 명령형 방식과 달리, 선언형 방식은 원하는 UI의 상태를 선언적으로 기술하여 플러터가 이를 자동으로 처리하도록 합니다.

명령형 UI에서는 UI 상태 변화를 위해 각 단계별 명령을 구체적으로 작성해야 합니다. 반면 선언형 UI는 변경될 UI 상태만 명시하면 플러터가 알아서 최적의 방식으로 UI를 갱신합니다.

이러한 선언형 방식은 애니메이션에도 적용됩니다. 선언형 애니메이션은 애니메이션의 시작, 끝, 변화 과정 등을 선언적으로 기술하여 플러터가 부드러운 애니메이션을 자동으로 생성하도록 합니다. 개발자는 애니메이션의 각 프레임을 일일이 제어할 필요 없이, 원하는 애니메이션 효과를 간결하게 표현할 수 있습니다.

리액트로 살펴보는 선언형 애니메이션

선언형 UI를 사용하는 리액트에서 선언형 애니메이션이 어떻게 구현 되는지 살펴보고, 플러터의 선언형 애니메이션과 어떤 차이가 있는지 비교해 봅시다.

/* app.js */
import React, { useState } from 'react'
import './App.css'; // import your css file

function App() {
const [hide, setHide] = useState(false);

const handleClick = () => {
setHide(true);
};

return (
<div>
<button onClick={handleClick}>Click me</button>
<p className={hide ? 'hide' : ''}>This text will disappear</p>
</div>
);
}

export default App
/* App.css */
.hide {
transition: opacity 1s ease-out;
opacity: 0;
}

리액트에서 애니메이션은 주로 CSS를 활용하여 구현합니다. 예를 들어, 숨김 애니메이션을 위해 ‘hide’ 클래스를 정의하고, CSS의 transition과 opacity 속성을 이용하여 애니메이션 효과를 설정합니다.

버튼 클릭과 같은 이벤트가 발생하면, useState 훅을 통해 컴포넌트의 상태를 변경합니다. 이로 인해 컴포넌트가 다시 렌더링되면서, 애니메이션을 적용할 요소에 'hide' 클래스를 추가하거나 제거하여 애니메이션 효과를 실행합니다.

이러한 방식은 선언형 애니메이션의 개념과 일맥상통합니다. 애니메이션의 시작 상태는 요소의 기본 스타일로, 종료 상태는 ‘hide’ 클래스에 정의된 opacity 속성값으로 표현됩니다. 또한, transition 속성을 통해 애니메이션의 변화 과정을 선언적으로 정의합니다.

즉, 리액트는 CSS를 통해 애니메이션의 시작, 종료, 변화 과정을 명시적으로 선언하고, 상태 변화에 따라 해당 스타일을 적용함으로써 선언형 애니메이션을 구현합니다.

플러터로 살펴보는 선언형 애니메이션

플러터 역시 UI 애니메이션을 구현하기 위해 선언형을 사용합니다.

class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
late AnimationController _controller;

@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
);
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: AnimatedBuilder(
animation: _controller,
child: const Text('This text will disappear'),
builder: (context, child) {
return Opacity(
opacity: 1 - _controller.value,
child: child,
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _controller.forward(),
tooltip: 'Disappear',
child: const Icon(Icons.play_arrow),
),
);
}
}

플러터 프로젝트를 생성하고 첫 화면에 표시될 위젯 트리를 구성해 보겠습니다. 먼저, StatefulWidget을 상속받는 MyHomePage 위젯을 생성하고, 이를 앱 시작 시 첫 화면으로 설정합니다.

class MyHomePage extends StatefulWidget {
// ...
}

_MyHomePageState클래스를 선언하고, 이를 MyHomePage의 상태 클래스로 지정합니다. 이 상태 클래스에 애니메이션을 제어할 AnimationController속성을 추가합니다.

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
late AnimationController _controller;

@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 1), // 애니메이션 지속 시간 설정
vsync: this,
);
}

@override
void dispose() {
_controller.dispose(); // 애니메이션 컨트롤러 해제
super.dispose();
}

// ...
}

initState 메서드에서 AnimationController객체를 생성하고, 애니메이션 지속 시간을 설정합니다. dispose 메서드에서는 생성한 AnimationController를 해제하여 리소스를 관리합니다. 다음으로, Scaffold위젯의 body에 기존의 Center > Text 구조를 Center > AnimatedBuilder 구조로 변경합니다.

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: AnimatedBuilder(
animation: _controller,
child: Text('Hello, Flutter!'), // 애니메이션 적용될 텍스트
builder: (context, child) {
return Opacity(
opacity: 1 - _controller.value, // 투명도 설정 (0 -> 1)
child: child,
);
},
),
),
);
}

AnimatedBuilder의 animation 속성에 AnimationController 객체를 전달하고, child 속성에는 화면에 표시될 텍스트를 설정합니다. builder 함수에서는 전달받은 child를 Opacity 위젯으로 감싸고, opacity 속성에 AnimationController의 value값을 활용하여 투명도를 조절합니다. 이제 FloatingActionButton을 터치하면 _controller.forward()를 호출하면 애니메이션이 시작되고, 텍스트는 0에서 1로 투명도가 변화하며 나타납니다.

구현 후 실행해보면 우측 하단의 FloatingActionButton을 터치했을 때 화면 중앙의 텍스트가 천천히 사라지는걸 볼 수 있습니다. 앞서 살펴본 리액트 CSS 기반의 애니메이션이 선언형 애니메이션인 이유로 애니메이션을 위한 상태의 시작값, 끝 값, 변화량을 선언적으로 표현하기 때문이라 이야기했습니다. 플러터 역시 애니메이션 상태의 시작 값과 끝 값은 AnimationController 생성자의 lowerBound, upperBound 초기값에 의해 지정됐습니다.

// flutter/../animation_controller.dart
AnimationController({
double? value,
this.duration,
this.reverseDuration,
this.debugLabel,
this.lowerBound = 0.0,
this.upperBound = 1.0,
this.animationBehavior = AnimationBehavior.normal,
required TickerProvider vsync,
}) : assert(upperBound >= lowerBound),
_direction = _AnimationDirection.forward {
if (kFlutterMemoryAllocationsEnabled) {
_maybeDispatchObjectCreation();
}
_ticker = vsync.createTicker(_tick);
_internalSetValue(value ?? lowerBound);
}

변화량은 AnimationController 객체 생성 시 duration 인자로 전달한 값 입니다. 시작 값 0에서 끝 값이 1로 1초 동안 변하는 상태를 선언적으로 작성됐습니다. 이제 이 변하는 상태가 Text에 적용되기 위해 AnimatedBuilder와 Opacity 위젯으로 감싸 변하는 상태에 의해 Text가 어떻게 보일지 선언적으로 애니메이션을 적용했습니다.

class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
late AnimationController _controller;

@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
);
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: AnimatedBuilder(
animation: _controller,
child: const Text('This text will disappear'),
builder: (context, child) {
return Opacity(
opacity: 1 - _controller.value,
child: child,
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _controller.forward(),
tooltip: 'Disappear',
child: const Icon(Icons.play_arrow),
),
);
}
}

명시적 선언형 애니메이션

앞서 구현한 예제는 AnimationController를 통해 선언형 애니메이션으로 구현한 위젯이 FloatingActionButton의 클릭 이벤트를 통해 명시적으로 애니메이션이 시작됩니다. 따라서 이러한 방식의 애니메이션을 명시적(Explicit) 애니메이션이라 하고, 명시적이지 않고 애니메이션이 암묵적(Implicit)으로 시작되는 경우를 암묵적 애니메이션이라 합니다. 명시적 애니메이션과 암묵적 애니메이션에 대한 설명은 지난 포스팅을 통해 보다 자세히 확인할 수 있습니다.

끝으로

리액트와 플러터에서 선언형 UI에 선언형으로 애니메이션을 적용되는 부분을 살펴봤습니다. 선언형 애니메이션은 명령형 애니메이션 대비 아래와 같은 장점이 존재합니다.

  • 코드의 간결성
    애니메이션의 상태 변화를 프레임워크에서 처리하기 때문에 상태 변화를 위한 로직을 작성할 필요가 없습니다.
  • 재사용 가능함
    UI와 애니메이션 로직이 분리되어 재사용하기 좋습니다.
  • UI 테스트의 용이함
    UI와 애니메이션 상태 로직이 분리되고, 애니메이션 상태에 따라 UI의 상태가 결정되기 때문에 상태의 재현이 쉽습니다. 이는 위젯의 테스트 코드 작성이 가능함을 의미합니다.

다음에는 AnimationController와 AnimatedBuilder의 구현 코드를 살펴보며 애니메이션의 동작 원리를 살펴보는 포스팅으로 찾아오겠습니다.

--

--

Cody Yun

I wanna be a full stack software engineer at the side of user-facing application.