Flutter 튜토리얼 27편: 물리 기반 애니메이션

요약#

핵심 요지#

  • 문제 정의: 고정된 시간과 커브로 애니메이션하면 기계적이고 부자연스럽다.
  • 핵심 주장: 물리 시뮬레이션을 적용하면 현실 세계처럼 자연스러운 움직임을 만들 수 있다.
  • 주요 근거: AnimationController.animateWith()1Simulation2 객체를 전달하면 물리 법칙에 따라 애니메이션이 동작한다.
  • 실무 기준: 드래그 후 놓기, 스크롤 관성, 튕기는 효과 등에서 효과적으로 활용된다.
  • 한계: 시뮬레이션 매개변수 튜닝이 필요하고, 복잡한 물리 효과는 추가 계산이 필요하다.

문서가 설명하는 범위#

  • 물리 기반 애니메이션의 개념과 장점
  • SpringSimulation으로 튕기는 효과 구현
  • GravitySimulation으로 낙하 효과 구현
  • FrictionSimulation으로 감속 효과 구현
  • 제스처 속도를 애니메이션에 연결하기

읽는 시간: 15분 | 난이도: 중급


참고 자료#


문제 상황#

버튼을 탭하면 위젯이 움직이는 애니메이션을 만들었습니다.
durationcurve를 지정해서 동작하긴 하는데, 뭔가 어색합니다.

기계적인 느낌의 애니메이션#

고정 시간 애니메이션 물리 기반 애니메이션
시작 ──300ms──→ 끝 시작 ──?ms──→ 끝
(항상 똑같은 시간) (속도에 따라 달라짐)
천천히 밀어도 300ms 천천히 밀면 천천히
세게 밀어도 300ms 세게 밀면 빠르게

문제는 다음과 같습니다.

  • 사용자가 빠르게 드래그해도 애니메이션 속도가 똑같다.
  • 공을 던지면 포물선을 그려야 하는데, 직선으로 움직인다.
  • 스프링처럼 튕겨야 하는데, 한 번에 멈춘다.

현실 세계에서는 속도, 가속도, 마찰이 움직임에 영향을 줍니다.
Flutter에서도 이런 물리 법칙을 적용할 수 있습니다.


해결 방법#

Flutter의 physics 라이브러리는 현실적인 움직임을 시뮬레이션하는 클래스들을 제공합니다.
AnimationController.animateWith()에 시뮬레이션을 전달하면 duration 없이 물리 법칙에 따라 애니메이션이 동작합니다.

챕터 1: 물리 기반 애니메이션 이해하기#

Why#

NOTE

현실에서 공을 던지면 어떻게 될까요?
던진 속도, 중력, 공기 저항에 따라 자연스럽게 움직입니다.

애니메이션도 마찬가지입니다.
고정된 시간 대신 물리 법칙을 적용하면 사용자 입력에 반응하는 자연스러운 움직임을 만들 수 있습니다.

graph LR A[사용자 제스처] -->|속도 전달| B[물리 시뮬레이션] B -->|위치 계산| C[애니메이션 값] C -->|화면 갱신| D[위젯 움직임]

What#

NOTE

Flutter는 세 가지 주요 물리 시뮬레이션을 제공합니다.

시뮬레이션동작사용 예시
SpringSimulation3스프링처럼 튕김드래그 후 원위치로 복귀
GravitySimulation4일정한 가속도로 낙하떨어지는 물체
FrictionSimulation5점점 느려지다 멈춤스크롤 관성

이들의 공통점은 Simulation 클래스를 상속한다는 것입니다.
모든 시뮬레이션은 x(time), dx(time), isDone(time) 메서드를 가집니다.

How#

TIP

기본 패턴은 이렇습니다.

1단계: physics 라이브러리 임포트

import 'package:flutter/physics.dart';

2단계: 시뮬레이션 생성

final simulation = SpringSimulation(
SpringDescription(mass: 1, stiffness: 100, damping: 10),
0, // 시작 위치
1, // 끝 위치
-500, // 초기 속도 (픽셀/초)
);

