앱에 인터랙션 잔뜩 추가하면서 디자이너와 개발자가 사이좋게 지내는 법

Sylas
BlueSignum Tech Blog
24 min readJul 9, 2024

이제 모바일 서비스에서 자연스럽고 눈길을 끄는 인터랙션은 필수입니다.

오늘날의 모바일 애플리케이션이 유저에게 좋은 사용 경험을 주기 위해서는 유용한 피쳐나 창의적인 아이디어 그 자체로는 부족합니다. 적절한 인터랙션이나 애니메이션을 통해 자연스럽게 눈길을 끌고 몰입도를 높여주는 좋은 시각 경험이 필수적인데요.

모바일 디바이스의 칩 성능이 높아지고 렌더링 엔진 성능이 개선되면서 아무리 복잡한 그래픽 렌더링도 무리 없이 매끄럽게 그려낼 수 있는 시대가 되었습니다. 이에 자연스럽게 디자이너는 유저에게 최고의 시각 경험을 제공하기 위해 앱 곳곳에 유저의 참여도를 높일 수 있는 mirco-interaction 를 추가하는 등 적극적으로 복잡한 애니메이션을 개발하게 되었습니다.

하지만 문제는… 애니메이션을 개발하는 건 여전히 정말 어렵다는 것입니다. 어느 시점에 애니메이션을 시작할지, 도형의 어떤 특성이 어떤 곡선을 따라 어떤 속도로 어떻게 변화할지, 언제 애니메이션을 초기화하게 될지, 앱 내 state 에 따라서 애니메이션이 다르게 재생되어야 할 경우에는 어떻게 대응해야 할지… 모든 걸 일일이 정의하고 구현해줘야 하기 때문에 애니메이션 개발, 특히 인터랙션 개발은 static 한 UI를 개발하는 과정에 비해 몇 배나 복잡하고 어려운 작업입니다. 아주 작고 사소해 보이는 작업, 이를테면 좋아요 버튼을 탭 하면 아이콘이 커지면서 색이 바뀌고 돌아오는 것, 도 고민해야 할 것들이 한두 가지가 아닌 것이죠! 이렇게 개발자와 디자이너의 사이는 안 좋아지기 시작합니다.

디자이너 : 개발자님, 여기를 탭 하면 이렇게 애니메이션이 재생되고요, 여기를 스크롤하면 저렇게…

하지만 무디에는 수십 종류 이상의 애니메이션과 인터랙션이 적용되어 있음에도 불구하고 디자이너와 앱 개발자의 사이가 무척 좋은데요! 🤓 그러면서도 여러 복잡한 인터랙션을 디자이너의 의도대로 빠르게 앱에 녹여내고 있습니다. 이번 아티클에서는 그 비법을 공유해드리고자 합니다.

잠자는 무디를 깨우는 예시 애니메이션을 함께 보실까요?

이 애니메이션을 어떻게 구현해야 할지, 제가 기획서를 쓰는 입장으로 간략하게 묘사해 보았습니다.

애니메이션이 시작한 직후에는 무디가 졸고 있다.
조는 중에는 무디가 숨을 쉬는 효과를 위해 몸이 부풀어 올랐다 수축한다.
조는 중에는 동시에 무디의 고개가 회전한다.
사용자가 무디를 탭 하면 무디가 깜짝 놀란다. 눈 도형이 호 모양에서 타원으로 변환되고, 모자가 튀어 올랐다가 내려오며, 팔이 펴졌다가 다시 돌아온다.
n초 뒤에 다시 눈 도형이 호 모양으로 변환되며, 졸고 있는 상태로 돌아온다.

벌써 머리가 아픈데요… 자는 중이 아닐 때에는 몸이 부풀어 오르는 애니메이션을 재생하면 안 되겠네요. 무디를 탭 하면 flag 를 세팅해서 깨어나는 애니메이션이 재생되어야 하겠고요. 자는 중일 때만 Tab 이벤트가 작동해야 할 테니 이것도 분기를 쳐줘야 할 것 같고요. n초를 기다렸다가 졸고 있는 상태로 돌아가는 애니메이션을 재생하고 flag를 변경해줘야 하니 Timer 객체를 사용해서 콜백 수행을 지연시켜 줘야겠고요. 자연스레 메모리 leak 을 관리해줘야 하니 Timer 를 제때 잘 dispose 하도록 신경 써줘야겠네요. 또 깨어나는 애니메이션을 재생하기 시작해야 하는 시점에서 무디가 얼마나 부풀어져 있는지 모르기 때문에 별개의 애니메이션을 이어 붙여서 재생할 수는 없고, 프레임 별로 자연스럽게 이어지도록 직접 구현해야겠네요. 🤯

