토스(Toss) 앱을 사용할 때마다 앱이 참 부드럽다는 느낌을 받으신 적이 있나요?
저는 늘 적재적소에 배치된 애니메이션을 보고 자주 감탄을 하곤 합니다.
특히 아래에서 위로 부드럽게 떠오르는(Slide-Up) 느낌의 애니메이션에 매료되어 해당 위젯을 플러터에서도 만들어보자 라는 생각이 들었는데요, 이번 포스팅에서는 토스 스타일의 Slide-Up 인터렉션을 플러터에서 구현하는 과정을 공유하려 합니다. 단순히 움직이는 것을 넘어, lerpDouble을 활용한 수학적 보간과 VisibilityDetector를 이용한 스크롤 시점 제어까지, 디테일한 구현 과정에 대해 설명드리겠습니다.
💡 본 포스팅에서 소개하는 SlideUpFadeIn 위젯을 쉽게 적용할 수 있도록 패키지를 개발하였습니다. 향후 더 많은 멋진 인터렉션을 해당 패키지에 추가할 에정이니 많은 관심 부탁드립니다!
package : cool_animation_flutter
github : https://github.com/yundal8755/cool_animations
저는 해당 인터렉션를 아래에서 위로 부드럽게 등장한다는 특징에 착안하여 SlideUpFadeIn 위젯이라고 정의했습니다. 단순히 위치만 이동하는 것이 아니라 투명도가 함께 변하는 것이 핵심입니다. GIF를 자세히 보시면 텍스트가 아주 먼 곳에서 날아오는 것이 아니라, Offset(0, 20) 정도의 아주 작은 폭으로 아래에서 위로 이동하며 동시에 불투명해지는 것을 볼 수 있습니다. 자연스럽게 콘텐츠가 등장하는 방식이죠. 이 짧은 거리의 이동과 페이드 효과가 결합되었을 때, 사용자는 정보가 갑자기 튀어나왔다가 아니라 "부드럽게 건네받았다"는 느낌을 받게 됩니다.
보통 플러터에서 애니메이션을 구현할 때 SlideTransition과 FadeTransition 위젯을 중첩해서 사용하곤 합니다. 하지만 이렇게 하면 위젯 트리가 깊어지고 코드가 복잡해질 수 있습니다.
저는 AnimatedBuilder와 lerpDouble 함수를 사용하여 더 깔끔하고 정교하게 제어하는 방식을 택했습니다. 여기서 lerpDouble이 무엇인지 잠깐 짚고 넘어가겠습니다.
lerp는 Linear interpolation(선형 보간)의 약자입니다. 쉽게 말해, 시작점(a)과 끝점(b) 사이의 특정 지점(t)에 있는 값을 계산해 주는 함수입니다. 수식으로 표현하면 a + (b - a) * t와 같습니다. 여기서 t는 애니메이션의 진행률을 나타내며 0.0(시작)에서 1.0(끝) 사이의 값을 가집니다.
t = 0.0 일 때: 시작점(a) 반환
t = 0.5 일 때: 딱 중간값 반환
t = 1.0 일 때: 끝점(b) 반환
가장 먼저 구현해야 할 것은 위젯이 아래에서 위로 올라오면서 투명도가 0에서 1로 변하는 로직입니다. 저는 AnimatedBuilder 안에서 lerpDouble을 사용해 이 두 가지 속성을 하나의 애니메이션 값(0.0 ~ 1.0)으로 동기화하여 제어했습니다.
return AnimatedBuilder(
animation: _curvedAnimation,
builder: (context, child) {
final t = _curvedAnimation.value;
// 시작 위치(beginOffset)에서 원래 위치(0)로 이동
final dx = lerpDouble(beginOffset.dx, 0, t)!;
final dy = lerpDouble(beginOffset.dy, 0, t)!;
// 투명도는 0.0 -> 1.0 으로 변화
final opacity = lerpDouble(0.0, 1.0, t)!.clamp(0.0, 1.0);
return Transform.translate(
offset: Offset(dx, dy),
child: Opacity(opacity: opacity, child: child),
);
},
child: widget.child,
);
이렇게 lerpDouble을 활용하면 애니메이션 컨트롤러가 실행될 때, 위젯이 살짝 아래(기본값 20px)에서 원래 위치로 스르륵 올라오며 선명해지는 효과를 깔끔한 코드로 구현할 수 있습니다.
여러 줄의 텍스트나 리스트 아이템이 한 번에 통째로 올라오면 시각적으로 부담스럽고 오히려 산만해 보일 수 있습니다. 토스 증권 탭이나 피드를 보면 아이템들이 아주 미세한 시차를 두고 톡톡 올라오는 걸 볼 수 있는데요. 이러한 시차 애니메이션(Staggered Animation)을 구현하기 위해 delay 속성을 추가했습니다.
Future<void> _scheduleAutoPlay() async {
if (_didAnimateOnce) return;
final delay = widget.delay;
// 설정된 딜레이만큼 기다렸다가 애니메이션 실행
if (delay != null && delay > Duration.zero) {
await Future<void>.delayed(delay);
}
if (!mounted) return;
play();
}
이제 Column 안에서 SlideFadeIn 위젯들의 delay를 조금씩 다르게 주면, 물 흐르듯 순차적으로 정보가 표시되는 고급스러운 연출이 가능해집니다.
Column(
children: [
SlideFadeIn(child: TitleText()),
// 100ms 뒤에 설명 등장
SlideFadeIn(delay: Duration(milliseconds: 100), child: DescriptionText()),
],
)
앱을 켜자마자 화면 저 아래(스크롤 해야 보이는 영역)에 있는 위젯들까지 애니메이션이 끝나버린다면, 사용자가 스크롤해서 내려갔을 땐 이미 정적인 화면만 보게 되겠죠. 사용자가 보는 그 순간, 애니메이션이 시작되어야 합니다. 이를 위해 visibility_detector 패키지를 도입했습니다.
visibility_detector란?
스크롤 뷰 내부에 있는 위젯이 현재 화면(Viewport)에 진입했는지, 얼마나 보이고 있는지를 감지해주는 라이브러리입니다. Flutter 프레임워크 자체적으로는 '현재 이 위젯이 화면에 그려지고 있는가?'를 판단하기 까다로운데, 이 패키지는 RenderObject를 통해 이를 쉽게 계산해줍니다.
위젯이 화면에 일정 비율 이상 보일 때(visibilityThreshold) 애니메이션을 시작하도록 트리거를 걸어주었습니다.
void _onVisibilityChanged(VisibilityInfo info) {
// 이미 애니메이션을 했거나 비활성화 상태면 패스
if (_didAnimateOnce || !widget.enabled || _isReduceMotionEnabled) return;
// 설정한 임계값(예: 10%) 이상 보이면 애니메이션 시작!
final isNowVisible = info.visibleFraction >= widget.visibilityThreshold;
if (isNowVisible && !_isVisible) {
_isVisible = true;
_scheduleAutoPlay();
}
}
triggerOnVisible: true 옵션만 켜주면, 긴 스크롤 화면에서도 사용자의 시선에 맞춰 위젯들이 살아 움직이게 됩니다.
이번 글에서는 토스 앱에서 느꼈던 부드러운 등장 효과를 SlideFadeIn이라는 위젯으로 직접 구현해 보았습니다.
처음에는 단순히 "위젯이 예쁘게 떴으면 좋겠다"는 생각으로 시작했지만, 스크롤 시점을 고려하고, 순차적인 등장까지 챙긴 모듈을 완성되었습니다.이제 여러분의 프로젝트에서도 복잡한 애니메이션 코드 없이, 이 위젯으로 감싸기만 하면 토스 스타일의 감성적인 UI를 손쉽게 구현하실 수 있을 겁니다. 이 기능이 포함된 cool_animation_flutter 패키지에도 많은 관심 부탁드립니다.
긴 글 읽어주셔서 감사합니다!