3단계: animateWith()로 애니메이션 실행

_controller.animateWith(simulation);

핵심 포인트:
duration을 지정하지 않습니다.
시뮬레이션이 스스로 “언제 끝날지” 계산합니다.

Watch out#

WARNING

AnimationController 설정 주의

물리 시뮬레이션 사용 시 AnimationControllerduration을 지정하지 않아도 됩니다.
하지만 vsync는 반드시 필요합니다.

// ✅ 물리 시뮬레이션용 컨트롤러
_controller = AnimationController(vsync: this);
// ❌ duration이 있어도 되지만, animateWith()가 무시함
_controller = AnimationController(
vsync: this,
duration: Duration(seconds: 1), // animateWith()에서 무시됨
);

결론: animateWith(simulation)을 사용하면 duration 없이 물리 법칙에 따라 애니메이션이 동작합니다.


챕터 2: SpringSimulation으로 튕기는 효과#

Why#

NOTE

드래그한 카드를 놓으면 원래 위치로 돌아가야 합니다.
단순히 직선으로 돌아가면 딱딱한 느낌이 듭니다.

스프링처럼 튕기면서 돌아가면 훨씬 자연스럽습니다.
실제로 많은 iOS/Android 앱에서 이 효과를 사용합니다.

What#

NOTE

SpringSimulation은 스프링에 매달린 물체의 움직임을 시뮬레이션합니다.

SpringDescription 매개변수:

매개변수의미값이 크면?
mass물체의 질량무거워서 느리게 움직임
stiffness스프링의 강성빠르게 원위치로 복귀
damping감쇠 (저항)튕김이 빨리 멈춤
stiffness 높음 + damping 낮음 = 많이 튕김
stiffness 낮음 + damping 높음 = 부드럽게 정착

How#

TIP

드래그 후 튕기며 돌아오는 카드 만들기

핵심 코드만 보겠습니다.

class _DraggableCardState extends State<DraggableCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
Alignment _dragAlignment = Alignment.center;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
_controller.addListener(() {
setState(() {
_dragAlignment = _animation.value;
});
});
}
void _runAnimation(Offset velocity, Size size) {
// 속도를 애니메이션 단위로 변환
final pixelsPerSecond = velocity;
final unitsPerSecondX = pixelsPerSecond.dx / size.width;
final unitsPerSecondY = pixelsPerSecond.dy / size.height;
final unitVelocity = Offset(unitsPerSecondX, unitsPerSecondY).distance;
// 스프링 설정
const spring = SpringDescription(
mass: 1,
stiffness: 100,
damping: 10,
);
// 시뮬레이션 생성 (현재 위치 → 중앙으로)
final simulation = SpringSimulation(spring, 0, 1, -unitVelocity);
// Tween 설정
_animation = _controller.drive(
AlignmentTween(begin: _dragAlignment, end: Alignment.center),
);
_controller.animateWith(simulation);
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return GestureDetector(
onPanUpdate: (details) {
setState(() {
_dragAlignment += Alignment(
details.delta.dx / (size.width / 2),
details.delta.dy / (size.height / 2),
);
});
},
onPanEnd: (details) {
_runAnimation(details.velocity.pixelsPerSecond, size);
},
child: Align(
alignment: _dragAlignment,
child: Card(child: FlutterLogo(size: 100)),
),
);
}
}

매개변수 튜닝 가이드:

효과massstiffnessdamping
가볍게 튕김0.520010
무겁게 튕김21005
부드럽게 정착15015
빠르게 정착130020

Watch out#

WARNING

속도 단위 변환을 잊지 마세요

GestureDetector의 속도는 픽셀/초 단위입니다.
AnimationController0~1 범위에서 동작합니다.

변환 공식:

// 픽셀/초 → 애니메이션 단위
final unitVelocityX = pixelsPerSecond.dx / screenWidth;
final unitVelocityY = pixelsPerSecond.dy / screenHeight;