이렇게 직접 구현하기도 전에 떠오르는 포인트만 해도 한두 가지가 아니네요. 실제로 위 애니메이션을 구현하려고 하면 더 많은 고려 사항들이 발생할 것입니다. 몇 백 줄 이상의 코드를 짜야하고, 수 시간이 소요될 것입니다. 이미 열심히 디자이너가 공들여 구현한 애니메이션인데, 그걸 다시 앱 런타임으로 옮겨오는 데에도 너무나 많은 공수가 들어가는 것이죠. 그렇다면 무디팀이 실제로 위 애니메이션을 Flutter 앱에 구현하기까지 몇 줄의 코드가 필요했을까요? 놀랍게도 80줄 내외였고, 보일러플레이트를 제외하면 15줄이면 충분했습니다!

강력한 인터랙티브 모션 그래픽 제작 툴 Rive 를 소개합니다.

비밀은 Rive 라는 강력한 애니메이션 제작 툴에 있습니다. Rive 는 애니메이션 제작, 특히 인터랙티브 애니메이션 제작에 특화된 디자인 툴인데요. 애니메이션의 상태 관리, Tab Event, Hover Event 등의 이벤트 처리, 외부 입력에 대한 Trigger 처리 등을 디자이너가 처리할 수 있도록 기능을 제공해 디자인과 개발 간의 격차를 해소하고, 반복 작업을 최소화할 수 있습니다.

Rive Editor 로 제작한 애니메이션은 별도의 변환 처리 없이 다양한 Platform 에서 지원 가능합니다. 즉, 하나의 파일을 웹에서도, 플러터에서도, 데스크탑 앱에서도 사용할 수 있다는 뜻이죠!

Rive가 제공하는 직관적인 피쳐들 덕분에 디자이너는 큰 러닝 커브 없이 복잡한 애니메이션을 구현할 수 있고, 개발자는 디자이너가 개발한 애니메이션을 재구현할 필요 없이 손쉽게 앱 런타임에 반영할 수 있습니다.

Rive 공식 웹에서 다양한 창의적인 Usecase 를 확인할 수 있습니다! 비밀번호 입력 시점을 감지해서 눈을 가리는 캐릭터가 인상적이네요.

Rive의 모션 그래픽 개발 프로세스

Finite State Machine 기반의 상태 관리

컴퓨터 공학을 전공하신 분들은 모두 Finite State Machine(이하 FSM) 이라는 단어가 익숙하실 텐데요. 저는 학부 1학년 때 논리 설계라는 수업에서 처음 이 개념을 접했던 기억이 납니다. FSM 은 간단히 state와 transition 으로 어떤 시스템의 동작을 모델링하는 기법입니다. 유한하고 선별적으로 정의된 Transition 을 통해서만 유한하게 정의된 State 로 진입할 수 있기 때문에, 설계 때 예상하지 못했던 상태로 진입하는 것을 원천 차단할 수 있는 모델이기도 하고, 시스템의 동작을 일목요연하게 확인하고 이해할 수 있는 직관적인 모델이기도 합니다. Rive 는 이 FSM을 기반으로 애니메이션을 개발합니다.

무디 깨우기 애니메이션의 State Machine

무디 깨우기 애니메이션을 일전의 방식대로 Imperative 한 방식이 아니라, State Machine 으로 모델링하면 어떻게 될까요? 우선 애니메이션의 State 는 크게 3가지로 나눌 수 있겠습니다. 무디가 깨어있는 default 상태, 무디가 잠들어 졸고 있는 sleeping 상태, 그리고 무디가 놀라며 깨는 wakeup 상태로 나누면 충분할 것 같습니다.

