TEXTBOOK SECTION / AI LEARNING

【メンバー一覧UI】オンライン・離席中・取り込み中を分けて表示する

Flutterアプリケーション開発概論の「Discord風チャットアプリ開発入門:Column・Rowで学ぶレスポンシブUI構築」より、【メンバー一覧UI】オンライン・離席中・取り込み中を分けて表示するを解説。生成AI、AI活用、DX、業務改善を実践しながら学べるオンライン教材です。

第13節Discord風チャットアプリ開発入門:Column・Rowで学ぶレスポンシブUI構築Flutter / iOS / Android / MacOS / Windows / 基礎から学ぶ / 開発 / アプリ開発

OVERVIEW

この節で学べること

概要を表示する
項目内容
教材名Flutterアプリケーション開発概論
章Discord風チャットアプリ開発入門:Column・Rowで学ぶレスポンシブUI構築
節【メンバー一覧UI】オンライン・離席中・取り込み中を分けて表示する
カテゴリFlutter / iOS / Android / MacOS / Windows / 基礎から学ぶ / 開発 / アプリ開発
学習内容生成AI、AI活用、DX、業務改善を実践しながら理解するための教材です。

TABLE OF CONTENTS

目次

CONTENT

ここから

この節で学ぶこと

前回の 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右側のメンバー一覧全体
MemberSectionTitleONLINE や 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風アプリ全体の完成コードを読み解いていきます。

FAQ

よくある質問

【メンバー一覧UI】オンライン・離席中・取り込み中を分けて表示するは医療関係者向けだけの内容ですか。
医療分野の例が含まれる場合もありますが、医療関係者だけに限定した内容ではありません。生成AI、AI活用、DX、業務改善、プロトタイプ開発など、一般的なAI学習の事例として読める内容です。
AI初心者でも読めますか。
はい。AIをこれから学ぶ方、数学が苦手な方、仕事でAIを使いたい方にも読み進めやすいように、教材の章と節の流れに沿って整理しています。
サムネイル画像は必ず表示されますか。
はい。教材にcoverUrlが設定されている場合はその画像を表示し、未設定の場合は代替サムネイル画像を表示します。
Flutterアプリケーション開発概論のほかの章も読めますか。
はい。教材トップから章立てを確認でき、前後の節へもページ下部のナビゲーションから移動できます。