변환하지 않으면 애니메이션이 너무 빠르거나 느려집니다.

결론: SpringSimulationSpringDescription으로 튕기는 효과를 만들고, 매개변수를 조절해 원하는 느낌을 만듭니다.


챕터 3: GravitySimulation으로 낙하 효과#

Why#

NOTE

게임에서 물체가 떨어질 때 중력의 영향을 받습니다.
처음에는 천천히, 점점 빠르게 떨어집니다.

이런 등가속도 운동을 구현하려면 GravitySimulation을 사용합니다.

What#

NOTE

GravitySimulation은 일정한 가속도로 물체가 움직이는 것을 시뮬레이션합니다.

생성자 매개변수:

GravitySimulation(
acceleration, // 가속도 (픽셀/초²)
distance, // 시작 위치
endDistance, // 종료 조건 (이 거리를 넘으면 끝)
velocity, // 초기 속도
)
매개변수의미예시 값
acceleration중력 가속도9.8 (현실), 500~2000 (앱)
distance시작 위치0.0
endDistance바닥 위치화면 높이
velocity초기 속도0.0 (정지 상태에서 시작)

How#

TIP

떨어지는 공 애니메이션

void _startFalling() {
final screenHeight = MediaQuery.of(context).size.height;
final simulation = GravitySimulation(
1000, // 가속도: 아래로 1000 픽셀/초²
0, // 시작: 맨 위 (0)
screenHeight, // 끝: 화면 바닥
0, // 초기 속도: 0 (정지 상태)
);
_controller.animateWith(simulation);
}

위로 던지고 떨어지는 효과:

final simulation = GravitySimulation(
500, // 가속도
screenHeight, // 시작: 화면 아래
0, // 끝: 화면 위
-800, // 초기 속도: 위로 800 픽셀/초
);

초기 속도가 음수면 위로 올라갔다가 중력에 의해 아래로 떨어집니다.

Watch out#

WARNING

endDistance는 방향에 따라 다르게 설정

GravitySimulation은 위치가 endDistance초과하면 종료됩니다.

// 아래로 떨어질 때: endDistance > 시작 위치
GravitySimulation(500, 0, 300, 0); // 0에서 시작, 300 넘으면 끝
// 위로 던질 때: 주의! endDistance 설정에 따라 다름
// 위로 갔다가 아래로 돌아올 때까지 기다리려면 endDistance를 크게

결론: GravitySimulation으로 떨어지거나 던지는 물리 효과를 구현합니다.


챕터 4: FrictionSimulation으로 감속 효과#

Why#

NOTE

스크롤을 빠르게 밀고 손을 떼면 어떻게 될까요?
바로 멈추지 않고 서서히 느려지다가 멈춥니다.

이것이 마찰(friction) 효과입니다.
FrictionSimulation은 이런 감속 움직임을 시뮬레이션합니다.

What#

NOTE

FrictionSimulation은 마찰력에 의해 점점 느려지는 움직임을 시뮬레이션합니다.

생성자 매개변수:

FrictionSimulation(
drag, // 마찰 계수 (단위 없음)
position, // 시작 위치
velocity, // 초기 속도
)
매개변수의미값이 크면?
drag마찰 계수빨리 멈춤
position시작 위치-
velocity초기 속도더 멀리 이동

drag 값 가이드:

drag 값효과사용 예시
0.01~0.05매우 미끄러움빙판 위
0.1~0.3적당한 마찰일반 스크롤
0.5~1.0높은 마찰빠른 정지

How#

TIP

스크롤 관성 효과 구현

void _onPanEnd(DragEndDetails details) {
final velocity = details.velocity.pixelsPerSecond.dx;
final simulation = FrictionSimulation(
0.15, // 마찰 계수
_offset, // 현재 위치
velocity, // 드래그 속도
);
_controller.animateWith(simulation);
}

팩토리 생성자로 정확한 위치까지 이동:

// 시작 위치에서 끝 위치까지, 지정한 속도로 감속 이동
final simulation = FrictionSimulation.through(
0, // 시작 위치
300, // 끝 위치
100, // 시작 속도
0, // 끝 속도 (멈춤)
);

through() 팩토리는 원하는 시작/끝 조건에 맞는 drag 값을 자동 계산합니다.

Watch out#

WARNING

시뮬레이션 종료 조건

FrictionSimulation은 속도가 거의 0이 되면 종료됩니다.
drag 값이 너무 작으면 매우 오래 걸릴 수 있습니다.

// tolerance로 종료 조건 조절
final simulation = FrictionSimulation(
0.05,
0,
1000,
tolerance: Tolerance(velocity: 0.1), // 속도가 0.1 이하면 종료
);

결론: FrictionSimulation으로 스크롤 관성처럼 서서히 멈추는 효과를 구현합니다.


챕터 5: 제스처 속도를 애니메이션에 연결하기#

Why#

NOTE

물리 시뮬레이션의 핵심은 사용자 입력을 반영하는 것입니다.
빠르게 밀면 빠르게, 천천히 밀면 천천히 반응해야 자연스럽습니다.

GestureDetectoronPanEnd에서 제공하는 velocity를 시뮬레이션에 전달하면 됩니다.

What#

NOTE

DragEndDetails는 드래그가 끝날 때의 속도 정보를 제공합니다.

onPanEnd: (DragEndDetails details) {
final velocity = details.velocity.pixelsPerSecond;
// velocity.dx: 수평 속도 (픽셀/초)
// velocity.dy: 수직 속도 (픽셀/초)
}

이 속도를 시뮬레이션의 velocity 매개변수로 전달합니다.

How#

TIP

완전한 드래그-스프링 애니메이션 예제

import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
class PhysicsCard extends StatefulWidget {
@override
_PhysicsCardState createState() => _PhysicsCardState();
}
class _PhysicsCardState extends State<PhysicsCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Alignment> _animation;
Alignment _dragAlignment = Alignment.center;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
_controller.addListener(() {
setState(() => _dragAlignment = _animation.value);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _runAnimation(Offset pixelsPerSecond, Size size) {
// 1. Tween 설정: 현재 위치 → 중앙
_animation = _controller.drive(
AlignmentTween(begin: _dragAlignment, end: Alignment.center),
);
// 2. 속도 변환: 픽셀/초 → 애니메이션 단위
final unitsPerSecond = Offset(
pixelsPerSecond.dx / size.width,
pixelsPerSecond.dy / size.height,
);
final unitVelocity = unitsPerSecond.distance;
// 3. 스프링 시뮬레이션 생성
const spring = SpringDescription(
mass: 1,
stiffness: 180,
damping: 15,
);
final simulation = SpringSimulation(spring, 0, 1, -unitVelocity);
// 4. 애니메이션 실행
_controller.animateWith(simulation);
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return GestureDetector(
onPanDown: (_) => _controller.stop(), // 드래그 시작: 애니메이션 중지
onPanUpdate: (details) {
setState(() {
_dragAlignment += Alignment(
details.delta.dx / (size.width / 2),
details.delta.dy / (size.height / 2),
);
});
},
onPanEnd: (details) {
_runAnimation(details.velocity.pixelsPerSecond, size);
},
child: Align(
alignment: _dragAlignment,
child: Card(
child: FlutterLogo(size: 128),
),
),
);
}
}

코드 흐름 요약:

graph TD A[onPanDown] -->|_controller.stop| B[드래그 시작] B --> C[onPanUpdate] C -->|_dragAlignment 갱신| C C --> D[onPanEnd] D -->|velocity 전달| E[_runAnimation] E -->|SpringSimulation| F[animateWith] F -->|addListener| G[setState]

Watch out#

WARNING

onPanDown에서 진행 중인 애니메이션을 멈추세요

카드가 튕기며 돌아오는 중에 다시 드래그하면 어떻게 될까요?
애니메이션이 계속 진행되면서 드래그가 꼬입니다.

onPanDown: (details) {
_controller.stop(); // 필수!
},

이 한 줄로 “드래그하면 애니메이션 중지”를 보장합니다.

결론: GestureDetector의 속도 정보를 시뮬레이션에 전달하면 사용자 입력에 반응하는 자연스러운 애니메이션이 됩니다.


챕터 6: 시뮬레이션 조합하기#

Why#

NOTE

실제 앱에서는 하나의 시뮬레이션만 사용하지 않을 수 있습니다.
예를 들어, 위로 던지고(Gravity) → 바닥에서 튕기기(Spring) 같은 조합이 필요할 수 있습니다.

Flutter는 시뮬레이션을 순차적으로 연결할 수 있습니다.

What#

NOTE

시뮬레이션이 끝났는지 확인하려면 isDone(time) 또는 컨트롤러의 status를 사용합니다.

_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
// 다음 시뮬레이션 시작
_startNextSimulation();
}
});

