
【メンバー一覧UI】オンライン・離席中・取り込み中を分けて表示する
この節で学ぶこと
前回の 5-12 では、プロフィール編集用のDialogを作りました。
プロフィールを編集
↓
Dialogを開く
↓
名前・ステータス・自己紹介・アイコン色を変更
↓
Save Changes
↓
プロフィールカードに反映
今回の 5-13 では、Discord風アプリの右側に表示されるメンバー一覧を作ります。
Discord風の画面では、右側に参加メンバーが表示されます。
ONLINE
flutter_dev
mika_design
flutter_bot
IDLE
code_senpai
DO NOT DISTURB
admin
このように、ユーザーをオンライン状態ごとに分けて表示します。
今回の教材では、次の3つを学びます。
ユーザー一覧をListで持つ
オンライン状態ごとにユーザーを分ける
MemberPanelとして右側のメンバー一覧UIを作る
この節で一番大切なのは、次の一文です。
メンバー一覧は、UserProfileのListを状態ごとに分類し、MemberSectionTitleとMemberTileを組み合わせて表示する。
今回作る部品
今回作る主なWidgetは、次の3つです。
| Widget名 | 役割 |
|---|---|
MemberPanel | 右側のメンバー一覧全体 |
MemberSectionTitle | ONLINE や IDLE などの見出し |
MemberTile | ユーザー1人分の表示 |
構造で見ると、次のようになります。
MemberPanel
├─ MemberSectionTitle
├─ MemberTile
├─ MemberTile
├─ MemberSectionTitle
├─ MemberTile
└─ ProfileCard
最終的には、右側のメンバー欄にプロフィールカードも入れます。
右側メンバー欄
├─ メンバー一覧
└─ 自分のプロフィールカード
まず完成イメージを分解する
Discord風のメンバー一覧は、次の要素でできています。
背景色
セクション見出し
ユーザーアイコン
ユーザー名
オンライン状態の小さな丸
hover時の背景色
下部のプロフィールカード
これをFlutterのWidgetに分けると、次のようになります。
Container
↓
右側パネルの背景
ListView
↓
メンバーを縦に並べる
Text
↓
ONLINEなどの見出し
Row
↓
アイコンと名前を横に並べる
UserAvatar
↓
丸いユーザーアイコン
ProfileCard
↓
プロフィールカード
つまり、今回も新しい特別な技術ではなく、これまで作ってきた部品を組み合わせます。
今回使うデータ
メンバー一覧に表示するのは、UserProfile のListです。
まず、UserProfile を確認します。
class UserProfile {
const UserProfile({
required this.name,
required this.handle,
required this.status,
required this.about,
required this.avatarColor,
required this.avatarUrl,
required this.onlineStatus,
});
final String name;
final String handle;
final String status;
final String about;
final Color avatarColor;
final String avatarUrl;
final OnlineStatus onlineStatus;
}
この中で、今回特に大切なのは onlineStatus です。
final OnlineStatus onlineStatus;
この値によって、ユーザーを次のように分けます。
OnlineStatus.online
↓
ONLINE
OnlineStatus.idle
↓
IDLE
OnlineStatus.doNotDisturb
↓
DO NOT DISTURB
OnlineStatus.offline
↓
OFFLINE
OnlineStatusを確認する
オンライン状態は、enum で表します。
enum OnlineStatus {
online,
idle,
doNotDisturb,
offline,
}
enum は、決まった選択肢を表すための仕組みです。
初心者向けには、次のように理解してください。
enum
↓
あらかじめ決まった種類を安全に扱うためのもの
今回のユーザー状態は、4種類だけです。
online
idle
doNotDisturb
offline
文字列で 'online' と書くよりも、OnlineStatus.online と書くほうが、打ち間違いを防ぎやすくなります。
まずメンバーデータを作る
最初に、表示するメンバーのデータを作ります。
final members = [
UserProfile(
name: 'flutter_dev',
handle: '@flutter_dev',
status: 'Flutter UIを制作中',
about: 'Discord風UIをFlutterで再現しています。',
avatarColor: DiscordColors.blurple,
avatarUrl: '',
onlineStatus: OnlineStatus.online,
),
UserProfile(
name: 'mika_design',
handle: '@mika',
status: 'FigmaでUI調整中',
about: 'UI/UXと余白設計が好きです。',
avatarColor: Color(0xFFEB459E),
avatarUrl: '',
onlineStatus: OnlineStatus.online,
),
UserProfile(
name: 'code_senpai',
handle: '@senpai',
status: 'レビューできます',
about: 'DartとFlutterの設計をよく見ています。',
avatarColor: DiscordColors.green,
avatarUrl: '',
onlineStatus: OnlineStatus.idle,
),
UserProfile(
name: 'admin',
handle: '@admin',
status: '取り込み中',
about: 'サーバー管理者です。',
avatarColor: DiscordColors.red,
avatarUrl: '',
onlineStatus: OnlineStatus.doNotDisturb,
),
];
このように、複数のユーザー情報をListで持ちます。
members
├─ flutter_dev
├─ mika_design
├─ code_senpai
└─ admin
このListを使って、右側のメンバー一覧を作ります。
状態ごとにユーザーを分ける
メンバー一覧では、全員をそのまま並べるのではなく、状態ごとに分けます。
ONLINE
├─ flutter_dev
└─ mika_design
IDLE
└─ code_senpai
DO NOT DISTURB
└─ admin
そのために、where を使います。
final online = members
.where((member) => member.onlineStatus == OnlineStatus.online)
.toList();
final idle = members
.where((member) => member.onlineStatus == OnlineStatus.idle)
.toList();
final dnd = members
.where((member) => member.onlineStatus == OnlineStatus.doNotDisturb)
.toList();
whereとは何か
where は、Listの中から条件に合うものだけを取り出す処理です。
初心者向けには、次のように理解してください。
where
↓
条件に合うデータだけを選ぶ
今回の場合は、オンライン状態で分けています。
member.onlineStatus == OnlineStatus.online
これは、次の意味です。
このユーザーのonlineStatusがonlineなら残す
たとえば、members の中からオンラインの人だけを取り出すと、次のようになります。
flutter_dev → online → 残す
mika_design → online → 残す
code_senpai → idle → 除く
admin → doNotDisturb → 除く
こうして、online のListができます。
まず最小のMemberPanelを作る
最初は、見た目をシンプルにして、メンバー一覧だけを作ります。
次のコードを main.dart に貼り付けてください。
import 'package:flutter/material.dart';
void main() {
runApp(const MemberPanelPracticeApp());
}
class MemberPanelPracticeApp extends StatelessWidget {
const MemberPanelPracticeApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Member Panel Practice',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: DiscordColors.background,
),
home: const MemberPanelPracticePage(),
);
}
}
class DiscordColors {
static const Color background = Color(0xFF313338);
static const Color sidebar = Color(0xFF2B2D31);
static const Color panel = Color(0xFF232428);
static const Color hover = Color(0xFF35373C);
static const Color textPrimary = Color(0xFFF2F3F5);
static const Color textSecondary = Color(0xFFB5BAC1);
static const Color textMuted = Color(0xFF949BA4);
static const Color blurple = Color(0xFF5865F2);
static const Color green = Color(0xFF23A559);
static const Color yellow = Color(0xFFF0B232);
static const Color red = Color(0xFFF23F42);
}
class DiscordSpacing {
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
}
class DiscordRadius {
static const double md = 8;
}
enum OnlineStatus {
online,
idle,
doNotDisturb,
offline,
}
class UserProfile {
const UserProfile({
required this.name,
required this.handle,
required this.status,
required this.about,
required this.avatarColor,
required this.avatarUrl,
required this.onlineStatus,
});
final String name;
final String handle;
final String status;
final String about;
final Color avatarColor;
final String avatarUrl;
final OnlineStatus onlineStatus;
}
final members = [
const UserProfile(
name: 'flutter_dev',
handle: '@flutter_dev',
status: 'Flutter UIを制作中',
about: 'Discord風UIをFlutterで再現しています。',
avatarColor: DiscordColors.blurple,
avatarUrl: '',
onlineStatus: OnlineStatus.online,
),
const UserProfile(
name: 'mika_design',
handle: '@mika',
status: 'FigmaでUI調整中',
about: 'UI/UXと余白設計が好きです。',
avatarColor: Color(0xFFEB459E),
avatarUrl: '',
onlineStatus: OnlineStatus.online,
),
const UserProfile(
name: 'code_senpai',
handle: '@senpai',
status: 'レビューできます',
about: 'DartとFlutterの設計をよく見ています。',
avatarColor: DiscordColors.green,
avatarUrl: '',
onlineStatus: OnlineStatus.idle,
),
const UserProfile(
name: 'admin',
handle: '@admin',
status: '取り込み中',
about: 'サーバー管理者です。',
avatarColor: DiscordColors.red,
avatarUrl: '',
onlineStatus: OnlineStatus.doNotDisturb,
),
];
class MemberPanelPracticePage extends StatelessWidget {
const MemberPanelPracticePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: DiscordColors.background,
body: SafeArea(
child: Row(
children: [
const Expanded(
child: Center(
child: Text(
'ここにチャット画面が入ります',
style: TextStyle(
color: DiscordColors.textPrimary,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
SizedBox(
width: 280,
child: MemberPanel(members: members),
),
],
),
),
);
}
}
class MemberPanel extends StatelessWidget {
const MemberPanel({
super.key,
required this.members,
});
final List<UserProfile> members;
@override
Widget build(BuildContext context) {
final online = members
.where((member) => member.onlineStatus == OnlineStatus.online)
.toList();
final idle = members
.where((member) => member.onlineStatus == OnlineStatus.idle)
.toList();
final dnd = members
.where((member) => member.onlineStatus == OnlineStatus.doNotDisturb)
.toList();
return Container(
color: DiscordColors.sidebar,
child: ListView(
padding: const EdgeInsets.all(DiscordSpacing.lg),
children: [
const MemberSectionTitle(title: 'ONLINE'),
...online.map((member) => MemberTile(member: member)),
if (idle.isNotEmpty) const MemberSectionTitle(title: 'IDLE'),
...idle.map((member) => MemberTile(member: member)),
if (dnd.isNotEmpty) const MemberSectionTitle(title: 'DO NOT DISTURB'),
...dnd.map((member) => MemberTile(member: member)),
],
),
);
}
}
class MemberSectionTitle extends StatelessWidget {
const MemberSectionTitle({
super.key,
required this.title,
});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(6, 14, 6, 6),
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 0.6,
),
),
);
}
}
class MemberTile extends StatelessWidget {
const MemberTile({
super.key,
required this.member,
});
final UserProfile member;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: DiscordSpacing.sm,
vertical: DiscordSpacing.sm,
),
child: Row(
children: [
UserAvatar(
name: member.name,
color: member.avatarColor,
imageUrl: member.avatarUrl,
status: member.onlineStatus,
size: 34,
),
const SizedBox(width: DiscordSpacing.md),
Expanded(
child: Text(
member.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
}
class UserAvatar extends StatelessWidget {
const UserAvatar({
super.key,
required this.name,
required this.color,
required this.imageUrl,
required this.status,
required this.size,
this.borderColor,
});
final String name;
final Color color;
final String imageUrl;
final OnlineStatus status;
final double size;
final Color? borderColor;
@override
Widget build(BuildContext context) {
final initials = buildInitials(name);
final hasImage = imageUrl.trim().isNotEmpty;
return SizedBox(
width: size,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: size,
height: size,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: borderColor ?? Colors.transparent,
width: borderColor == null ? 0 : 5,
),
),
alignment: Alignment.center,
child: hasImage
? Image.network(
imageUrl,
fit: BoxFit.cover,
width: size,
height: size,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
initials,
style: TextStyle(
color: Colors.white,
fontSize: size * 0.34,
fontWeight: FontWeight.bold,
),
),
);
},
)
: Text(
initials,
style: TextStyle(
color: Colors.white,
fontSize: size * 0.34,
fontWeight: FontWeight.bold,
),
),
),
Positioned(
right: -1,
bottom: -1,
child: Container(
width: size * 0.32,
height: size * 0.32,
decoration: BoxDecoration(
color: statusColor(status),
shape: BoxShape.circle,
border: Border.all(
color: borderColor ?? DiscordColors.background,
width: 3,
),
),
),
),
],
),
);
}
}
Color statusColor(OnlineStatus status) {
switch (status) {
case OnlineStatus.online:
return DiscordColors.green;
case OnlineStatus.idle:
return DiscordColors.yellow;
case OnlineStatus.doNotDisturb:
return DiscordColors.red;
case OnlineStatus.offline:
return DiscordColors.textMuted;
}
}
String buildInitials(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) {
return '?';
}
final parts = trimmed.split(RegExp(r'\s+'));
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
if (trimmed.length >= 2) {
return trimmed.substring(0, 2).toUpperCase();
}
return trimmed[0].toUpperCase();
}
実行して確認すること
このコードを実行すると、右側にメンバー一覧が表示されます。
確認するポイントは、次の通りです。
ONLINEにflutter_devとmika_designが表示される
IDLEにcode_senpaiが表示される
DO NOT DISTURBにadminが表示される
アイコン右下に状態の色が出る
メンバー名が横に表示される
この時点で、Discord右側のメンバー一覧に近い形ができました。
MemberPanelの構造を確認する
MemberPanel は、右側のメンバー一覧全体です。
class MemberPanel extends StatelessWidget {
const MemberPanel({
super.key,
required this.members,
});
final List<UserProfile> members;
}
members を受け取って、その中身を状態ごとに分けています。
final online = members
.where((member) => member.onlineStatus == OnlineStatus.online)
.toList();
そして、それぞれを MemberTile に変換して表示しています。
...online.map((member) => MemberTile(member: member)),
つまり、流れは次の通りです。
members
↓
whereで状態ごとに分ける
↓
mapでMemberTileに変換する
↓
ListViewに表示する
この流れは、一覧UIでとてもよく使います。
…mapの意味を確認する
次のコードを見てください。
...online.map((member) => MemberTile(member: member)),
map は、データを別の形に変換する処理です。
今回の場合は、UserProfile を MemberTile に変換しています。
UserProfile
↓
MemberTile
... は、複数のWidgetを children の中に展開するための書き方です。
...map
↓
作った複数のWidgetをchildrenの中に並べる
たとえば、online に2人いれば、次のようなイメージです。
MemberTile(flutter_dev)
MemberTile(mika_design)
if (idle.isNotEmpty) の意味
次のコードを見てください。
if (idle.isNotEmpty) const MemberSectionTitle(title: 'IDLE'),
これは、idle のユーザーがいるときだけ、IDLE の見出しを表示するという意味です。
idleにユーザーがいる
↓
IDLE見出しを表示
idleにユーザーがいない
↓
IDLE見出しを表示しない
もし中身が空なのに見出しだけ出てしまうと、少し不自然です。
そのため、ユーザーがいるときだけ見出しを表示します。
MemberSectionTitleを分解する
MemberSectionTitle は、ONLINE や IDLE などの小さな見出しです。
class MemberSectionTitle extends StatelessWidget {
const MemberSectionTitle({
super.key,
required this.title,
});
final String title;
}
このWidgetは、文字を小さく、薄い色で表示します。
Text(
title,
style: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 0.6,
),
)
Discord風UIでは、こうしたセクション見出しは控えめに表示されます。
目立たせすぎない
↓
でも分類は分かる
MemberTileを分解する
MemberTile は、ユーザー1人分の表示です。
MemberTile
├─ UserAvatar
└─ ユーザー名
コードでは、Row を使って横に並べています。
Row(
children: [
UserAvatar(...),
SizedBox(width: 12),
Expanded(
child: Text(member.name),
),
],
)
Expanded を使っている理由は、長いユーザー名が横幅を超えないようにするためです。
Expanded(
child: Text(
member.name,
overflow: TextOverflow.ellipsis,
),
)
overflow: TextOverflow.ellipsis によって、長い名前は省略されます。
very_long_member_name_example
↓
very_long_member...
hover風の演出を追加する
Discordでは、メンバー名にマウスを重ねると、背景色が少し変わります。
これをFlutterで作るには、MouseRegion と AnimatedContainer を使います。
MemberTile を StatefulWidget に変更します。
class MemberTile extends StatefulWidget {
const MemberTile({
super.key,
required this.member,
});
final UserProfile member;
@override
State<MemberTile> createState() => _MemberTileState();
}
class _MemberTileState extends State<MemberTile> {
bool hovering = false;
@override
Widget build(BuildContext context) {
final member = widget.member;
return MouseRegion(
onEnter: (_) {
setState(() {
hovering = true;
});
},
onExit: (_) {
setState(() {
hovering = false;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(
horizontal: DiscordSpacing.sm,
vertical: DiscordSpacing.sm,
),
decoration: BoxDecoration(
color: hovering ? DiscordColors.hover : Colors.transparent,
borderRadius: BorderRadius.circular(DiscordRadius.md),
),
child: Row(
children: [
UserAvatar(
name: member.name,
color: member.avatarColor,
imageUrl: member.avatarUrl,
status: member.onlineStatus,
size: 34,
),
const SizedBox(width: DiscordSpacing.md),
Expanded(
child: Text(
member.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
}
これで、PCでマウスを重ねたときに、背景色が変わります。
MouseRegionとは何か
MouseRegion は、マウスがWidgetに入ったり出たりしたことを検知するWidgetです。
MouseRegion(
onEnter: (_) {
setState(() {
hovering = true;
});
},
onExit: (_) {
setState(() {
hovering = false;
});
},
child: ...
)
初心者向けには、次のように理解してください。
MouseRegion
↓
マウスが乗ったかどうかを検知するWidget
スマホではマウスがないため、hoverの動きは基本的に確認できません。
PC向けUIでは、hover表現を入れるとかなりアプリらしくなります。
AnimatedContainerとは何か
AnimatedContainer は、見た目の変化をなめらかにしてくれるWidgetです。
AnimatedContainer(
duration: const Duration(milliseconds: 120),
decoration: BoxDecoration(
color: hovering ? DiscordColors.hover : Colors.transparent,
),
)
hovering が変わると、背景色がなめらかに変わります。
通常
↓
透明
hover
↓
hover色
このような細かい動きがあると、Discord風のUIに近づきます。
プロフィールカードを右側に追加する
5-10 で作った ProfileCard を、MemberPanel の下部に追加します。
MemberPanel に currentUser も渡すように変更します。
class MemberPanel extends StatelessWidget {
const MemberPanel({
super.key,
required this.currentUser,
required this.members,
});
final UserProfile currentUser;
final List<UserProfile> members;
}
そして、ListViewの最後に ProfileCard を入れます。
const SizedBox(height: DiscordSpacing.lg),
ProfileCard(user: currentUser),
これにより、右側のメンバー欄の中に、自分のプロフィールカードを表示できます。
MemberPanel
├─ メンバー一覧
└─ ProfileCard
ProfileCardを入れるMemberPanel
完成形に近い MemberPanel は次のようになります。
class MemberPanel extends StatelessWidget {
const MemberPanel({
super.key,
required this.currentUser,
required this.members,
});
final UserProfile currentUser;
final List<UserProfile> members;
@override
Widget build(BuildContext context) {
final online = members
.where((member) => member.onlineStatus == OnlineStatus.online)
.toList();
final idle = members
.where((member) => member.onlineStatus == OnlineStatus.idle)
.toList();
final dnd = members
.where((member) => member.onlineStatus == OnlineStatus.doNotDisturb)
.toList();
final offline = members
.where((member) => member.onlineStatus == OnlineStatus.offline)
.toList();
return Container(
color: DiscordColors.sidebar,
child: ListView(
padding: const EdgeInsets.all(DiscordSpacing.lg),
children: [
if (online.isNotEmpty) const MemberSectionTitle(title: 'ONLINE'),
...online.map((member) => MemberTile(member: member)),
if (idle.isNotEmpty) const MemberSectionTitle(title: 'IDLE'),
...idle.map((member) => MemberTile(member: member)),
if (dnd.isNotEmpty) const MemberSectionTitle(title: 'DO NOT DISTURB'),
...dnd.map((member) => MemberTile(member: member)),
if (offline.isNotEmpty) const MemberSectionTitle(title: 'OFFLINE'),
...offline.map((member) => MemberTile(member: member)),
const SizedBox(height: DiscordSpacing.lg),
ProfileCard(user: currentUser),
],
),
);
}
}
ここでは、offline も追加しています。
ユーザーがオフライン状態の場合にも、一覧として表示できます。
ProfileCardのコードを追加する
右側のメンバー欄にプロフィールカードを表示するため、ProfileCard も用意します。
class ProfileCard extends StatelessWidget {
const ProfileCard({
super.key,
required this.user,
});
final UserProfile user;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(top: DiscordSpacing.lg),
decoration: BoxDecoration(
color: DiscordColors.panel,
borderRadius: BorderRadius.circular(DiscordRadius.md),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 108,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
height: 58,
color: user.avatarColor,
),
Positioned(
left: 14,
top: 30,
child: UserAvatar(
name: user.name,
color: user.avatarColor,
imageUrl: user.avatarUrl,
status: user.onlineStatus,
size: 66,
borderColor: DiscordColors.panel,
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textPrimary,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: DiscordSpacing.sm),
Text(
user.handle,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 13,
),
),
const SizedBox(height: DiscordSpacing.sm),
Text(
user.status,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textSecondary,
fontSize: 13,
),
),
const SizedBox(height: DiscordSpacing.lg),
Container(height: 1, color: Colors.white12),
const SizedBox(height: DiscordSpacing.lg),
const Text(
'ABOUT ME',
style: TextStyle(
color: DiscordColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 0.4,
),
),
const SizedBox(height: DiscordSpacing.sm),
Text(
user.about,
style: const TextStyle(
color: DiscordColors.textSecondary,
fontSize: 13,
height: 1.5,
),
),
],
),
),
],
),
);
}
}
ここでは、メンバー欄に収まりやすいように、カード幅に合わせて少しコンパクトにしています。
完成コード
ここまでをまとめた完成コードです。
import 'package:flutter/material.dart';
void main() {
runApp(const MemberPanelPracticeApp());
}
class MemberPanelPracticeApp extends StatelessWidget {
const MemberPanelPracticeApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Member Panel Practice',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: DiscordColors.background,
),
home: const MemberPanelPracticePage(),
);
}
}
class DiscordColors {
static const Color background = Color(0xFF313338);
static const Color sidebar = Color(0xFF2B2D31);
static const Color panel = Color(0xFF232428);
static const Color hover = Color(0xFF35373C);
static const Color textPrimary = Color(0xFFF2F3F5);
static const Color textSecondary = Color(0xFFB5BAC1);
static const Color textMuted = Color(0xFF949BA4);
static const Color blurple = Color(0xFF5865F2);
static const Color green = Color(0xFF23A559);
static const Color yellow = Color(0xFFF0B232);
static const Color red = Color(0xFFF23F42);
}
class DiscordSpacing {
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
}
class DiscordRadius {
static const double md = 8;
}
enum OnlineStatus {
online,
idle,
doNotDisturb,
offline,
}
class UserProfile {
const UserProfile({
required this.name,
required this.handle,
required this.status,
required this.about,
required this.avatarColor,
required this.avatarUrl,
required this.onlineStatus,
});
final String name;
final String handle;
final String status;
final String about;
final Color avatarColor;
final String avatarUrl;
final OnlineStatus onlineStatus;
}
final currentUser = const UserProfile(
name: 'flutter_dev',
handle: '@flutter_dev',
status: 'Flutter UIを制作中',
about: 'Discord風UIをFlutterで再現しています。プロフィールは一時保存のみです。',
avatarColor: DiscordColors.blurple,
avatarUrl: '',
onlineStatus: OnlineStatus.online,
);
final members = [
currentUser,
const UserProfile(
name: 'mika_design',
handle: '@mika',
status: 'FigmaでUI調整中',
about: 'UI/UXと余白設計が好きです。',
avatarColor: Color(0xFFEB459E),
avatarUrl: '',
onlineStatus: OnlineStatus.online,
),
const UserProfile(
name: 'flutter_bot',
handle: '@bot',
status: '自動応答',
about: '学習用Botです。',
avatarColor: Color(0xFF00B0F4),
avatarUrl: '',
onlineStatus: OnlineStatus.online,
),
const UserProfile(
name: 'code_senpai',
handle: '@senpai',
status: 'レビューできます',
about: 'DartとFlutterの設計をよく見ています。',
avatarColor: DiscordColors.green,
avatarUrl: '',
onlineStatus: OnlineStatus.idle,
),
const UserProfile(
name: 'admin',
handle: '@admin',
status: '取り込み中',
about: 'サーバー管理者です。',
avatarColor: DiscordColors.red,
avatarUrl: '',
onlineStatus: OnlineStatus.doNotDisturb,
),
const UserProfile(
name: 'old_member',
handle: '@old',
status: 'オフライン',
about: '現在オフラインです。',
avatarColor: DiscordColors.textMuted,
avatarUrl: '',
onlineStatus: OnlineStatus.offline,
),
];
class MemberPanelPracticePage extends StatelessWidget {
const MemberPanelPracticePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: DiscordColors.background,
body: SafeArea(
child: Row(
children: [
const Expanded(
child: Center(
child: Text(
'ここにチャット画面が入ります',
style: TextStyle(
color: DiscordColors.textPrimary,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
SizedBox(
width: 300,
child: MemberPanel(
currentUser: currentUser,
members: members,
),
),
],
),
),
);
}
}
class MemberPanel extends StatelessWidget {
const MemberPanel({
super.key,
required this.currentUser,
required this.members,
});
final UserProfile currentUser;
final List<UserProfile> members;
@override
Widget build(BuildContext context) {
final online = members
.where((member) => member.onlineStatus == OnlineStatus.online)
.toList();
final idle = members
.where((member) => member.onlineStatus == OnlineStatus.idle)
.toList();
final dnd = members
.where((member) => member.onlineStatus == OnlineStatus.doNotDisturb)
.toList();
final offline = members
.where((member) => member.onlineStatus == OnlineStatus.offline)
.toList();
return Container(
color: DiscordColors.sidebar,
child: ListView(
padding: const EdgeInsets.all(DiscordSpacing.lg),
children: [
if (online.isNotEmpty) const MemberSectionTitle(title: 'ONLINE'),
...online.map((member) => MemberTile(member: member)),
if (idle.isNotEmpty) const MemberSectionTitle(title: 'IDLE'),
...idle.map((member) => MemberTile(member: member)),
if (dnd.isNotEmpty) const MemberSectionTitle(title: 'DO NOT DISTURB'),
...dnd.map((member) => MemberTile(member: member)),
if (offline.isNotEmpty) const MemberSectionTitle(title: 'OFFLINE'),
...offline.map((member) => MemberTile(member: member)),
const SizedBox(height: DiscordSpacing.lg),
ProfileCard(user: currentUser),
],
),
);
}
}
class MemberSectionTitle extends StatelessWidget {
const MemberSectionTitle({
super.key,
required this.title,
});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(6, 14, 6, 6),
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 0.6,
),
),
);
}
}
class MemberTile extends StatefulWidget {
const MemberTile({
super.key,
required this.member,
});
final UserProfile member;
@override
State<MemberTile> createState() => _MemberTileState();
}
class _MemberTileState extends State<MemberTile> {
bool hovering = false;
@override
Widget build(BuildContext context) {
final member = widget.member;
return MouseRegion(
onEnter: (_) {
setState(() {
hovering = true;
});
},
onExit: (_) {
setState(() {
hovering = false;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(
horizontal: DiscordSpacing.sm,
vertical: DiscordSpacing.sm,
),
decoration: BoxDecoration(
color: hovering ? DiscordColors.hover : Colors.transparent,
borderRadius: BorderRadius.circular(DiscordRadius.md),
),
child: Row(
children: [
UserAvatar(
name: member.name,
color: member.avatarColor,
imageUrl: member.avatarUrl,
status: member.onlineStatus,
size: 34,
),
const SizedBox(width: DiscordSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
member.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
Text(
member.status,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 11,
),
),
],
),
),
],
),
),
);
}
}
class ProfileCard extends StatelessWidget {
const ProfileCard({
super.key,
required this.user,
});
final UserProfile user;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(top: DiscordSpacing.lg),
decoration: BoxDecoration(
color: DiscordColors.panel,
borderRadius: BorderRadius.circular(DiscordRadius.md),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 108,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
height: 58,
color: user.avatarColor,
),
Positioned(
left: 14,
top: 30,
child: UserAvatar(
name: user.name,
color: user.avatarColor,
imageUrl: user.avatarUrl,
status: user.onlineStatus,
size: 66,
borderColor: DiscordColors.panel,
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textPrimary,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: DiscordSpacing.sm),
Text(
user.handle,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 13,
),
),
const SizedBox(height: DiscordSpacing.sm),
Text(
user.status,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textSecondary,
fontSize: 13,
),
),
const SizedBox(height: DiscordSpacing.lg),
Container(height: 1, color: Colors.white12),
const SizedBox(height: DiscordSpacing.lg),
const Text(
'ABOUT ME',
style: TextStyle(
color: DiscordColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 0.4,
),
),
const SizedBox(height: DiscordSpacing.sm),
Text(
user.about,
style: const TextStyle(
color: DiscordColors.textSecondary,
fontSize: 13,
height: 1.5,
),
),
],
),
),
],
),
);
}
}
class UserAvatar extends StatelessWidget {
const UserAvatar({
super.key,
required this.name,
required this.color,
required this.imageUrl,
required this.status,
required this.size,
this.borderColor,
});
final String name;
final Color color;
final String imageUrl;
final OnlineStatus status;
final double size;
final Color? borderColor;
@override
Widget build(BuildContext context) {
final initials = buildInitials(name);
final hasImage = imageUrl.trim().isNotEmpty;
return SizedBox(
width: size,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: size,
height: size,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: borderColor ?? Colors.transparent,
width: borderColor == null ? 0 : 5,
),
),
alignment: Alignment.center,
child: hasImage
? Image.network(
imageUrl,
fit: BoxFit.cover,
width: size,
height: size,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
initials,
style: TextStyle(
color: Colors.white,
fontSize: size * 0.34,
fontWeight: FontWeight.bold,
),
),
);
},
)
: Text(
initials,
style: TextStyle(
color: Colors.white,
fontSize: size * 0.34,
fontWeight: FontWeight.bold,
),
),
),
Positioned(
right: -1,
bottom: -1,
child: Container(
width: size * 0.32,
height: size * 0.32,
decoration: BoxDecoration(
color: statusColor(status),
shape: BoxShape.circle,
border: Border.all(
color: borderColor ?? DiscordColors.background,
width: 3,
),
),
),
),
],
),
);
}
}
Color statusColor(OnlineStatus status) {
switch (status) {
case OnlineStatus.online:
return DiscordColors.green;
case OnlineStatus.idle:
return DiscordColors.yellow;
case OnlineStatus.doNotDisturb:
return DiscordColors.red;
case OnlineStatus.offline:
return DiscordColors.textMuted;
}
}
String buildInitials(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) {
return '?';
}
final parts = trimmed.split(RegExp(r'\s+'));
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
if (trimmed.length >= 2) {
return trimmed.substring(0, 2).toUpperCase();
}
return trimmed[0].toUpperCase();
}
実行して確認すること
完成コードを実行したら、次の点を確認してください。
右側にメンバー一覧が表示される
ONLINE / IDLE / DO NOT DISTURB / OFFLINE に分かれている
各メンバーにアイコン・名前・ステータスメッセージが表示される
アイコン右下に状態色が表示される
メンバーにマウスを重ねると背景色が変わる
下部にプロフィールカードが表示される
これで、Discord風の右側メンバー欄が完成に近づきました。
今回の流れを整理する
今回の実装の流れは、次の通りです。
UserProfileのListを作る
↓
onlineStatusで分類する
↓
MemberSectionTitleで見出しを作る
↓
MemberTileでユーザー1人分を表示する
↓
UserAvatarで状態つきアイコンを表示する
↓
ProfileCardを下に追加する
メンバー一覧は、データを分類して表示するUIです。
データを分ける
↓
見出しを出す
↓
一覧として表示する
これは、チャットアプリ以外にもよく使える考え方です。
手を動かす練習1:新しいオンラインメンバーを追加する
members に、次のユーザーを追加してみましょう。
const UserProfile(
name: 'new_student',
handle: '@student',
status: '学習中',
about: 'Flutterを学んでいます。',
avatarColor: Color(0xFF9B59FF),
avatarUrl: '',
onlineStatus: OnlineStatus.online,
),
ONLINE の中に新しいメンバーが追加されます。
手を動かす練習2:idleをonlineに変える
code_senpai の onlineStatus を変えてみましょう。
onlineStatus: OnlineStatus.online,
すると、code_senpai が IDLE から ONLINE に移動します。
この練習で、データの状態を変えるだけで表示位置が変わることを確認できます。
手を動かす練習3:OFFLINEを非表示にしてみる
old_member を削除してみましょう。
すると、OFFLINE 見出しも表示されなくなります。
理由は、次の条件があるからです。
if (offline.isNotEmpty) const MemberSectionTitle(title: 'OFFLINE'),
空のセクションは表示しないようにしています。
手を動かす練習4:プロフィールカードを非表示にする
MemberPanel の最後にある次の部分をコメントアウトしてみましょう。
ProfileCard(user: currentUser),
メンバー一覧だけの右側パネルになります。
そのあと、元に戻してください。
メンバー一覧だけ
↓
プロフィールカードあり
違いを確認できます。
手を動かす練習5:hover色を変える
DiscordColors.hover を変更してみましょう。
static const Color hover = Color(0xFF3A3D44);
メンバーにマウスを重ねたときの背景色が少し明るくなります。
よくあるつまずき1:分類されずに全部同じ場所に出る
ユーザーを状態ごとに分けるには、where が必要です。
final online = members
.where((member) => member.onlineStatus == OnlineStatus.online)
.toList();
これがないと、すべてのメンバーを同じ場所に並べるだけになります。
分類なし
↓
全員同じ一覧
分類あり
↓
ONLINE / IDLE / DO NOT DISTURB に分かれる
よくあるつまずき2:見出しだけ表示される
空のセクション見出しを出さないためには、isNotEmpty を使います。
if (idle.isNotEmpty) const MemberSectionTitle(title: 'IDLE'),
これにより、該当ユーザーがいない場合は見出しも出ません。
よくあるつまずき3:ユーザー名がはみ出る
ユーザー名が長い場合は、Expanded と TextOverflow.ellipsis を使います。
Expanded(
child: Text(
member.name,
overflow: TextOverflow.ellipsis,
),
)
横幅が決まっているメンバー欄では重要です。
よくあるつまずき4:hoverがスマホで確認できない
MouseRegion は、主にPC向けのマウス操作です。
スマホではhoverがないため、背景色の変化は基本的に確認できません。
PC
↓
hoverが分かる
スマホ
↓
タップ操作が中心
スマホ対応は、別の章でDrawerやレスポンシブ対応として扱えます。
よくあるつまずき5:ProfileCardが大きすぎる
右側のメンバー欄は横幅が狭いです。
そのため、プロフィールカードは大きくしすぎないようにします。
SizedBox(
width: 300,
child: MemberPanel(...),
)
カード内の文字が長い場合は、overflow: TextOverflow.ellipsis やスクロール対応を使うと安全です。
この節の確認問題
確認問題1
MemberPanel は何を担当するWidgetですか。
答え
右側のメンバー一覧全体を担当するWidgetです。
確認問題2
MemberTile は何を担当するWidgetですか。
答え
ユーザー1人分の表示を担当するWidgetです。
確認問題3
ONLINE や IDLE の見出しを担当するWidgetは何ですか。
答え
MemberSectionTitle です。
確認問題4
ユーザーをオンライン状態ごとに分けるために使った処理は何ですか。
答え
where です。
確認問題5
空のセクション見出しを表示しないために使った条件は何ですか。
答え
isNotEmpty です。
確認問題6
メンバーにマウスを重ねたときの見た目を変えるために使ったWidgetは何ですか。
答え
MouseRegion と AnimatedContainer です。
確認問題7
ユーザーアイコン右下の状態色を表示するために使ったWidget構造は何ですか。
答え
UserAvatar の中で Stack と Positioned を使っています。
この節のまとめ
この節では、Discord風アプリの右側に表示されるメンバー一覧UIを作りました。
今回作った主な部品は、次の通りです。
MemberPanel
MemberSectionTitle
MemberTile
UserAvatar
ProfileCard
今回の中心の流れは、次の通りです。
UserProfileのListを用意する
↓
onlineStatusで分類する
↓
ONLINE / IDLE / DO NOT DISTURB / OFFLINE の見出しを出す
↓
MemberTileでユーザー1人分を表示する
↓
下部にProfileCardを表示する
この節で一番大切なのは、次の考え方です。
一覧UIは、データを分類し、見出しと1件分のWidgetに分けると整理して作れる。
次の節では、ここまで作ってきたサーバー一覧、チャンネル一覧、チャット画面、メンバー一覧を1つにまとめ、Discord風アプリ全体の完成コードを読み解いていきます。