[Flutter] 암묵적 애니메이션, 그게 뭔데?

Cody Yun
Flutter Seoul
Published in
34 min readMay 30, 2024

플러터 애니메이션 시스템에 대해서 꾸준히 살펴보고 있습니다.

처음 살펴본 내용은 플러터팀에서 제공한 “How to choose which Flutter Animation Widget is right for you?” 라는 영상을 정리한 글을 바탕으로 “올바른 애니메이션 구현 방법 선택하기"라는 번역글을 작성했습니다. 번역글에서는 명시적 애니메이션과 암묵적 애니메이션의 차이를 살펴보고, 요구사항에 맞는 애니메이션 구현 방법을 선택하기 위한 내용을 담고 있습니다.

최근에는 플러터 애니메이션 시스템 공부를 시작하며, 플러터 선언형 UI에 애니메이션을 적용할 때 명령형이 아닌 선언형으로 애니메이션을 구현하는 과정을 살펴봤습니다. 명령형 프로그래밍에서는 애니메이션을 적용할 객체에 접근한 후 애니메이션을 위해 매 프레임 호출되는 업데이트 루프 등을 통해 객체의 렌더링에 영향을 주는 상태값을 변경하는 방식으로 구현되지만, 선언형 애니메이션은 애니메이션이 적용될 곳에 선언적으로 표현하는 방식이라 설명했습니다. “선언형 애니메이션, 그게 뭔데?” 글에서는 AnimationController와 AnimatedBuilder를 통해 명시적인 선언형 애니메이션을 적용했습니다.

이번 포스팅에서는 암묵적(Implicitly) 선언형 애니메이션을 적용해보고, 선언형 애니메이션을 암묵적으로 동작하게 하는 플러터 내부 코드를 살펴보도록 하겠습니다.

Container 속성을 변경하기

암묵적 선언형 애니메이션을 적용할 간단한 프로젝트를 만들어 봅시다. 이 프로젝트에서는 플로팅 액션 버튼을 누르면 컨테이너의 width, height, color, borderRadius 속성이 랜덤하게 변경되도록 구현하겠습니다. 플로팅 액션 버튼이 클릭되면, setState를 호출해 랜덤하게 변경된 속성으로 리빌드를 할 수 있도록 StatelessWidget 대신 StatefulWidget으로 AnimatedContainerExample 위젯을 만들어줍니다.

import 'dart:math';
import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return const MaterialApp(
home: AnimatedContainerExample(),
);
}
}

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

@override
State<AnimatedContainerExample> createState() =>
_AnimatedContainerExampleState();
}

AnimatedContainerExample의 State에서는 Container의 속성으로 사용될 late 프로퍼티를 선언합니다. 랜덤으로 값을 지정하기 위해 Random 객체를 final로 추가하고, 랜덤으로 값을 지정하는 _randomize 메소드를 구현하고 생성자에서 호출합니다.

class _AnimatedContainerExampleState extends State<AnimatedContainerExample> {
final random = Random();
late double _width;
late double _height;
late Color _color;
late BorderRadiusGeometry _borderRadius;

_AnimatedContainerExampleState() {
_randomize();
}

void _randomize() {
_width = random.nextDouble() * 300;
_height = random.nextDouble() * 300;
_color = Color.fromRGBO(
random.nextInt(256),
random.nextInt(256),
random.nextInt(256),
1,
);
_borderRadius = BorderRadius.circular(
random.nextDouble() * min(_width, _height),
);
}

_AnimatedContainerExampleState의 builder 메소드에서는 Scaffold > (body: Center, floatingActionButton: FloatingActionButton > Icon) > Container로 위젯 트리를 구성합니다.

Container는 _AnimatedContainerExampleState의 _width, _height, _color, _borderRadius 속성을 사용하도록하고, FloatingActionButton의 onPressed에서는 setState를 호출해 리빌드를 시켜주는데 이때 각 속성이 랜덤한 값으로 변경되도록 _randomize 메소드를 호출합니다.

class _AnimatedContainerExampleState extends State<AnimatedContainerExample> {

/// 중략

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Container(
width: _width,
height: _height,
decoration: BoxDecoration(
borderRadius: _borderRadius,
color: _color,
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
_randomize();
});
},
child: const Icon(Icons.play_arrow),
),
);
}
}

