채팅 앱을 만들다 보면 그룹 채팅방 목록에서 참여자들의 프로필 사진을 겹쳐서 보여주는 UI가 필요하다. 카카오톡, WhatsApp, Telegram, 슬랙 같은 앱에서 흔히 볼 수 있는 그 UI다.
단순히 이미지 하나 보여주는 게 아니라, 참여자 수에 따라 레이아웃이 바뀌어야 한다. 2명이면 대각선으로 겹치고, 3명이면 삼각형, 4명 이상이면 그리드 형태로 보여주는 식이다.
React Native나 네이티브(Android, iOS)에서는 이런 스택 아바타 라이브러리가 꽤 있다. 그런데 Flutter에서는 마땅한 게 없었다. 찾아보면 있긴 한데 기능이 부족하거나 커스터마이징이 안 되거나 유지보수가 안 되고 있었다. 그래서 직접 만들었다.
RN 생태계에서는 react-native-stacked-avatar, react-native-group-avatar 같은 패키지들이 있다. npm에서 "group avatar", "stacked avatar"로 검색하면 꽤 많이 나온다. 다운로드 수도 많고, 옵션도 다양하다.
겹침 정도 조절, 최대 표시 개수, "+N" 배지, 테두리 색상 등 왠만한 커스터마이징은 다 된다.
Android에서는 AvatarView, StackedAvatarView, GroupAvatarView 같은 라이브러리가 있다. iOS도 Swift 패키지나 CocoaPods으로 비슷한 게 있다.
네이티브 쪽은 아무래도 생태계가 오래되다 보니 이런 UI 컴포넌트도 잘 갖춰져 있다.
Flutter에서 "group avatar", "stacked avatar", "chat avatar"로 pub.dev를 검색해봤다. 있긴 있는데 문제가 있었다.
채팅 앱에서 쓰려면 이런 게 다 필요한데, 하나로 해결되는 패키지가 없었다.
기존 패키지를 포크해서 수정할까도 생각했는데, 차라리 처음부터 만드는 게 나을 것 같았다. 내가 원하는 기능을 다 넣으면서 구조도 깔끔하게 가져갈 수 있으니까.
그래서 chat_group_avatar를 만들었다.
그룹 채팅 아바타를 표시하는 Flutter 패키지다. 카카오톡, WhatsApp, Telegram 같은 채팅 앱의 그룹 아바타 UI를 쉽게 구현할 수 있다.
1. 적응형 레이아웃
멤버 수에 따라 레이아웃이 자동으로 바뀐다. 개발자가 조건문으로 분기할 필요 없이, 이미지 URL 리스트만 넘기면 알아서 처리된다.
2. 다양한 모양 지원
원형뿐만 아니라 정사각형, 둥근 정사각형도 지원한다. 앱 디자인에 맞게 선택할 수 있다.
3. 스택/그리드 레이아웃
이미지를 겹쳐서 보여주는 스택 레이아웃과, 격자로 보여주는 그리드 레이아웃을 선택할 수 있다. 겹침 정도도 조절 가능하다.
4. 카운터 배지
멤버가 많을 때 일부만 보여주고 "+N" 형태로 나머지 인원을 표시할 수 있다.
5. 캐시 이미지
cached_network_image를 내장해서 네트워크 이미지를 효율적으로 로드한다. 별도 설정 없이 메모리/디스크 캐싱이 적용된다.
6. 제스처 지원
탭, 롱프레스 콜백을 지원한다. 그룹 아바타를 눌렀을 때 멤버 목록을 보여주는 등의 인터랙션을 구현할 수 있다.
dependencies:
chat_group_avatar: ^0.1.0
flutter pub get
의존성은 cached_network_image 하나뿐이다. 불필요한 의존성을 최소화했다.
import 'package:chat_group_avatar/chat_group_avatar.dart';
GroupAvatar(
imageUrls: [
'https://example.com/user1.jpg',
'https://example.com/user2.jpg',
'https://example.com/user3.jpg',
],
size: 56,
)
이미지 URL 리스트와 크기만 넘기면 된다. 멤버 수에 따라 알아서 레이아웃이 결정된다.
멤버 수에 따라 자동으로 레이아웃이 결정된다. 이게 이 패키지의 핵심 기능이다.
단일 이미지로 표시된다. 일반적인 프로필 이미지와 동일하다.
┌─────┐
│ │
│ 1 │
│ │
└─────┘
1:1 채팅방이나 멤버가 한 명만 남은 그룹 채팅방에서 이렇게 보인다.
대각선으로 겹쳐서 표시된다. 왼쪽 위에 첫 번째 이미지, 오른쪽 아래에 두 번째 이미지가 배치된다.
┌─────┐
│ 1 │───┐
└───┬─│ 2 │
└─────┘
두 명이 대화하는 느낌을 준다.
삼각형 형태로 배치된다. 위에 한 명, 아래에 두 명이 배치된다.
┌───┐
│ 1 │
└───┘
┌───┐ ┌───┐
│ 2 │ │ 3 │
└───┘ └───┘
세 명이 모여있는 느낌을 준다.
2x2 그리드로 표시된다. 4명을 초과하면 마지막 자리에 "+N" 카운터가 표시된다.
┌───┐ ┌───┐
│ 1 │ │ 2 │
└───┘ └───┘
┌───┐ ┌───┐
│ 3 │ │+2 │
└───┘ └───┘
그룹 채팅방에서 참여자가 많을 때 이런 식으로 보인다.
기본은 원형이고, 정사각형이나 둥근 정사각형으로 바꿀 수 있다.
// 원형 (기본값)
GroupAvatar(
imageUrls: imageUrls,
size: 56,
shape: AvatarShape.circle,
)
// 정사각형
GroupAvatar(
imageUrls: imageUrls,
size: 56,
shape: AvatarShape.square,
)
// 둥근 정사각형
GroupAvatar(
imageUrls: imageUrls,
size: 56,
shape: AvatarShape.roundedSquare,
)
앱 디자인에 따라 원형이 어울리는 곳도 있고, 정사각형이 어울리는 곳도 있다. 슬랙 같은 앱은 둥근 정사각형을 쓴다.
멤버가 여러 명일 때 이미지를 겹쳐서 보여줄 수 있다.
GroupAvatar(
imageUrls: imageUrls,
size: 56,
layout: GroupAvatarLayout.stack,
overlapRatio: 0.3, // 겹침 정도 (0.0 ~ 1.0)
)
overlapRatio가 0이면 안 겹치고, 1에 가까울수록 많이 겹친다. 0.3 정도가 적당하다. 너무 많이 겹치면 얼굴이 안 보이고, 너무 적게 겹치면 공간을 많이 차지한다.
이미지가 많을 때 일부만 보여주고 나머지는 "+N"으로 표시할 수 있다.
GroupAvatar(
imageUrls: imageUrls, // 10개라고 가정
size: 56,
maxVisible: 4, // 최대 4개만 표시
showCounter: true, // "+6" 배지 표시
)
그룹 채팅방에 멤버가 수십 명일 수도 있으니까, 적당히 잘라서 보여주는 게 좋다. 보통 3~4개 정도로 제한한다.
"+N" 배지의 스타일도 바꿀 수 있다.
GroupAvatar(
imageUrls: imageUrls,
size: 56,
maxVisible: 4,
showCounter: true,
counterBackgroundColor: Colors.grey[800],
counterStyle: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
)
앱 테마에 맞게 배경색이나 글자 스타일을 조절할 수 있다.
각 아바타에 테두리를 줄 수 있다. 겹칠 때 경계가 잘 보이게 하려면 테두리를 주는 게 좋다.
GroupAvatar(
imageUrls: imageUrls,
size: 56,
borderWidth: 2,
borderColor: Colors.white,
)
배경이 어두우면 흰색 테두리, 배경이 밝으면 회색 테두리를 주면 된다.
이미지 로딩 중이거나 로드 실패했을 때 보여줄 위젯을 지정할 수 있다.
GroupAvatar(
imageUrls: imageUrls,
size: 56,
placeholderIcon: Icon(Icons.person, color: Colors.grey[600]),
backgroundColor: Colors.grey[300],
)
네트워크 상태가 안 좋거나 이미지 URL이 잘못됐을 때 빈 화면 대신 적절한 대체 UI를 보여줄 수 있다.
탭이나 롱프레스 이벤트를 받을 수 있다.
GroupAvatar(
imageUrls: imageUrls,
size: 56,
onTap: () {
// 그룹 아바타 탭 시 멤버 목록 모달 띄우기
showMemberListModal(context);
},
onLongPress: () {
// 롱프레스 시 채팅방 설정
showChatRoomSettings(context);
},
)
그룹 아바타를 눌렀을 때 참여자 목록을 보여주거나, 롱프레스했을 때 채팅방 설정을 여는 등의 인터랙션을 구현할 수 있다.
채팅 앱에서 가장 흔하게 쓰는 케이스다.
class ChatRoomListTile extends StatelessWidget {
final ChatRoom chatRoom;
const ChatRoomListTile({required this.chatRoom});
Widget build(BuildContext context) {
return ListTile(
leading: GroupAvatar(
imageUrls: chatRoom.memberProfileUrls,
size: 48,
maxVisible: 4,
showCounter: true,
borderWidth: 1.5,
borderColor: Colors.white,
shape: AvatarShape.circle,
),
title: Text(
chatRoom.isGroupChat ? chatRoom.name : chatRoom.memberNames.first,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
chatRoom.lastMessage,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(chatRoom.formattedTime),
if (chatRoom.unreadCount > 0)
Badge(label: Text('${chatRoom.unreadCount}')),
],
),
onTap: () => context.push('/chat/${chatRoom.id}'),
);
}
}
채팅방 안에서 상단 AppBar에 그룹 아바타를 보여줄 수도 있다.
AppBar(
leading: BackButton(),
title: Row(
children: [
GroupAvatar(
imageUrls: chatRoom.memberProfileUrls,
size: 36,
maxVisible: 3,
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(chatRoom.name, style: TextStyle(fontSize: 16)),
Text('${chatRoom.memberCount}명', style: TextStyle(fontSize: 12)),
],
),
),
],
),
)
그룹 채팅에 멤버를 초대할 때 선택된 멤버들을 미리보기로 보여줄 수 있다.
Column(
children: [
GroupAvatar(
imageUrls: selectedMembers.map((m) => m.profileUrl).toList(),
size: 80,
maxVisible: 5,
showCounter: true,
),
SizedBox(height: 8),
Text('${selectedMembers.length}명 선택됨'),
],
)
네트워크 이미지를 로드할 때 캐싱이 중요하다.
채팅방 목록은 사용자가 앱을 켤 때마다 보는 화면이다. 매번 네트워크 요청을 하면 로딩이 느려지고, 데이터도 낭비된다. 특히 모바일에서는 네트워크 상태가 불안정할 수도 있어서 캐싱이 필수다.
cached_network_image는 메모리 캐시와 디스크 캐시를 모두 지원한다.
이걸 직접 구현하려면 꽤 복잡한데, cached_network_image가 다 해결해준다. 그래서 이 패키지를 의존성으로 넣었다.
size 파라미터로 표시 크기를 지정하는데, 이건 위젯 크기일 뿐이고 실제 이미지 해상도와는 다르다. 서버에서 적절한 크기의 썸네일을 내려주는 게 좋다.
예를 들어 48x48 크기로 표시할 건데 원본 이미지가 1000x1000이면 낭비다. 서버에서 100x100 정도의 썸네일 URL을 제공하는 게 좋다.
ListView나 GridView에서 사용할 때는 itemExtent를 지정하거나 ListView.builder를 사용하는 게 좋다. Flutter가 보이는 영역만 렌더링하도록 최적화해준다.
ListView.builder(
itemCount: chatRooms.length,
itemBuilder: (context, index) {
return ChatRoomListTile(chatRoom: chatRooms[index]);
},
)
| 플랫폼 | 지원 |
|---|---|
| Android | O |
| iOS | O |
| Web | O |
| macOS | O |
| Windows | O |
| Linux | O |
Flutter가 지원하는 모든 플랫폼에서 동작한다. 특별히 네이티브 코드를 쓰지 않아서 플랫폼 제약이 없다.
| 직접 구현 | chat_group_avatar | |
|---|---|---|
| 레이아웃 자동 조정 | 조건문으로 직접 구현 | 내장 |
| 모양 변경 | ClipRRect 등으로 직접 구현 | shape 파라미터 |
| 스택/그리드 | Stack, GridView로 직접 구현 | layout 파라미터 |
| 겹침 정도 | Positioned로 계산 | overlapRatio 파라미터 |
| 캐시 이미지 | cached_network_image 별도 사용 | 내장 |
| 카운터 배지 | 별도 위젯으로 구현 | showCounter 파라미터 |
| 테두리 | BoxDecoration으로 직접 구현 | borderWidth, borderColor |
Flutter에서 그룹 채팅 아바타 UI가 필요한데 마땅한 패키지가 없어서 직접 만들었다. RN이나 네이티브에는 이런 라이브러리가 있는데 Flutter에는 없어서 좀 아쉬웠다.
멤버 수에 따른 레이아웃 자동 조정, 모양 커스터마이징, 스택/그리드 레이아웃, 카운터 배지 등 채팅 앱에서 필요한 기능들을 넣었다.
비슷한 UI가 필요한 분들에게 도움이 됐으면 좋겠다.
질문이나 버그 리포트는 GitHub Issue로 남겨주시면 됩니다.
오 깔끔한 정리 덕분에 플러터 입문자가 이해하기 쉬웠습ㄴ디ㅏ!