그리고 각 state 간 전환이 일어나는 transition 을 정의하면 됩니다. 처음에 애니메이션이 실행되면 default state에서 시작하면 됩니다. default와 sleeping 은 기본적으로 loop를 돌면서 반복되고요. default 에서 n초가 지나면 sleeping state 로 전환이 이루어져야 합니다. tab 이벤트가 있으면 sleeping state 에서 wake up state 로 transition 이 이루어지고, wakeup state 재생이 끝나면 직후에 default state 로 전환이 이루어지면 되겠습니다.

3개의 state와 4개의 transition (2개의 loop) 으로 복잡해 보였던 인터랙티브 애니메이션을 모델링할 수 있었습니다. Rive Editor 에서는 Adobe AE 와 같은 다른 그래픽 제작 소프트웨어와 유사한 방식의 애니메이션 제작 툴을 제공하고 있기 때문에 손쉽게 각 애니메이션을 구현할 수 있습니다!

App runtime 에서 State를 조작할 수 있는 Input 을 주입

앱이 실행되고 있는 런타임에서 Rive로 특정 값을 전달하는 것도 가능합니다! Rive Editor 에서 미리 input을 정의할 수 있고, 입력되었을 때 input 값에 따라 Transition 을 Trigger할 수도 있고, Blend 기능을 이용해 두 State를 원하는 비율로 혼합한 중간 State를 만들 수도 있습니다. Input 은 Number, Boolean, Trigger 타입을 제공합니다.

애니메이션 내부에서 발생한 Event 를 감지하는 Listener

Rive 애니메이션 내부에서 발생하는 이벤트를 감지해서 처리하는 것도 가능한데요. 각 엘리먼트에 대해서 Pointer Down, Pointer Up, Pointer Exit 등의 이벤트를 감지할 수 있고, Input 값을 변경하거나, 런타임으로 Event를 Notify 시키는 것도 가능합니다.

무디에서는 현재 감정 Top 5를 버블 차트 형식으로 표시해주고 있는데요. 5개의 차트가 하나의 Rive 파일 안에서 구현되어 있기 때문에 Flutter 에서는 GestureDetector 등으로 어떤 버블을 탭 했는지 파악하는 데 한계가 있습니다. 아래 예시처럼 Rive 에서 각 엘리멘터에 Listener 를 연결해 각 버블을 탭 할 때 선택된 감정 Input 값을 설정하고, 이 값을 받아 와 Rive 외부 데이터를 조작할 수 있습니다. 현재 선택된 감정이 ‘피곤한'이라는 상태를 전달해 ‘피곤한’과 함께 기록된 키워드를 UI에 표시할 수 있게 되는 것이죠! 😄

여러 State Machine을 결합해 사용할 수 있는 Layer

뿐만 아니라 여러 State Machine을 결합해 하나의 애니메이션에 녹여낼 수 있는 Layer 도 지원하기 때문에, State Machine 이 복잡해지는 것을 방지하고 관심사를 분리하여 직관적이고 깔끔하게 애니메이션을 개발할 수 있습니다!

손쉽게 Flutter 프레임워크에 녹아드는 Runtime

개발자 관점에서 Rive의 가장 큰 장점이라고 생각하는데요. 바로 디자이너가 구현한 애니메이션을 손쉽게 불러오는 것만으로 대부분의 작업이 끝난다는 점입니다. 단순한 애니메이션 재생이라면, 아래와 같이 구현하는 것만으로도 충분합니다.

class MyRiveAnimation extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: RiveAnimation.asset(
'assets/vehicles.riv',
),
),
);
}
}

Input을 연결하거나, Event Listener 를 연결하는 경우에는 onInit 콜백을 구현해 줌으로써 구현할 수 있습니다. 아래는 Trigger Type의 Input을 구현하는 예시입니다. onInit 콜백에서 stateMachineController 객체를 이름으로 찾아오고, 다시 Trigger Input을 ‘bump’라는 이름으로 검색해 찾아와서 Input 객체를 찾아온 후에 Flutter의 State 객체 안에 정의된 변수에 할당합니다. 이렇게 얻어온 Input 은 다른 위젯에서의 onTapEvent 등으로 활용할 수 있습니다.