실행하면 아래와 같은 동작을 확인할 수 있습니다.

Container의 속성을 암묵적으로 애니메이션 시키는 AnimatedContainer

우리는 A 상태에서 B 상태로 즉시 변경되는걸 애니메이션이라 하지 않습니다. A 상태에서 B 상태로 변경될 때 A와 B 의 중간 상태를 거쳐 변경될 때 애니메이션이라 부릅니다. AnimatedContainer는 이전 상태에서 새로운 상태의 중간값을 Duration으로 보간(interpolation)해 변경되는 과정을 표시합니다. 애니메이션을 위해서는 Duration이 필수 속성인 이유입니다. Container를 AnimatedContainer로 변경한 후 duration 속성으로 1초를 지정합니다.

class _AnimatedContainerExampleState extends State<AnimatedContainerExample> {

/// 중략

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: AnimatedContainer(
duration: const Duration(seconds: 1),
width: _width,
height: _height,
decoration: BoxDecoration(
borderRadius: _borderRadius,
color: _color,
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
_randomize();
});
},
child: const Icon(Icons.play_arrow),
),
);
}
}

Container를 AnimatedContainer로 변경하고, duration을 추가하는것 만으로 랜덤한 Container의 속성을 애니메이션을 통해 변경되도록 적용했습니다.

암묵적 애니메이션

한번 더 암묵적 애니메이션에 대해서 이야기 나눠봅시다. 다양한 개발 서적에서 Implicit는 암묵적이라는 단어로 번역됩니다. Implicit는 형용사로 다음 사전에 따르면 아래와 같은 뜻을 가지고 있습니다.

https://dic.daum.net/word/view.do?wordid=ekw000084594&q=Implicit

플러터에서 암묵적(Implicit) 애니메이션은 개발자가 직접 애니메이션을 실행하는 명령을 내리지 않아도 자동으로 동작하는 애니메이션을 의미합니다. 예를 들어, AnimatedContainer 위젯을 사용하면 컨테이너의 속성(예: 크기, 색상)을 변경할 때, 별도의 애니메이션 함수를 호출하지 않아도 부드러운 애니메이션 효과가 자동으로 적용됩니다. 단순히 변경될 속성과 애니메이션 지속 시간을 duration으로 전달하면, AnimatedContainer가 내부적으로 애니메이션 로직을 처리하여 이전 속성에서 새로운 속성으로 자연스럽게 변화하는 과정을 보여줍니다. 즉, 암묵적 애니메이션은 개발자가 애니메이션 구현에 대한 세부적인 코드를 작성하지 않고도 손쉽게 UI 요소에 애니메이션 효과를 추가할 수 있도록 도와주는 편리한 기능입니다.

AnimatedContainer 뜯어보기

AnimatedContainer가 내부적으로 애니메이션 로직을 어떻게 처리 하는지 살펴보며 플러터 애니메이션의 동작 원리를 더 깊게 이해해 봅시다. AnimatedContainer 클래스는 ImplicitlyAnimatedWidget을 확장하고 있습니다. ImplicitlyAnimatedWidget은 StatefulWidget을 확장한 추상 클래스로 ImplicitlyAnimatedWidgetState를 반환하는 createState 메소드를 확장한 클래스에서 구현하도록 위임하고 있습니다.

abstract class ImplicitlyAnimatedWidget extends StatefulWidget {
/// 중략
@override
ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState();
}

class AnimatedContainer extends ImplicitlyAnimatedWidget {
/// 중략
@override
AnimatedWidgetBaseState<AnimatedContainer> createState() => _AnimatedContainerState();
}

