티스토리 뷰
Flutter를 사용하면 채팅 애플리케이션의 디자인을 한 단계 더 업그레이드할 수 있습니다. 특히, 고정된 단색 배경 대신, 그라디언트 채팅 말풍선을 활용하여 모던한 느낌을 줄 수 있습니다. 이 글에서는 Flutter에서 CustomPainter를 사용하여 채팅 말풍선에 그라디언트 효과를 적용하는 방법을 자세히 설명하고, 구현 시 유의해야 할 주요 사항을 정리해 보겠습니다.
참고. Create gradient chat bubbles
그라디언트 채팅 말풍선이란?
전통적인 채팅 애플리케이션에서는 말풍선의 배경을 단색으로 설정하는 경우가 많습니다. 하지만, 모던한 디자인에서는 말풍선의 위치에 따라 배경 그라디언트를 적용하여 더 생동감 있는 UI를 만들 수 있습니다. 특히, 메시지의 위치와 스크롤 상태에 따라 동적으로 그라디언트 색상이 변경되도록 하면 사용자 경험이 크게 향상됩니다.
Flutter에서 CustomPainter 사용하기
Flutter의 CustomPainter는 커스텀 페인팅을 구현할 때 유용한 도구입니다. 일반적으로 배경을 그리는 데 사용되는 DecoratedBox와 같은 위젯은 레이아웃 전 단계에서 배경 색상을 결정합니다. 반면, CustomPainter를 사용하면 말풍선의 위치 정보를 이용해 레이아웃 후에 그라디언트를 적용할 수 있습니다.
BubbleBackground 위젯 만들기
먼저, 말풍선의 배경을 담당하는 새로운 BubbleBackground 위젯을 만듭니다. 이 위젯은 말풍선의 그라디언트를 처리하며, CustomPaint와 CustomPainter를 활용하여 그라디언트를 렌더링합니다.
@immutable
class BubbleBackground extends StatelessWidget {
const BubbleBackground({Key? key, required this.colors, this.child}) : super(key: key);
final List<Color> colors;
final Widget? child;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: BubblePainter(colors: colors),
child: child,
);
}
}
스크롤 정보를 통한 그라디언트 효과 제어
말풍선의 위치와 스크롤 정보를 가져오기 위해서는 ScrollableState와 BuildContext를 CustomPainter에 전달해야 합니다. 이를 통해 화면에 표시된 말풍선의 위치를 파악하고, 그에 따라 그라디언트의 색상을 결정할 수 있습니다.
class BubblePainter extends CustomPainter {
BubblePainter({required this.scrollable, required this.bubbleContext, required this.colors});
final ScrollableState scrollable;
final BuildContext bubbleContext;
final List<Color> colors;
@override
bool shouldRepaint(BubblePainter oldDelegate) {
return oldDelegate.scrollable != scrollable ||
oldDelegate.bubbleContext != bubbleContext ||
oldDelegate.colors != colors;
}
}
전체 화면 그라디언트 페인팅 구현
스크롤 정보와 말풍선의 위치 정보를 모두 얻었으면, 이제 실제 그라디언트 페인팅을 구현할 차례입니다. CustomPainter의 paint
메서드를 사용하여, 말풍선의 위치에 따라 그라디언트를 계산하고 화면에 렌더링합니다.
@override
void paint(Canvas canvas, Size size) {
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final scrollableRect = Offset.zero & scrollableBox.size;
final bubbleBox = bubbleContext.findRenderObject() as RenderBox;
final origin = bubbleBox.localToGlobal(Offset.zero, ancestor: scrollableBox);
final paint = Paint()
..shader = ui.Gradient.linear(
scrollableRect.topCenter,
scrollableRect.bottomCenter,
colors,
[0.0, 1.0],
TileMode.clamp,
Matrix4.translationValues(-origin.dx, -origin.dy, 0.0).storage,
);
canvas.drawRect(Offset.zero & size, paint);
}
요약 및 결론
Flutter로 채팅 애플리케이션을 구현할 때, 단색 배경이 아닌 그라디언트를 사용하면 시각적으로 더욱 풍부한 UI를 만들 수 있습니다. CustomPainter를 활용하여 스크롤 정보와 말풍선의 위치를 바탕으로 동적인 그라디언트를 적용할 수 있습니다. 이는 Flutter의 레이아웃 및 페인팅 단계에 대한 이해가 필요하지만, 결과적으로 사용자에게 더욱 매력적인 경험을 제공합니다.
주요 포인트 요약:
- Flutter에서 CustomPainter를 사용하여 레이아웃 이후에 페인팅을 제어.
- 스크롤 정보를 활용하여 말풍선의 위치에 따라 그라디언트를 변경.
- ScrollableState와 BuildContext를 이용해 페인팅에 필요한 정보 확보.
Flutter는 UI의 세밀한 조정이 가능한 도구와 위젯을 제공하며, 이번 포스트에서 다룬 그라디언트 채팅 말풍선은 그 대표적인 사례라 할 수 있습니다.
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
void main() {
runApp(const App(home: ExampleGradientBubbles()));
}
@immutable
class App extends StatelessWidget {
const App({super.key, this.home});
final Widget? home;
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Chat',
theme: ThemeData.dark(useMaterial3: true),
home: home,
);
}
}
@immutable
class ExampleGradientBubbles extends StatefulWidget {
const ExampleGradientBubbles({super.key});
@override
State<ExampleGradientBubbles> createState() => _ExampleGradientBubblesState();
}
class _ExampleGradientBubblesState extends State<ExampleGradientBubbles> {
late final List<Message> data;
@override
void initState() {
super.initState();
data = MessageGenerator.generate(60, 1337);
}
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData(
brightness: Brightness.dark,
primaryColor: const Color(0xFF4F4F4F),
),
child: Scaffold(
appBar: AppBar(
title: const Text('Flutter Chat'),
),
body: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 16.0),
reverse: true,
itemCount: data.length,
itemBuilder: (context, index) {
final message = data[index];
return MessageBubble(
message: message,
child: Text(message.text),
);
},
),
),
);
}
}
@immutable
class MessageBubble extends StatelessWidget {
const MessageBubble({
super.key,
required this.message,
required this.child,
});
final Message message;
final Widget child;
@override
Widget build(BuildContext context) {
final messageAlignment =
message.isMine ? Alignment.topLeft : Alignment.topRight;
return FractionallySizedBox(
alignment: messageAlignment,
widthFactor: 0.8,
child: Align(
alignment: messageAlignment,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 20.0),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: BubbleBackground(
colors: [
if (message.isMine) ...const [
Color(0xFF6C7689),
Color(0xFF3A364B),
] else ...const [
Color(0xFF19B7FF),
Color(0xFF491CCB),
],
],
child: DefaultTextStyle.merge(
style: const TextStyle(
fontSize: 18.0,
color: Colors.white,
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: child,
),
),
),
),
),
),
);
}
}
@immutable
class BubbleBackground extends StatelessWidget {
const BubbleBackground({
super.key,
required this.colors,
this.child,
});
final List<Color> colors;
final Widget? child;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: BubblePainter(
scrollable: Scrollable.of(context),
bubbleContext: context,
colors: colors,
),
child: child,
);
}
}
class BubblePainter extends CustomPainter {
BubblePainter({
required ScrollableState scrollable,
required BuildContext bubbleContext,
required List<Color> colors,
}) : _scrollable = scrollable,
_bubbleContext = bubbleContext,
_colors = colors,
super(repaint: scrollable.position);
final ScrollableState _scrollable;
final BuildContext _bubbleContext;
final List<Color> _colors;
@override
void paint(Canvas canvas, Size size) {
final scrollableBox = _scrollable.context.findRenderObject() as RenderBox;
final scrollableRect = Offset.zero & scrollableBox.size;
final bubbleBox = _bubbleContext.findRenderObject() as RenderBox;
final origin =
bubbleBox.localToGlobal(Offset.zero, ancestor: scrollableBox);
final paint = Paint()
..shader = ui.Gradient.linear(
scrollableRect.topCenter,
scrollableRect.bottomCenter,
_colors,
[0.0, 1.0],
TileMode.clamp,
Matrix4.translationValues(-origin.dx, -origin.dy, 0.0).storage,
);
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(BubblePainter oldDelegate) {
return oldDelegate._scrollable != _scrollable ||
oldDelegate._bubbleContext != _bubbleContext ||
oldDelegate._colors != _colors;
}
}
enum MessageOwner { myself, other }
@immutable
class Message {
const Message({
required this.owner,
required this.text,
});
final MessageOwner owner;
final String text;
bool get isMine => owner == MessageOwner.myself;
}
class MessageGenerator {
static List<Message> generate(int count, [int? seed]) {
final random = Random(seed);
return List.unmodifiable(List<Message>.generate(count, (index) {
return Message(
owner: random.nextBool() ? MessageOwner.myself : MessageOwner.other,
text: _exampleData[random.nextInt(_exampleData.length)],
);
}));
}
static final _exampleData = [
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
'In tempus mauris at velit egestas, sed blandit felis ultrices.',
'Ut molestie mauris et ligula finibus iaculis.',
'Sed a tempor ligula.',
'Test',
'Phasellus ullamcorper, mi ut imperdiet consequat, nibh augue condimentum nunc, vitae molestie massa augue nec erat.',
'Donec scelerisque, erat vel placerat facilisis, eros turpis egestas nulla, a sodales elit nibh et enim.',
'Mauris quis dignissim neque. In a odio leo. Aliquam egestas egestas tempor. Etiam at tortor metus.',
'Quisque lacinia imperdiet faucibus.',
'Proin egestas arcu non nisl laoreet, vitae iaculis enim volutpat. In vehicula convallis magna.',
'Phasellus at diam a sapien laoreet gravida.',
'Fusce maximus fermentum sem a scelerisque.',
'Nam convallis sapien augue, malesuada aliquam dui bibendum nec.',
'Quisque dictum tincidunt ex non lobortis.',
'In hac habitasse platea dictumst.',
'Ut pharetra ligula libero, sit amet imperdiet lorem luctus sit amet.',
'Sed ex lorem, lacinia et varius vitae, sagittis eget libero.',
'Vestibulum scelerisque velit sed augue ultricies, ut vestibulum lorem luctus.',
'Pellentesque et risus pretium, egestas ipsum at, facilisis lectus.',
'Praesent id eleifend lacus.',
'Fusce convallis eu tortor sit amet mattis.',
'Vivamus lacinia magna ut urna feugiat tincidunt.',
'Sed in diam ut dolor imperdiet vehicula non ac turpis.',
'Praesent at est hendrerit, laoreet tortor sed, varius mi.',
'Nunc in odio leo.',
'Praesent placerat semper libero, ut aliquet dolor.',
'Vestibulum elementum leo metus, vitae auctor lorem tincidunt ut.',
];
}
'Flutter Cookbook' 카테고리의 다른 글
Flutter 폼 유효성 검증: 쉽게 구현하는 방법 (1) | 2024.11.03 |
---|---|
Flutter에서 드래그 가능한 UI 요소 구현하기 (0) | 2024.11.02 |
Flutter에서 확장 가능한 Floating Action Button(FAB) 구현하기 (0) | 2024.10.31 |
Flutter에서 타이핑 인디케이터 구현하기: 사용자 인터페이스를 향상시키는 방법 (0) | 2024.10.29 |
Flutter 스태거드 메뉴 애니메이션 구현 가이드: 한 단계씩 따라하는 Flutter 애니메이션 마스터하기 (0) | 2024.10.28 |