class SimpleStateMachine extends StatefulWidget {
const SimpleStateMachine({Key? key}) : super(key: key);

@override
_SimpleStateMachineState createState() => _SimpleStateMachineState();
}

class _SimpleStateMachineState extends State<SimpleStateMachine> {
SMITrigger? _bump;

void _onRiveInit(Artboard artboard) {
final controller = StateMachineController.fromArtboard(artboard, 'bumpy');
artboard.addController(controller!);
_bump = controller.findInput<bool>('bump') as SMITrigger;
}

void _hitBump() => _bump?.fire();

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Simple Animation'),
),
body: Center(
child: GestureDetector(
child: RiveAnimation.network(
'https://cdn.rive.app/animations/vehicles.riv',
fit: BoxFit.cover,
onInit: _onRiveInit,
),
onTap: _hitBump,
),
),
);
}
}

Rive 내부에서 일어나는 Event 를 Listening 하는 것도 마찬가지입니다. Listener Callback을 구현해서 onInit 콜백 안에서 추가해 주면 됩니다.

void onRiveEvent(RiveEvent event) {
print(event);
}

...

RiveAnimation.asset(
'assets/rating.riv',
onInit: (Artboard artboard) {
// Get State Machine Controller for the state machine
final controller = StateMachineController.fromArtboard(artboard, 'State Machine 1');
controller.addEventListener(onRiveEvent);
artboard.addController(controller!);
},
)

TextRun 으로 런타임에 텍스트 값 주입

뿐만 아니라 동적으로 애니메이션 내부의 텍스트를 변경하는 것도 가능한데요. 스테이지 번호, 닉네임, 점수 등 다양한 값을 가질 수 있는 텍스트를 일일이 직접 구현하는 것이 아니라 TextRun 기능을 사용해서 동적으로 주입할 수 있습니다.

App Runtime 에 애니메이션 내부 텍스트 값을 변경할 수 있습니다.
extension _TextExtension on Artboard {
TextValueRun? textRun(String name) => component<TextValueRun>(name);
}
RiveAnimation.asset(
'assets/hello_world_text.riv',
animations: const ['Timeline 1'],
onInit: (artboard) {
final textRun = artboard.textRun('MyRun')!; // find text run named "MyRun"
print('Run text used to be ${textRun.text}');
textRun.text = 'Hi Flutter Runtime!';
},
)

자세한 가이드는 Rive 공식 문서에서 확인할 수 있습니다.

Moodee 곳곳에는 특별한 애니메이션이 가득해요

무디는 Rive 를 활용해서 구현이 복잡할 수 있었던 애니메이션과 인터랙션을 손쉽고 빠르게 적용할 수 있었습니다. 무디가 살고 있는 곳의 하늘이 전환되는 애니메이션이라든지, 팝업 속 애니메이션이라든지, 로딩 중 아이콘이나 바텀 내비게이션 아이콘이라든지요! 곳곳에 특별한 유저 경험을 제공하기 위해 구현된 인터랙티브 애니메이션을 찾아보시면 무척 재밌으실 거예요 😇

무디의 하늘에는 오로라도 떴다가, 별도 뜹니다. 자연스럽게요! (GIF 퀄리티가 좋지 않네요.)

Rive 가 가져다준 혁신적인 퍼포먼스 향상

핸드오프가 편해졌어요

Rive 라는 툴의 가장 큰 의의는 디자이너와 개발자 간의 ‘인터페이스' 가 잘 정의될 수 있게 되었다는 것입니다. 개발자는 애니메이션이 구체적으로 어떻게 구현되어 있는지에 대해서는 전혀 신경 쓸 필요가 없습니다. 단지 앱에서 구현하기 위해 알아야 하는 최소한의 정보 (Input 의 종류, 제공받을 수 있는 Event 의 종류, Artboard 와 State Machine Controller 이름)만 알고 있고, 어떻게 앱과 Rive 간의 데이터가 교환되는지만 공유해 주면 됩니다. 개발자는 실제로 애니메이션이 얼마나 소요되는지, 어떻게 동작하는지는 전혀 알 필요가 없이 깔끔하고 직관적으로 코드를 관리할 수 있고, 디자이너는 본인이 구현한 디자인이 그대로 앱에 들어갈 것이라고 보장받을 수 있죠.