AnimatedContainer 위젯의 createState 함수는 AnimatedWidgetBaseState를 확장한 _AnimatedContainerState를 생성합니다. _AnimatedContainerState는 AnimatedWidgetBaseState 추상 클래스를 확장하고 있고, AnimatedWidgetBaseState 추상 클래스는 ImplicitlyAnimatedWidgetState 추상 클래스를 확장하고 있습니다. AnimatedContainer의 이러한 확장 구조의 State 객체들이 AnimationController와 다양한 종류의 속성값을 갱신하는 Tween 객체를 통해 애니메이션 로직을 구현하고 있습니다.

abstract class ImplicitlyAnimatedWidgetState<T extends ImplicitlyAnimatedWidget> extends State<T> with SingleTickerProviderStateMixin<T> {
/// The animation controller driving this widget's implicit animations.
@protected
AnimationController get controller => _controller;
late final AnimationController _controller = AnimationController(
duration: widget.duration,
debugLabel: kDebugMode ? widget.toStringShort() : null,
vsync: this,
);

/// The animation driving this widget's implicit animations.
Animation<double> get animation => _animation;
late CurvedAnimation _animation = _createCurve();

@override
void initState() {
super.initState();
_controller.addStatusListener((AnimationStatus status) {
switch (status) {
case AnimationStatus.completed:
widget.onEnd?.call();
case AnimationStatus.dismissed:
case AnimationStatus.forward:
case AnimationStatus.reverse:
}
});
_constructTweens();
didUpdateTweens();
}

@override
void didUpdateWidget(T oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.curve != oldWidget.curve) {
_animation.dispose();
_animation = _createCurve();
}
_controller.duration = widget.duration;
if (_constructTweens()) {
forEachTween((Tween<dynamic>? tween, dynamic targetValue, TweenConstructor<dynamic> constructor) {
_updateTween(tween, targetValue);
return tween;
});
_controller
..value = 0.0
..forward();
didUpdateTweens();
}
}

CurvedAnimation _createCurve() {
return CurvedAnimation(parent: _controller, curve: widget.curve);
}

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

bool _shouldAnimateTween(Tween<dynamic> tween, dynamic targetValue) {
return targetValue != (tween.end ?? tween.begin);
}

void _updateTween(Tween<dynamic>? tween, dynamic targetValue) {
if (tween == null) {
return;
}
tween
..begin = tween.evaluate(_animation)
..end = targetValue;
}

bool _constructTweens() {
bool shouldStartAnimation = false;
forEachTween((Tween<dynamic>? tween, dynamic targetValue, TweenConstructor<dynamic> constructor) {
if (targetValue != null) {
tween ??= constructor(targetValue);
if (_shouldAnimateTween(tween, targetValue)) {
shouldStartAnimation = true;
} else {
tween.end ??= tween.begin;
}
} else {
tween = null;
}
return tween;
});
return shouldStartAnimation;
}

@protected
void forEachTween(TweenVisitor<dynamic> visitor);

@protected
void didUpdateTweens() { }
}

abstract class AnimatedWidgetBaseState<T extends ImplicitlyAnimatedWidget> extends ImplicitlyAnimatedWidgetState<T> {
@override
void initState() {
super.initState();
controller.addListener(_handleAnimationChanged);
}

void _handleAnimationChanged() {
setState(() { /* The animation ticked. Rebuild with new animation value */ });
}
}