How#

TIP

던지고 튕기는 공

void _throwAndBounce(double throwVelocity) {
// 1단계: 위로 던지기 (중력 시뮬레이션)
final gravitySimulation = GravitySimulation(
500, // 중력
0, // 시작 위치
300, // 바닥 위치
-throwVelocity, // 위로 던지는 속도
);
_controller.animateWith(gravitySimulation);
// 2단계: 바닥에 닿으면 튕기기
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
final bounceSimulation = SpringSimulation(
SpringDescription(mass: 1, stiffness: 500, damping: 20),
_controller.value, // 현재 위치
0, // 목표: 바닥
0, // 속도: 0 (정지)
);
_controller.animateWith(bounceSimulation);
}
});
}

Watch out#

WARNING

상태 리스너 중복 등록 주의

addStatusListener를 여러 번 호출하면 리스너가 중복 등록됩니다.

// ❌ 매번 새 리스너 추가
void _runAnimation() {
_controller.addStatusListener((status) { ... }); // 누적됨!
_controller.animateWith(simulation);
}
// ✅ initState에서 한 번만 등록
@override
void initState() {
super.initState();
_controller.addStatusListener(_onAnimationStatusChanged);
}

결론: 시뮬레이션을 순차적으로 연결하면 복잡한 물리 효과를 만들 수 있습니다.


한계#

물리 기반 애니메이션은 자연스럽지만 모든 상황에 적합하지는 않습니다.

  • 매개변수 튜닝: mass, stiffness, damping 등의 값을 찾는 데 시행착오가 필요합니다.
  • 예측 불가능한 시간: 애니메이션 종료 시점을 정확히 알기 어렵습니다.
  • 복잡한 물리: 충돌, 회전, 다중 물체 상호작용은 추가 구현이 필요합니다.
  • 성능: 매우 복잡한 시뮬레이션은 프레임 드롭을 유발할 수 있습니다.

Footnotes#

  1. animateWith(시뮬레이션): AnimationController의 메서드로, duration 대신 Simulation 객체에 따라 애니메이션을 실행한다.

  2. Simulation(시뮬레이션): Flutter physics 라이브러리의 추상 클래스로, x(time), dx(time), isDone(time) 메서드를 통해 시간에 따른 위치와 속도를 계산한다.

  3. SpringSimulation(스프링 시뮬레이션): 스프링에 매달린 물체의 움직임을 시뮬레이션하는 클래스다. 후크의 법칙을 따른다.

  4. GravitySimulation(중력 시뮬레이션): 일정한 가속도로 물체가 움직이는 것을 시뮬레이션하는 클래스다. 뉴턴의 제2법칙을 따른다.

  5. FrictionSimulation(마찰 시뮬레이션): 마찰력에 의해 점점 느려지는 움직임을 시뮬레이션하는 클래스다. 유체 저항을 모델링한다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 27편: 물리 기반 애니메이션
https://moodturnpost.net/posts/flutter/flutter-physics-animations/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차