복잡한 로직의 애니메이션도 거뜬해요

Rive에는 설명드린 기능 이외에도 애니메이션 구현을 위한 다양한 피쳐를 제공하고 있습니다. 지속적으로 추가되는 다양한 피쳐를 사용해서 게임 개발 수준의 복잡한 애니메이션과 인터랙션을 제약 없이 구현할 수 있습니다. 공식 웹을 통해, 혹은 Rive Editor 를 직접 설치해서 사용해 보시면서 어떤 기능들이 지원되는지 바로 확인해 볼 수 있습니다.

용량도 적고 퍼포먼스도 좋아요

기존 애니메이션 포맷으로 널리 사용되는 Lottie 에 비해서 퍼포먼스가 떨어지지는 않을까요? 잘 최적화된 Rive File Format 과 Runtime 덕분에 Lottie Json 파일보다 훨씬 작은 파일 사이즈로 Export 가 가능하고, 런타임에서 소요하는 메모리도 효율적으로 유지할 수 있습니다. 특히 직접 재생 여부 관리가 가능하기 때문에, 다른 UI에 가려져서 애니메이션 재생이 필요 없을 때나 스크롤되었을 때 애니메이션을 정지시키면, CPU 자원 사용을 최적화할 수도 있습니다.

먼저 겪어봤어요, Flutter 에 Rive 를 적용할 때 팁

Caching Rive Files into Memory

파일 로드 시간을 단축시키거나, 여러 곳에서 동일한 Rive 파일을 사용해야 할 때에는 RiveFile 객체를 미리 로드해서 캐싱할 수 있습니다. 아래는 간단하게 구현한 Global Cache Manager 예시 코드입니다.

class RiveAssetCacheManager {
final Map<String, RiveFile> _cache = {};

// constructor
RiveAssetCacheManager() {
RiveFile.initialize().then(
(value) => preload(RiveAssetPath.values.map((e) => e.path).toList()));
}

Future<void> preload(List<String> paths) async {
// load rive file asynchronously
await Future.wait(paths.map((path) => load(path)));
}

Future<RiveFile> load(String path) async {
if (_cache.containsKey(path)) {
return _cache[path]!;
}
final bytes = await rootBundle.load(path);
final file = RiveFile.import(bytes);
_cache[path] = file;
return file;
}

RiveFile? get(String path) {
final result = _cache[path];
// if result is null, load it in background
if (result == null) {
load(path);
}
return result;
}

Future<void> clear() async {
_cache.clear();
}
}

이렇게 글로벌하게 RiveFile 객체를 캐싱한 뒤에, UI 단에서 캐싱 여부를 확인하고 캐싱된 객체를 바로 사용할 수 있습니다. get method 에서 cache miss 가 났을 경우, 파일을 로드할 수 있게 구현할 수도 있겠습니다.

/// build method of [RiveAnimationWithCache]
@override
Widget build(BuildContext context) {
final file = getItInstance.isRegistered<RiveAssetCacheManager>()
? getItInstance<RiveAssetCacheManager>().get(asset)
: null;

return file != null
? RiveAnimation.direct(
file,
artboard: artboard,
animations: animations,
stateMachines: stateMachines,
fit: fit,
alignment: alignment,
placeHolder: placeHolder,
antialiasing: antialiasing,
useArtboardSize: useArtboardSize,
clipRect: clipRect,
controllers: controllers,
onInit: onInit,
behavior: behavior,
)
: RiveAnimation.asset(
asset,
artboard: artboard,
animations: animations,
stateMachines: stateMachines,
fit: fit,
alignment: alignment,
placeHolder: placeHolder,
antialiasing: antialiasing,
useArtboardSize: useArtboardSize,
clipRect: clipRect,
controllers: controllers,
onInit: onInit,
behavior: behavior,
);
}

Dynamic Asset Referencing