class _AnimatedContainerState extends AnimatedWidgetBaseState<AnimatedContainer> {
AlignmentGeometryTween? _alignment;
EdgeInsetsGeometryTween? _padding;
DecorationTween? _decoration;
DecorationTween? _foregroundDecoration;
BoxConstraintsTween? _constraints;
EdgeInsetsGeometryTween? _margin;
Matrix4Tween? _transform;
AlignmentGeometryTween? _transformAlignment;

@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_alignment = visitor(_alignment, widget.alignment, (dynamic value) => AlignmentGeometryTween(begin: value as AlignmentGeometry)) as AlignmentGeometryTween?;
_padding = visitor(_padding, widget.padding, (dynamic value) => EdgeInsetsGeometryTween(begin: value as EdgeInsetsGeometry)) as EdgeInsetsGeometryTween?;
_decoration = visitor(_decoration, widget.decoration, (dynamic value) => DecorationTween(begin: value as Decoration)) as DecorationTween?;
_foregroundDecoration = visitor(_foregroundDecoration, widget.foregroundDecoration, (dynamic value) => DecorationTween(begin: value as Decoration)) as DecorationTween?;
_constraints = visitor(_constraints, widget.constraints, (dynamic value) => BoxConstraintsTween(begin: value as BoxConstraints)) as BoxConstraintsTween?;
_margin = visitor(_margin, widget.margin, (dynamic value) => EdgeInsetsGeometryTween(begin: value as EdgeInsetsGeometry)) as EdgeInsetsGeometryTween?;
_transform = visitor(_transform, widget.transform, (dynamic value) => Matrix4Tween(begin: value as Matrix4)) as Matrix4Tween?;
_transformAlignment = visitor(_transformAlignment, widget.transformAlignment, (dynamic value) => AlignmentGeometryTween(begin: value as AlignmentGeometry)) as AlignmentGeometryTween?;
}

@override
Widget build(BuildContext context) {
final Animation<double> animation = this.animation;
return Container(
alignment: _alignment?.evaluate(animation),
padding: _padding?.evaluate(animation),
decoration: _decoration?.evaluate(animation),
foregroundDecoration: _foregroundDecoration?.evaluate(animation),
constraints: _constraints?.evaluate(animation),
margin: _margin?.evaluate(animation),
transform: _transform?.evaluate(animation),
transformAlignment: _transformAlignment?.evaluate(animation),
clipBehavior: widget.clipBehavior,
child: widget.child,
);
}

@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
/// 생략
}
}

AnimatedContainer가 애니메이션 실행을 요청하지 않아도 리빌드 시 자동으로 실행되는 핵심적인 코드인 ImplicitlyAnimatedWidgetState의 initState와 didUpdateWidget 메소드를 살펴봅시다. ImplicitlyAnimatedWidgetState는 AnimationController 프로퍼티를 late final로 선언되어 있어 변수에 접근하게 되면 초기화를 수행합니다. AnimationController 초기화 시에 AnimatedContainer의 duration으로 전달한 값이 AnimationController의 생성 시 사용됩니다. CurvedAnimation 속성은 _createCurve 함수를 호출해 객체를 생성하는데, CurvedAnimation에 AnimationController를 전달해 easing에 따른 보간 방식이 달라지도록합니다. 우리는 AnimatedContainer 생성 시 Curve에 아무런 값을 전달하지 않아 기본값인 Curves.linear를 통해 선형 보간으로 계산됩니다.

abstract class ImplicitlyAnimatedWidgetState<T extends ImplicitlyAnimatedWidget> extends State<T> with SingleTickerProviderStateMixin<T> {
@protected
AnimationController get controller => _controller;
late final AnimationController _controller = AnimationController(
duration: widget.duration,
debugLabel: kDebugMode ? widget.toStringShort() : null,
vsync: this,
);
Animation<double> get animation => _animation;
late CurvedAnimation _animation = _createCurve();

CurvedAnimation _createCurve() {
return CurvedAnimation(parent: _controller, curve: widget.curve);
}
/// 생략
}

initState에서는 _controller에 리스너를 추가해 애니메이션 상태를 처리하고, 트윈 객체를 생성합니다. didUpdateWidget은 StatefulWidget의 라이프사이클 메소드로 위젯이 리빌드될 때 이전 위젯과 현재 위젯의 상태를 비교해 다양한 처리를 할 때 사용됩니다. ImplicitlyAnimatedWidgetState의 didUpdateWidget에서는 _constructTweens 메소드를 호출해 애니메이션을 위한 속성별 Tween 객체를 만들고, AnimationController의 forward를 호출해 속성별 Tween 값이 업데이트되도록 합니다.

