티스토리 뷰
Flutter는 다양한 애니메이션 및 UI 효과를 쉽게 적용할 수 있는 강력한 프레임워크입니다. 이 글에서는 현대적인 채팅 애플리케이션에서 자주 사용되는 타이핑 인디케이터(Typing Indicator)를 구현하는 방법에 대해 다룹니다. 타이핑 인디케이터는 다른 사용자가 메시지를 입력하고 있다는 것을 표시하는 기능으로, 사용자 간의 상호작용을 보다 부드럽고 자연스럽게 만듭니다.
참고. Define the typing indicator widget
타이핑 인디케이터란 무엇인가?
타이핑 인디케이터는 채팅 애플리케이션에서 자주 볼 수 있는 기능으로, 상대방이 메시지를 작성 중일 때 이를 시각적으로 보여줍니다. 이 기능은 사용자에게 실시간 소통의 느낌을 주며, 두 명 이상의 사용자가 동시에 메시지를 보내는 상황에서 혼란을 줄여주는 중요한 역할을 합니다.
Flutter에서 타이핑 인디케이터 구현하기
인디케이터 위젯 정의하기
타이핑 인디케이터는 별도의 StatefulWidget으로 정의됩니다. 이 위젯은 애니메이션을 제어하기 위해 상태를 관리해야 하며, 표시 여부와 색상을 제어할 수 있는 여러 매개변수를 가집니다.
class TypingIndicator extends StatefulWidget {
const TypingIndicator({
Key? key,
this.showIndicator = false,
this.bubbleColor = const Color(0xFF646b7f),
this.flashingCircleDarkColor = const Color(0xFF333333),
this.flashingCircleBrightColor = const Color(0xFFaec1dd),
}) : super(key: key);
final bool showIndicator;
final Color bubbleColor;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
@override
_TypingIndicatorState createState() => _TypingIndicatorState();
}
애니메이션을 이용한 인디케이터 표시
타이핑 인디케이터의 높이는 애니메이션을 통해 부드럽게 확장되고 축소됩니다. 이렇게 하면 인디케이터가 나타날 때 메시지 목록이 갑자기 위아래로 움직이는 불편함을 줄일 수 있습니다.
class _TypingIndicatorState extends State<TypingIndicator> with TickerProviderStateMixin {
late AnimationController _appearanceController;
late Animation<double> _indicatorSpaceAnimation;
@override
void initState() {
super.initState();
_appearanceController = AnimationController(vsync: this);
_indicatorSpaceAnimation = CurvedAnimation(
parent: _appearanceController,
curve: Interval(0.0, 0.4, curve: Curves.easeOut),
reverseCurve: Interval(0.0, 1.0, curve: Curves.easeOut),
).drive(Tween<double>(begin: 0.0, end: 60.0));
if (widget.showIndicator) {
_showIndicator();
}
}
void _showIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 750)
..forward();
}
void _hideIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 150)
..reverse();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _indicatorSpaceAnimation,
builder: (context, child) {
return SizedBox(height: _indicatorSpaceAnimation.value);
},
);
}
}
말풍선 애니메이션 추가
타이핑 인디케이터는 세 개의 말풍선을 표시하며, 각 말풍선은 스케일 애니메이션을 통해 나타납니다. 이 애니메이션은 단계별로 실행되어 말풍선이 차례로 나타나는 시각적 효과를 줍니다.
AnimatedBuilder(
animation: _smallBubbleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _smallBubbleAnimation.value,
alignment: Alignment.bottomLeft,
child: CircleBubble(size: 8, bubbleColor: widget.bubbleColor),
);
},
);
깜빡이는 원 애니메이션 구현하기
가장 큰 말풍선 안에는 세 개의 원이 깜빡이는 애니메이션이 포함되어 있습니다. 각 원은 약간의 시간차를 두고 깜빡이도록 설정되어, 빛이 이동하는 듯한 느낌을 줍니다. 이를 위해 AnimationController를 사용해 반복적인 애니메이션을 적용합니다.
class FlashingCircle extends StatelessWidget {
const FlashingCircle({
required this.index,
required this.repeatingController,
required this.dotIntervals,
required this.flashingCircleBrightColor,
required this.flashingCircleDarkColor,
});
final int index;
final AnimationController repeatingController;
final List<Interval> dotIntervals;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: repeatingController,
builder: (context, child) {
final circleFlashPercent = dotIntervals[index].transform(repeatingController.value);
final circleColorPercent = sin(pi * circleFlashPercent);
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color.lerp(
flashingCircleDarkColor,
flashingCircleBrightColor,
circleColorPercent,
),
),
);
},
);
}
}
요약 및 마무리
이번 글에서는 Flutter로 타이핑 인디케이터를 구현하는 방법을 단계별로 설명했습니다. 타이핑 인디케이터는 실시간 메시징 환경에서 필수적인 기능으로, 사용자의 경험을 크게 개선할 수 있습니다. Flutter의 강력한 애니메이션 및 위젯 시스템을 통해 쉽게 구현할 수 있으며, AnimatedBuilder와 AnimationController를 활용해 효율적인 애니메이션을 만들 수 있습니다.
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: ExampleIsTyping(),
debugShowCheckedModeBanner: false,
),
);
}
const _backgroundColor = Color(0xFF333333);
class ExampleIsTyping extends StatefulWidget {
const ExampleIsTyping({
super.key,
});
@override
State<ExampleIsTyping> createState() => _ExampleIsTypingState();
}
class _ExampleIsTypingState extends State<ExampleIsTyping> {
bool _isSomeoneTyping = false;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _backgroundColor,
appBar: AppBar(
title: const Text('Typing Indicator'),
),
body: Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: 25,
reverse: true,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(left: 100),
child: FakeMessage(isBig: index.isOdd),
);
},
),
),
Align(
alignment: Alignment.bottomLeft,
child: TypingIndicator(
showIndicator: _isSomeoneTyping,
),
),
Container(
color: Colors.grey,
padding: const EdgeInsets.all(16),
child: Center(
child: CupertinoSwitch(
onChanged: (newValue) {
setState(() {
_isSomeoneTyping = newValue;
});
},
value: _isSomeoneTyping,
),
),
),
],
),
);
}
}
class TypingIndicator extends StatefulWidget {
const TypingIndicator({
super.key,
this.showIndicator = false,
this.bubbleColor = const Color(0xFF646b7f),
this.flashingCircleDarkColor = const Color(0xFF333333),
this.flashingCircleBrightColor = const Color(0xFFaec1dd),
});
final bool showIndicator;
final Color bubbleColor;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
@override
State<TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<TypingIndicator>
with TickerProviderStateMixin {
late AnimationController _appearanceController;
late Animation<double> _indicatorSpaceAnimation;
late Animation<double> _smallBubbleAnimation;
late Animation<double> _mediumBubbleAnimation;
late Animation<double> _largeBubbleAnimation;
late AnimationController _repeatingController;
final List<Interval> _dotIntervals = const [
Interval(0.25, 0.8),
Interval(0.35, 0.9),
Interval(0.45, 1.0),
];
@override
void initState() {
super.initState();
_appearanceController = AnimationController(
vsync: this,
)..addListener(() {
setState(() {});
});
_indicatorSpaceAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
reverseCurve: const Interval(0.0, 1.0, curve: Curves.easeOut),
).drive(Tween<double>(
begin: 0.0,
end: 60.0,
));
_smallBubbleAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.0, 0.5, curve: Curves.elasticOut),
reverseCurve: const Interval(0.0, 0.3, curve: Curves.easeOut),
);
_mediumBubbleAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.2, 0.7, curve: Curves.elasticOut),
reverseCurve: const Interval(0.2, 0.6, curve: Curves.easeOut),
);
_largeBubbleAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.3, 1.0, curve: Curves.elasticOut),
reverseCurve: const Interval(0.5, 1.0, curve: Curves.easeOut),
);
_repeatingController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
if (widget.showIndicator) {
_showIndicator();
}
}
@override
void didUpdateWidget(TypingIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.showIndicator != oldWidget.showIndicator) {
if (widget.showIndicator) {
_showIndicator();
} else {
_hideIndicator();
}
}
}
@override
void dispose() {
_appearanceController.dispose();
_repeatingController.dispose();
super.dispose();
}
void _showIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 750)
..forward();
_repeatingController.repeat();
}
void _hideIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 150)
..reverse();
_repeatingController.stop();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _indicatorSpaceAnimation,
builder: (context, child) {
return SizedBox(
height: _indicatorSpaceAnimation.value,
child: child,
);
},
child: Stack(
children: [
AnimatedBubble(
animation: _smallBubbleAnimation,
left: 8,
bottom: 8,
bubble: CircleBubble(
size: 8,
bubbleColor: widget.bubbleColor,
),
),
AnimatedBubble(
animation: _mediumBubbleAnimation,
left: 10,
bottom: 10,
bubble: CircleBubble(
size: 16,
bubbleColor: widget.bubbleColor,
),
),
AnimatedBubble(
animation: _largeBubbleAnimation,
left: 12,
bottom: 12,
bubble: StatusBubble(
repeatingController: _repeatingController,
dotIntervals: _dotIntervals,
flashingCircleDarkColor: widget.flashingCircleDarkColor,
flashingCircleBrightColor: widget.flashingCircleBrightColor,
bubbleColor: widget.bubbleColor,
),
),
],
),
);
}
}
class CircleBubble extends StatelessWidget {
const CircleBubble({
super.key,
required this.size,
required this.bubbleColor,
});
final double size;
final Color bubbleColor;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: bubbleColor,
),
);
}
}
class AnimatedBubble extends StatelessWidget {
const AnimatedBubble({
super.key,
required this.animation,
required this.left,
required this.bottom,
required this.bubble,
});
final Animation<double> animation;
final double left;
final double bottom;
final Widget bubble;
@override
Widget build(BuildContext context) {
return Positioned(
left: left,
bottom: bottom,
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.scale(
scale: animation.value,
alignment: Alignment.bottomLeft,
child: child,
);
},
child: bubble,
),
);
}
}
class StatusBubble extends StatelessWidget {
const StatusBubble({
super.key,
required this.repeatingController,
required this.dotIntervals,
required this.flashingCircleBrightColor,
required this.flashingCircleDarkColor,
required this.bubbleColor,
});
final AnimationController repeatingController;
final List<Interval> dotIntervals;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
final Color bubbleColor;
@override
Widget build(BuildContext context) {
return Container(
width: 85,
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(27),
color: bubbleColor,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FlashingCircle(
index: 0,
repeatingController: repeatingController,
dotIntervals: dotIntervals,
flashingCircleDarkColor: flashingCircleDarkColor,
flashingCircleBrightColor: flashingCircleBrightColor,
),
FlashingCircle(
index: 1,
repeatingController: repeatingController,
dotIntervals: dotIntervals,
flashingCircleDarkColor: flashingCircleDarkColor,
flashingCircleBrightColor: flashingCircleBrightColor,
),
FlashingCircle(
index: 2,
repeatingController: repeatingController,
dotIntervals: dotIntervals,
flashingCircleDarkColor: flashingCircleDarkColor,
flashingCircleBrightColor: flashingCircleBrightColor,
),
],
),
);
}
}
class FlashingCircle extends StatelessWidget {
const FlashingCircle({
super.key,
required this.index,
required this.repeatingController,
required this.dotIntervals,
required this.flashingCircleBrightColor,
required this.flashingCircleDarkColor,
});
final int index;
final AnimationController repeatingController;
final List<Interval> dotIntervals;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: repeatingController,
builder: (context, child) {
final circleFlashPercent = dotIntervals[index].transform(
repeatingController.value,
);
final circleColorPercent = sin(pi * circleFlashPercent);
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color.lerp(
flashingCircleDarkColor,
flashingCircleBrightColor,
circleColorPercent,
),
),
);
},
);
}
}
class FakeMessage extends StatelessWidget {
const FakeMessage({
super.key,
required this.isBig,
});
final bool isBig;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 24),
height: isBig ? 128 : 36,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
color: Colors.grey.shade300,
),
);
}
}
'Flutter Cookbook' 카테고리의 다른 글
Flutter에서 그라디언트 채팅 말풍선 구현하기: 사용자 경험을 향상시키는 방법 (1) | 2024.11.01 |
---|---|
Flutter에서 확장 가능한 Floating Action Button(FAB) 구현하기 (0) | 2024.10.31 |
Flutter 스태거드 메뉴 애니메이션 구현 가이드: 한 단계씩 따라하는 Flutter 애니메이션 마스터하기 (0) | 2024.10.28 |
Flutter로 쉬머 로딩 효과 구현하기: 상세 가이드 (0) | 2024.10.22 |
Flutter로 패럴랙스 스크롤 효과 구현하기: 단계별 가이드 (1) | 2024.10.21 |