Flutterアプリケーション開発概論

【メンバー一覧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右側のメンバー一覧全体
MemberSectionTitleONLINEIDLE などの見出し
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 は、データを別の形に変換する処理です。

今回の場合は、UserProfileMemberTile に変換しています。

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 は、ONLINEIDLE などの小さな見出しです。

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で作るには、MouseRegionAnimatedContainer を使います。

MemberTileStatefulWidget に変更します。

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 の下部に追加します。

MemberPanelcurrentUser も渡すように変更します。

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_senpaionlineStatus を変えてみましょう。

onlineStatus: OnlineStatus.online,

すると、code_senpaiIDLE から 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:ユーザー名がはみ出る

ユーザー名が長い場合は、ExpandedTextOverflow.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

ONLINEIDLE の見出しを担当するWidgetは何ですか。

答え

MemberSectionTitle です。

確認問題4

ユーザーをオンライン状態ごとに分けるために使った処理は何ですか。

答え

where です。

確認問題5

空のセクション見出しを表示しないために使った条件は何ですか。

答え

isNotEmpty です。

確認問題6

メンバーにマウスを重ねたときの見た目を変えるために使ったWidgetは何ですか。

答え

MouseRegionAnimatedContainer です。

確認問題7

ユーザーアイコン右下の状態色を表示するために使ったWidget構造は何ですか。

答え

UserAvatar の中で StackPositioned を使っています。

この節のまとめ

この節では、Discord風アプリの右側に表示されるメンバー一覧UIを作りました。

今回作った主な部品は、次の通りです。

MemberPanel
MemberSectionTitle
MemberTile
UserAvatar
ProfileCard

今回の中心の流れは、次の通りです。

UserProfileのListを用意する
↓
onlineStatusで分類する
↓
ONLINE / IDLE / DO NOT DISTURB / OFFLINE の見出しを出す
↓
MemberTileでユーザー1人分を表示する
↓
下部にProfileCardを表示する

この節で一番大切なのは、次の考え方です。

一覧UIは、データを分類し、見出しと1件分のWidgetに分けると整理して作れる。

次の節では、ここまで作ってきたサーバー一覧、チャンネル一覧、チャット画面、メンバー一覧を1つにまとめ、Discord風アプリ全体の完成コードを読み解いていきます。

教材トップへ戻る