abstract class ImplicitlyAnimatedWidgetState<T extends ImplicitlyAnimatedWidget> extends State<T> with SingleTickerProviderStateMixin<T> {
/// 생략
@override
void initState() {
super.initState();
_controller.addStatusListener((AnimationStatus status) {
switch (status) {
case AnimationStatus.completed:
widget.onEnd?.call();
case AnimationStatus.dismissed:
case AnimationStatus.forward:
case AnimationStatus.reverse:
}
});
_constructTweens();
didUpdateTweens();
}

@override
void didUpdateWidget(T oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.curve != oldWidget.curve) {
_animation.dispose();
_animation = _createCurve();
}
_controller.duration = widget.duration;
if (_constructTweens()) {
forEachTween((Tween<dynamic>? tween, dynamic targetValue, TweenConstructor<dynamic> constructor) {
_updateTween(tween, targetValue);
return tween;
});
_controller
..value = 0.0
..forward();
didUpdateTweens();
}
}

bool _constructTweens() {
bool shouldStartAnimation = false;
forEachTween((Tween<dynamic>? tween, dynamic targetValue, TweenConstructor<dynamic> constructor) {
if (targetValue != null) {
tween ??= constructor(targetValue);
if (_shouldAnimateTween(tween, targetValue)) {
shouldStartAnimation = true;
} else {
tween.end ??= tween.begin;
}
} else {
tween = null;
}
return tween;
});
return shouldStartAnimation;
}
/// 생략
}

class _AnimatedContainerState extends AnimatedWidgetBaseState<AnimatedContainer> {
AlignmentGeometryTween? _alignment;
EdgeInsetsGeometryTween? _padding;
DecorationTween? _decoration;
DecorationTween? _foregroundDecoration;
BoxConstraintsTween? _constraints;
EdgeInsetsGeometryTween? _margin;
Matrix4Tween? _transform;
AlignmentGeometryTween? _transformAlignment;

@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_alignment = visitor(_alignment, widget.alignment, (dynamic value) => AlignmentGeometryTween(begin: value as AlignmentGeometry)) as AlignmentGeometryTween?;
_padding = visitor(_padding, widget.padding, (dynamic value) => EdgeInsetsGeometryTween(begin: value as EdgeInsetsGeometry)) as EdgeInsetsGeometryTween?;
_decoration = visitor(_decoration, widget.decoration, (dynamic value) => DecorationTween(begin: value as Decoration)) as DecorationTween?;
_foregroundDecoration = visitor(_foregroundDecoration, widget.foregroundDecoration, (dynamic value) => DecorationTween(begin: value as Decoration)) as DecorationTween?;
_constraints = visitor(_constraints, widget.constraints, (dynamic value) => BoxConstraintsTween(begin: value as BoxConstraints)) as BoxConstraintsTween?;
_margin = visitor(_margin, widget.margin, (dynamic value) => EdgeInsetsGeometryTween(begin: value as EdgeInsetsGeometry)) as EdgeInsetsGeometryTween?;
_transform = visitor(_transform, widget.transform, (dynamic value) => Matrix4Tween(begin: value as Matrix4)) as Matrix4Tween?;
_transformAlignment = visitor(_transformAlignment, widget.transformAlignment, (dynamic value) => AlignmentGeometryTween(begin: value as AlignmentGeometry)) as AlignmentGeometryTween?;
}
/// 생략
}

많은 플러터 내부 코드를 살펴봤음에도 Tween에 의해 변경되는 상태가 Container에 적용되는 코드는 살펴보지 않았는데, 원리는 단순합니다. AnimatedContainer의 build 메소드는 Container를 반환하는데, Container는 각 속성의 Tween 객체에서 값을 꺼내와 사용됩니다. AnimatedContainer가 확장하고 있는 ImplicitlyAnimatedWidget은 initState에서 AnimationController가 값이 변경될 때 마다 setState가 호출되되도록 addListener를 추가합니다.