Rive 내부에서 텍스트를 사용하는 경우에는 폰트 파일이 Rive 파일에 포함될 수 있습니다. 이는 불필요하게 중복된 데이터를 만들어서 앱 용량을 증가시킬 수 있습니다. 폰트는 이미 앱에 내장되어 있기 때문에 Dynamic 하게 해당 에셋을 참조할 수 있습니다. 다음은 RiveFile 을 로드할 때 연관된 asset을 로드하는 예시입니다. 필요한 asset이 FontAsset 인 경우 폰트 이름으로 asset 폴더에 저장된 Font Byte 데이터를 가져와 연결할 수 있습니다. 해당 기능은 Rive 파일을 export 할 때 Export Type을 Referenced로 설정해야 사용할 수 있습니다!

Future<RiveFile> load(String path) async {
if (_cache.containsKey(path)) {
debugPrint('load from cache: $path');
return _cache[path]!;
}
final bytes = await rootBundle.load(path);
final file = RiveFile.import(bytes,
assetLoader: CallbackAssetLoader((asset, bytes) async {
if (asset is FontAsset) {
// Rive 에서 어떤 Font를 연결시켜줘야 할지, font 이름의 형태로 알려줍니다.
final fontName = asset.name;

// 자체 구현한 FontAssetLoader로 asset Directory에 있는 폰트 파일을 Uint8List로 반환합니다.
final Uint8List fontBytes = await RiveFontAssetLoader().load(fontName);

asset.font = await FontAsset.parseBytes(fontBytes);

return true;
}
return false;
}));
_cache[path] = file;
return file;
}

Impeller Rendering Engine 에서 발생할 수 있는 Glitch

Rive는 플랫폼 별로 다른 렌더링 엔진을 사용하는데요. Flutter 의 경우 기존에 Skia 엔진을 사용하다가 최근 Flutter 측에서 개발한 Impeller 엔진도 지원할 수 있도록 확장되었습니다. 하지만 Impeller 엔진이 아직 배포되지 얼마 되지 않아서인지, Rive 애니메이션을 렌더링할 때에 여러 예상치 못한 Glitch 가 발생되곤 합니다. Rive 공식 문서에서는 Flutter 측에서 Impeller Issue를 해결한 새로운 버전을 출시할 때까지 Impeller 엔진을 사용하지 않을 것을 제안하고 있습니다.

$ flutter run --no-enable-impeller

좋은 소식은 Rive 측에서 Rive 에 최적화된 Rive Renderer를 개발중이라는 것입니다! 현재 Web과 Android, iOS 에서는 사용해볼 수 있고, 곧 Flutter 에서도 사용할 수 있을 것이라고 합니다.

Rive Renderer 가 오고 있습니다!

그래서, 추천하나요?

Flutter에 Rive를 적용하는 데 있어서 고민해야 할 부분도 있습니다. 아직 런타임 패키지 버전이 0.13.9 일 정도로 초창기이고요. 그만큼 커뮤니티가 관련 문서도 아직은 미진합니다.

구현에도 아직은 불편한 부분이 있는데요. 이를테면 Artboard 이름, State Machine Contoller 이름을 런타임에 직접 String으로 검색해서 가져와야 하는 점도 그렇고요. TextRun 변수명을 직접 변경해주지 않으면 Export 과정에서 자동으로 지워지기 때문에 반드시 이름을 변경해줘야 하는 이상한 오류도 있습니다.

그럼에도 불구하고, 인터랙션 애니메이션을 구현하고 적용하는 데에 특화되어 있는 Rive이기 때문에 훌륭한 개발 경험으로 빠르고 쉽게 애니메이션을 구현할 수 있기 때문에, 저는 한 번쯤 Rive 를 경험해 보실 것을 적극적으로 추천드립니다. 또 하루가 다르게 발전하고 있는 Rive 생태계이기 때문에, 현재 겪고 있는 불편한 이슈들은 빠르게 해결될 것이라 예상합니다.

블루시그넘의 대원칙 5개 중 1번은 “우리는 아주 특별한 프로덕트를 만든다.”입니다. 타협할 수도, 그럴 생각도 없는 이 원칙을 이루기 위해 무디는 Rive 라는 툴을 적극적으로 얼리어답팅 해서 유저에게 특별한 경험을 제공하려 하고 있습니다.

여러분도 상상력을 제한 없이 녹여낼 수 있는 Rive 와, 빠르고 간편하게 구현하여 유저에게 가치를 제공할 수 있는 Flutter 조합으로 특별한 프로덕트를 만들어 보시길 추천드립니다 😎

--

--