abstract class AnimatedWidgetBaseState<T extends ImplicitlyAnimatedWidget> extends ImplicitlyAnimatedWidgetState<T> {
@override
void initState() {
super.initState();
controller.addListener(_handleAnimationChanged);
}

void _handleAnimationChanged() {
setState(() { /* The animation ticked. Rebuild with new animation value */ });
}
}

class AnimatedContainer extends ImplicitlyAnimatedWidget {
AlignmentGeometryTween? _alignment;
EdgeInsetsGeometryTween? _padding;
DecorationTween? _decoration;
DecorationTween? _foregroundDecoration;
BoxConstraintsTween? _constraints;
EdgeInsetsGeometryTween? _margin;
Matrix4Tween? _transform;
AlignmentGeometryTween? _transformAlignment;

/// 생략
@override
Widget build(BuildContext context) {
final Animation<double> animation = this.animation;
return Container(
alignment: _alignment?.evaluate(animation),
padding: _padding?.evaluate(animation),
decoration: _decoration?.evaluate(animation),
foregroundDecoration: _foregroundDecoration?.evaluate(animation),
constraints: _constraints?.evaluate(animation),
margin: _margin?.evaluate(animation),
transform: _transform?.evaluate(animation),
transformAlignment: _transformAlignment?.evaluate(animation),
clipBehavior: widget.clipBehavior,
child: widget.child,
);
}
/// 생략
}

AnimationController와 Tween으로 상태를 업데이트하고, AnimationController에 의해 setState가 호출되며 Tween의 상태로 위젯이 리빌드되는게 플러터 암묵적 애니메이션의 정체입니다. 흔히 AnimationController를 사용하면 명시적 애니메이션이라고 이야기하는 경우가 있는데 암묵적 애니메이션 역시 내부적으로는 AnimationController를 이용하고 있어 동작 원리는 큰 차이가 나지 않습니다. 암묵적 애니메이션을 동작 중심으로 다시 설명한다면 이렇게 됩니다.

AnimationController와 Tween 등 플러터 애니메이션 동작의 기반이 되는 객체를 이용해 선언형 UI에 애니메이션을 쉽게 적용할 수 있도록 만들어놓은 위젯을 사용한 애니메이션

끝으로

명시적 애니메이션을 자주 사용하던 플러터 개발자라면 내부 코드를 들여다봤을 때 뜻밖의 요소들에 놀라셨을지도 모르겠습니다. AnimationController, Tween, setState… 익숙한 이름 들이죠? 놀랍게도, 암묵적 애니메이션과 명시적 애니메이션은 동일한 원리로 작동합니다.

암묵적 애니메이션의 편리함에는 약간의 비용이 따릅니다. 내부적으로는 사용하지 않는 속성까지 처리하는 로직이 포함되어 있어, 명시적 애니메이션에 비해 성능 면에서 다소 불리할 수 있습니다. 그렇다고 해서 암묵적 애니메이션을 외면할 필요는 없습니다. 복잡한 애니메이션 로직을 직접 구현하는 수고를 덜어주고, 생산성을 크게 향상시켜주기 때문이죠. 다양한 암묵적 애니메이션 위젯을 활용하면, 짧은 시간 안에 멋진 애니메이션 효과를 구현할 수 있습니다.

이번 글에서는 암묵적 애니메이션의 작동 원리에 대해 살펴보았습니다. 다음 글에서는 애니메이션의 핵심 요소인 AnimationController와 Tween에 대해 더 자세히 알아보겠습니다. 플러터 애니메이션의 세계에 대한 이해를 한층 더 넓혀줄 것입니다.

그러면 언제나 그렇듯 Happy Coding👨‍💻

--

--

Cody Yun
Flutter Seoul

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