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

【プロフィールカードUI】Discord風のユーザープロフィールを作る

この節で学ぶこと

前回の 5-9 では、入力欄に書いたメッセージをチャット欄に追加する機能を作りました。

TextFieldで入力する
↓
controller.textで文字を取得する
↓
ChatMessageを作る
↓
localMessagesに追加する
↓
setStateで画面を更新する

今回の 5-10 では、Discord風アプリの右側に表示するプロフィールカードUIを作ります。

Discord風アプリでは、右側のメンバー欄にユーザー一覧があり、その下や詳細表示としてプロフィールカードが表示されます。

右側のメンバー欄
├─ ONLINE
├─ flutter_dev
├─ mika_design
├─ flutter_bot
└─ プロフィールカード

この節では、ユーザー情報をもとに、次のようなカードUIを作ります。

上部の背景色
丸いユーザーアイコン
オンライン状態の丸い表示
ユーザー名
ハンドル名
ABOUT ME
自己紹介文

この節で一番大切なのは、次の一文です。

プロフィールカードは、UserProfileデータを受け取り、Stack・Column・Paddingを組み合わせて作る。

今回作る部品

今回作る主なWidgetは、次の3つです。

Widget名役割
ProfileCardDiscord風プロフィールカード全体
UserAvatar丸いユーザーアイコン
MemberPanel右側のメンバー欄全体

構造で見ると、次のようになります。

MemberPanel
├─ ONLINE
├─ MemberTile
├─ MemberTile
├─ MemberTile
└─ ProfileCard
   ├─ 上部の色付き背景
   ├─ UserAvatar
   ├─ ユーザー名
   ├─ ハンドル名
   ├─ ABOUT ME
   └─ 自己紹介文

まずは、プロフィールカードに必要なデータを確認します。

プロフィールカードに必要なデータ

プロフィールカードには、ユーザー1人分の情報が必要です。

前の節で作った UserProfile classを使います。

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;
}

それぞれのpropertyの意味は、次の通りです。

property内容表示場所
name表示名ユーザー名
handle@flutter_dev のようなIDユーザー名の下
status今の状態メンバー欄など
about自己紹介文ABOUT ME
avatarColorアイコン色・背景色アイコン、カード上部
avatarUrlアイコン画像URL画像アイコン
onlineStatusオンライン状態アイコン右下の丸

このように、プロフィールカードに必要な情報は、UserProfile にまとめられています。

まず完成イメージを分解する

Discord風のプロフィールカードは、次のように分解できます。

ProfileCard
├─ 外側のカード背景
├─ 上部のバナー色
├─ 丸いアイコン
├─ オンライン状態の小さな丸
├─ ユーザー名
├─ ハンドル名
├─ 区切り線
├─ ABOUT ME
└─ 自己紹介文

難しそうに見えますが、Flutterの基本Widgetに分けると、次のようになります。

Container
↓
カード背景・角丸

Column
↓
上から下に情報を並べる

Stack
↓
バナーとアイコンを重ねる

Text
↓
名前・ハンドル・自己紹介を表示

Padding
↓
余白を整える

つまり、これまで学んだ ContainerColumnStackTextPadding を組み合わせれば作れます。

まずは最小のプロフィールカードを作る

最初は、画像URLやオンライン状態までは入れず、カードの形だけを作ります。

次のコードを main.dart に貼り付けてください。

import 'package:flutter/material.dart';

void main() {
  runApp(const ProfileCardPracticeApp());
}

class ProfileCardPracticeApp extends StatelessWidget {
  const ProfileCardPracticeApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Profile Card Practice',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        brightness: Brightness.dark,
        scaffoldBackgroundColor: DiscordColors.background,
      ),
      home: const ProfileCardPracticePage(),
    );
  }
}

class DiscordColors {
  static const Color background = Color(0xFF313338);
  static const Color panel = Color(0xFF232428);
  static const Color textPrimary = Color(0xFFF2F3F5);
  static const Color textSecondary = Color(0xFFB5BAC1);
  static const Color textMuted = Color(0xFF949BA4);
  static const Color blurple = Color(0xFF5865F2);
}

class DiscordSpacing {
  static const double sm = 8;
  static const double md = 12;
  static const double lg = 16;
  static const double xl = 24;
}

class DiscordRadius {
  static const double lg = 12;
}

class ProfileCardPracticePage extends StatelessWidget {
  const ProfileCardPracticePage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      backgroundColor: DiscordColors.background,
      body: SafeArea(
        child: Center(
          child: ProfileCard(),
        ),
      ),
    );
  }
}

class ProfileCard extends StatelessWidget {
  const ProfileCard({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 280,
      decoration: BoxDecoration(
        color: DiscordColors.panel,
        borderRadius: BorderRadius.circular(DiscordRadius.lg),
      ),
      clipBehavior: Clip.antiAlias,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            height: 72,
            color: DiscordColors.blurple,
          ),
          const Padding(
            padding: EdgeInsets.all(DiscordSpacing.lg),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  'flutter_dev',
                  style: TextStyle(
                    color: DiscordColors.textPrimary,
                    fontSize: 22,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                SizedBox(height: DiscordSpacing.sm),
                Text(
                  '@flutter_dev',
                  style: TextStyle(
                    color: DiscordColors.textMuted,
                    fontSize: 14,
                  ),
                ),
                SizedBox(height: DiscordSpacing.lg),
                Divider(color: Colors.white12),
                SizedBox(height: DiscordSpacing.lg),
                Text(
                  'ABOUT ME',
                  style: TextStyle(
                    color: DiscordColors.textMuted,
                    fontSize: 12,
                    fontWeight: FontWeight.bold,
                    letterSpacing: 0.4,
                  ),
                ),
                SizedBox(height: DiscordSpacing.sm),
                Text(
                  'Discord風UIをFlutterで再現しています。プロフィールは一時保存のみです。',
                  style: TextStyle(
                    color: DiscordColors.textSecondary,
                    fontSize: 14,
                    height: 1.5,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

実行して確認すること

実行すると、中央にDiscord風のプロフィールカードが表示されます。

確認してほしいポイントは、次の通りです。

カード背景が暗い
上部に青紫のバナーがある
ユーザー名が大きく表示される
ハンドル名が薄い色で表示される
ABOUT MEが小さな見出しとして表示される
自己紹介文が表示される

この時点では、まだアイコンはありません。

まずは、プロフィールカードの基本形を作りました。

clipBehaviorとは何か

カードの Container には、次の設定があります。

clipBehavior: Clip.antiAlias,

これは、カードの角丸から中身がはみ出さないようにするための設定です。

初心者向けには、次のように理解してください。

clipBehavior
↓
はみ出した部分を切り取る設定

たとえば、カード上部の青紫バナーは、カードの角丸に合わせて切り取られる必要があります。

clipBehavior がないと、角丸の外にはみ出して見える場合があります。

角丸カード
↓
中のバナーも角丸に合わせて切り取る

UserProfileデータを使う

次に、固定文字ではなく、UserProfile データを使って表示します。

まず、オンライン状態を表す enum と、ユーザー情報を表す UserProfile classを作ります。

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;
}

そして、表示用のユーザーデータを作ります。

const currentUser = UserProfile(
  name: 'flutter_dev',
  handle: '@flutter_dev',
  status: 'Flutter UIを制作中',
  about: 'Discord風UIをFlutterで再現しています。プロフィールは一時保存のみです。',
  avatarColor: DiscordColors.blurple,
  avatarUrl: '',
  onlineStatus: OnlineStatus.online,
);

ProfileCardにuserを渡す

ProfileCard は、UserProfile を受け取る形にします。

class ProfileCard extends StatelessWidget {
  const ProfileCard({
    super.key,
    required this.user,
  });

  final UserProfile user;

  @override
  Widget build(BuildContext context) {
    ...
  }
}

使う側では、次のように渡します。

ProfileCard(user: currentUser)

こうすると、カードの中で user.nameuser.about を使えます。

Text(user.name)
Text(user.handle)
Text(user.about)

つまり、プロフィールカードの見た目は共通のまま、中身だけを差し替えられます。

データを使ったProfileCard

ProfileCard を次のように変更します。

class ProfileCard extends StatelessWidget {
  const ProfileCard({
    super.key,
    required this.user,
  });

  final UserProfile user;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 280,
      decoration: BoxDecoration(
        color: DiscordColors.panel,
        borderRadius: BorderRadius.circular(DiscordRadius.lg),
      ),
      clipBehavior: Clip.antiAlias,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            height: 72,
            color: user.avatarColor,
          ),
          Padding(
            padding: const EdgeInsets.all(DiscordSpacing.lg),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  user.name,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    color: DiscordColors.textPrimary,
                    fontSize: 22,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(height: DiscordSpacing.sm),
                Text(
                  user.handle,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    color: DiscordColors.textMuted,
                    fontSize: 14,
                  ),
                ),
                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: 14,
                    height: 1.5,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

実行するための差し替え

ProfileCardPracticePage では、次のように使います。

class ProfileCardPracticePage extends StatelessWidget {
  const ProfileCardPracticePage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      backgroundColor: DiscordColors.background,
      body: SafeArea(
        child: Center(
          child: ProfileCard(user: currentUser),
        ),
      ),
    );
  }
}

ここで、currentUserconst なので、ProfileCard(user: currentUser)const の中で使えます。

丸いユーザーアイコンを作る

次に、カードに丸いユーザーアイコンを追加します。

ユーザーアイコンは、いろいろな場所で使います。

メンバー一覧
チャットメッセージ
プロフィールカード
下部ユーザー情報

そのため、UserAvatar というWidgetに分けます。

UserAvatar
├─ 丸いアイコン
├─ 画像URLがあれば画像表示
├─ 画像URLがなければ文字表示
└─ 右下にオンライン状態の丸

まずは、画像なしで文字表示するアイコンを作ります。

class UserAvatar extends StatelessWidget {
  const UserAvatar({
    super.key,
    required this.name,
    required this.color,
    required this.status,
    required this.size,
    this.borderColor,
  });

  final String name;
  final Color color;
  final OnlineStatus status;
  final double size;
  final Color? borderColor;

  @override
  Widget build(BuildContext context) {
    final initials = buildInitials(name);

    return SizedBox(
      width: size,
      height: size,
      child: Stack(
        clipBehavior: Clip.none,
        children: [
          Container(
            width: size,
            height: size,
            alignment: Alignment.center,
            decoration: BoxDecoration(
              color: color,
              shape: BoxShape.circle,
              border: Border.all(
                color: borderColor ?? Colors.transparent,
                width: borderColor == null ? 0 : 5,
              ),
            ),
            child: 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,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

UserAvatarの構造

UserAvatar は、Stack で作っています。

UserAvatar
└─ Stack
   ├─ 丸いアイコン本体
   └─ 右下のオンライン状態

アイコン本体とオンライン状態の丸を重ねるために、Stack を使います。

Stack(
  children: [
    アイコン本体,
    Positioned(
      right: -1,
      bottom: -1,
      child: オンライン状態の丸,
    ),
  ],
)

Positioned を使うことで、オンライン状態の丸を右下に置いています。

Color?とは何か

UserAvatar には、次のpropertyがあります。

final Color? borderColor;

Color?? は、「値がない場合もある」という意味です。

初心者向けには、次のように理解してください。

Color
↓
必ずColorが入る

Color?
↓
Colorが入ることもあるし、nullの場合もある

プロフィールカードの上では、アイコンをカード背景から浮かせるために、太い枠線をつけます。

そのときに borderColor を使います。

borderColor: DiscordColors.panel

一方、通常のメンバー一覧では、枠線が不要な場合もあります。

その場合は、borderColor を渡さなくてもよいようにしています。

buildInitials関数を作る

画像がない場合、ユーザー名から短い文字を作ります。

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();
}

この関数は、名前からアイコン用の文字を作ります。

flutter_dev
↓
FL

mika design
↓
MD

空文字
↓
?

split(RegExp(r'\s+')) は、空白で名前を分ける処理です。

初心者のうちは、完全に覚えなくても大丈夫です。

まずは、「名前からアイコン用の短い文字を作っている」と理解してください。

statusColor関数を作る

オンライン状態に応じて、表示する色を変えます。

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;
  }
}

この関数は、OnlineStatus を受け取って、対応する色を返します。

online
↓
緑

idle
↓
黄色

doNotDisturb
↓
赤

offline
↓
グレー

このように、表示ルールを関数にまとめておくと、UI側が読みやすくなります。

ProfileCardにUserAvatarを重ねる

次に、プロフィールカードの上部に UserAvatar を重ねます。

ここで使うのが Stack です。

カード上部
├─ バナー背景
└─ 丸いアイコンを少し下に重ねる

ProfileCard の上部を次のように変更します。

SizedBox(
  height: 118,
  child: Stack(
    clipBehavior: Clip.none,
    children: [
      Container(
        height: 64,
        color: user.avatarColor,
      ),
      Positioned(
        left: 14,
        top: 34,
        child: UserAvatar(
          name: user.name,
          color: user.avatarColor,
          status: user.onlineStatus,
          size: 72,
          borderColor: DiscordColors.panel,
        ),
      ),
    ],
  ),
),

ここでは、上部のバナーは高さ64です。

アイコンは top: 34 に配置しているので、バナー部分から少し下にはみ出して見えます。

これがDiscord風のプロフィールカードらしさになります。

バナー
↓
アイコンが少し重なる
↓
プロフィールカードらしい見た目になる

Positionedとは何か

Positioned は、Stack の中で位置を指定するためのWidgetです。

Positioned(
  left: 14,
  top: 34,
  child: UserAvatar(...),
)

これは、次のような意味です。

左から14px
上から34px
の位置にUserAvatarを置く

StackPositioned を組み合わせると、重なりのあるUIを作れます。

プロフィールカードやバナー付きカードではよく使います。

画像URLに対応したUserAvatar

次に、ユーザーアイコン画像URLがある場合は画像を表示できるようにします。

UserAvatarimageUrl を追加します。

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;
}

画像URLがあるかどうかを判定します。

final hasImage = imageUrl.trim().isNotEmpty;

画像がある場合は Image.network、ない場合は文字を表示します。

child: hasImage
    ? Image.network(
        imageUrl,
        fit: BoxFit.cover,
        width: size,
        height: size,
        errorBuilder: (context, error, stackTrace) {
          return Center(
            child: Text(initials),
          );
        },
      )
    : Text(initials),

画像が読み込めない場合も、errorBuilder で文字表示に戻します。

完成コード:プロフィールカードUI

ここまでの内容をまとめた完成コードです。

import 'package:flutter/material.dart';

void main() {
  runApp(const ProfileCardPracticeApp());
}

class ProfileCardPracticeApp extends StatelessWidget {
  const ProfileCardPracticeApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Profile Card Practice',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        brightness: Brightness.dark,
        scaffoldBackgroundColor: DiscordColors.background,
      ),
      home: const ProfileCardPracticePage(),
    );
  }
}

class DiscordColors {
  static const Color background = Color(0xFF313338);
  static const Color panel = Color(0xFF232428);

  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 lg = 12;
}

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;
}

const currentUser = UserProfile(
  name: 'flutter_dev',
  handle: '@flutter_dev',
  status: 'Flutter UIを制作中',
  about: 'Discord風UIをFlutterで再現しています。プロフィールは一時保存のみです。',
  avatarColor: DiscordColors.blurple,
  avatarUrl: '',
  onlineStatus: OnlineStatus.online,
);

class ProfileCardPracticePage extends StatelessWidget {
  const ProfileCardPracticePage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      backgroundColor: DiscordColors.background,
      body: SafeArea(
        child: Center(
          child: ProfileCard(user: currentUser),
        ),
      ),
    );
  }
}

class ProfileCard extends StatelessWidget {
  const ProfileCard({
    super.key,
    required this.user,
  });

  final UserProfile user;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 300,
      margin: const EdgeInsets.all(DiscordSpacing.lg),
      decoration: BoxDecoration(
        color: DiscordColors.panel,
        borderRadius: BorderRadius.circular(DiscordRadius.lg),
      ),
      clipBehavior: Clip.antiAlias,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            height: 118,
            child: Stack(
              clipBehavior: Clip.none,
              children: [
                Container(
                  height: 64,
                  color: user.avatarColor,
                ),
                Positioned(
                  left: 14,
                  top: 34,
                  child: UserAvatar(
                    name: user.name,
                    color: user.avatarColor,
                    imageUrl: user.avatarUrl,
                    status: user.onlineStatus,
                    size: 72,
                    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: 22,
                    fontWeight: FontWeight.bold,
                    height: 1.1,
                  ),
                ),
                const SizedBox(height: DiscordSpacing.sm),
                Text(
                  user.handle,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    color: DiscordColors.textMuted,
                    fontSize: 14,
                  ),
                ),
                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: 14,
                    height: 1.55,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

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();
}

実行して確認すること

このコードを実行したら、次の点を確認してください。

カード上部にユーザー色のバナーがある
バナーに丸いユーザーアイコンが重なっている
アイコン右下にオンライン状態の丸が表示される
ユーザー名とハンドル名が表示される
ABOUT MEと自己紹介文が表示される

この段階で、Discord風のプロフィールカードらしい見た目ができました。

avatarUrlに画像を入れてみる

currentUseravatarUrl に画像URLを入れてみます。

const currentUser = UserProfile(
  name: 'flutter_dev',
  handle: '@flutter_dev',
  status: 'Flutter UIを制作中',
  about: 'Discord風UIをFlutterで再現しています。プロフィールは一時保存のみです。',
  avatarColor: DiscordColors.blurple,
  avatarUrl:
      'https://images.unsplash.com/photo-1531746790731-6c087fecd65a?w=120&h=120&fit=crop',
  onlineStatus: OnlineStatus.online,
);

画像が読み込まれると、アイコン部分が画像表示になります。

もし読み込めなかった場合は、buildInitials(name) で作った文字表示に戻ります。

画像URLあり
↓
画像を表示

画像URLなし・読み込み失敗
↓
文字アイコンを表示

MemberPanelにProfileCardを入れる

次に、プロフィールカードを右側のメンバー欄に入れる形を考えます。

MemberPanel は、右側の領域全体です。

MemberPanel
├─ ONLINE
├─ メンバー一覧
├─ IDLE
├─ メンバー一覧
└─ ProfileCard

まずは簡易的な MemberPanel を作ります。

class MemberPanel extends StatelessWidget {
  const MemberPanel({
    super.key,
    required this.currentUser,
  });

  final UserProfile currentUser;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 280,
      color: DiscordColors.panel,
      child: ListView(
        padding: const EdgeInsets.all(DiscordSpacing.lg),
        children: [
          const Text(
            'ONLINE',
            style: TextStyle(
              color: DiscordColors.textMuted,
              fontSize: 12,
              fontWeight: FontWeight.bold,
              letterSpacing: 0.4,
            ),
          ),
          const SizedBox(height: DiscordSpacing.md),
          Row(
            children: [
              UserAvatar(
                name: currentUser.name,
                color: currentUser.avatarColor,
                imageUrl: currentUser.avatarUrl,
                status: currentUser.onlineStatus,
                size: 34,
              ),
              const SizedBox(width: DiscordSpacing.md),
              Expanded(
                child: Text(
                  currentUser.name,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    color: DiscordColors.textSecondary,
                    fontSize: 14,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: DiscordSpacing.lg),
          ProfileCard(user: currentUser),
        ],
      ),
    );
  }
}

これを使うと、右側のメンバー欄の中に、ユーザー一覧とプロフィールカードを表示できます。

ProfileCardを使い回せる理由

ProfileCard は、UserProfile を受け取るように作りました。

ProfileCard(user: currentUser)

そのため、別のユーザーを渡せば、同じ見た目で別のプロフィールを表示できます。

ProfileCard(user: anotherUser)

これがWidget化のメリットです。

見た目の構造
↓
ProfileCardとして共通化

表示する中身
↓
UserProfileで差し替える

アプリ開発では、この「形は同じで中身だけ違う」部品をたくさん作ります。

手を動かす練習1:ユーザー名を変更する

currentUsername を変更してみましょう。

name: 'design_master',

プロフィールカードのユーザー名と、アイコン内の文字が変わります。

flutter_dev
↓
design_master

データを変えるとUIも変わることを確認してください。

手を動かす練習2:オンライン状態を変える

onlineStatus を変更してみましょう。

onlineStatus: OnlineStatus.idle,

アイコン右下の状態表示が緑から黄色に変わります。

さらに、次のようにも試せます。

onlineStatus: OnlineStatus.doNotDisturb,

赤色になります。

このように、enum の値を変えるだけで、表示色を切り替えられます。

手を動かす練習3:自己紹介文を長くしてみる

about を長くしてみましょう。

about: 'FlutterでDiscord風UIを作りながら、Column、Row、Stack、StatefulWidget、データ設計を学んでいます。小さな部品を積み重ねると、複雑な画面も作れるようになります。',

自己紹介文が複数行で表示されます。

ただし、長くなりすぎる場合は、カード全体を ListView の中に入れるなど、スクロールできる設計にすると安全です。

手を動かす練習4:バナー色を変える

avatarColor を変えてみましょう。

avatarColor: DiscordColors.green,

カード上部のバナー色とアイコン色が変わります。

この教材では、avatarColor をアイコン色だけでなく、カード上部のバナー色にも使っています。

avatarColor
↓
アイコン背景
↓
カード上部バナー

手を動かす練習5:画像URLを壊してみる

avatarUrl に存在しないURLを入れてみましょう。

avatarUrl: 'https://example.com/not-found-image.jpg',

画像が読み込めない場合でも、アイコンにはユーザー名の頭文字が表示されます。

これは、errorBuilder があるためです。

画像読み込み失敗
↓
errorBuilder
↓
文字アイコンを表示

よくあるつまずき1:アイコンがカードからはみ出して見切れる

アイコンをバナーに重ねる場合、Stack の高さや clipBehavior に注意が必要です。

今回のコードでは、上部に余裕を持たせるために、次のようにしています。

SizedBox(
  height: 118,
  child: Stack(...),
)

バナー自体は高さ64ですが、アイコンが下にはみ出すため、全体の高さを118にしています。

バナー高さ64
↓
アイコンが下にはみ出す
↓
上部エリア全体は118にする

よくあるつまずき2:StackとPositionedの関係が分からない

Positioned は、Stack の中で使います。

Stack(
  children: [
    Positioned(
      left: 14,
      top: 34,
      child: UserAvatar(...),
    ),
  ],
)

Stack の外で Positioned を使うことはできません。

Stack
↓
重ねるための土台

Positioned
↓
Stackの中で位置を指定する

よくあるつまずき3:画像が丸くならない

画像を丸く表示するには、親の Container に次の設定を入れています。

clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
  shape: BoxShape.circle,
)

画像そのものは四角でも、親Widgetで丸く切り取ることで、丸いアイコンに見えます。

四角い画像
↓
丸いContainerで切り取る
↓
丸いアイコンになる

よくあるつまずき4:Color? が分からない

Color? borderColor は、渡してもよいし、渡さなくてもよい値です。

final Color? borderColor;

プロフィールカード上では、次のように枠線色を渡します。

borderColor: DiscordColors.panel

通常の小さなメンバーアイコンでは、省略できます。

UserAvatar(
  name: user.name,
  color: user.avatarColor,
  imageUrl: user.avatarUrl,
  status: user.onlineStatus,
  size: 34,
)

このように、必要なときだけ使える値にしたい場合、? をつけます。

よくあるつまずき5:文字が横にはみ出る

ユーザー名やハンドル名が長い場合、横にはみ出ることがあります。

そのため、次の指定を入れています。

overflow: TextOverflow.ellipsis,

これは、入りきらない文字を ... のように省略する指定です。

very_long_user_name_example
↓
very_long_user...

横幅が限られるカードUIでは、とても大切です。

この節の確認問題

確認問題1

ProfileCard は何を担当するWidgetですか。

答え

Discord風のユーザープロフィールカード全体を担当するWidgetです。

確認問題2

UserAvatar は何を担当するWidgetですか。

答え

丸いユーザーアイコンとオンライン状態の表示を担当するWidgetです。

確認問題3

プロフィールカードでアイコンをバナーに重ねるために使ったWidgetは何ですか。

答え

StackPositioned です。

確認問題4

画像URLがない場合、アイコンには何を表示しますか。

答え

ユーザー名から作った頭文字を表示します。

確認問題5

オンライン状態に応じて色を返す関数は何でしたか。

答え

statusColor です。

確認問題6

Color?? は何を意味しますか。

答え

値が入る場合もあり、null の場合もあるという意味です。

確認問題7

カードの角丸から中身がはみ出さないようにするために使った設定は何ですか。

答え

clipBehavior: Clip.antiAlias です。

この節のまとめ

この節では、Discord風アプリの右側メンバー欄やプロフィール表示に使うプロフィールカードUIを作りました。

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

ProfileCard
UserAvatar
MemberPanel

プロフィールカードは、UserProfile データを受け取って表示します。

UserProfile
↓
ProfileCard
↓
ユーザー名・ハンドル名・自己紹介・アイコンを表示

また、丸いアイコンとオンライン状態の表示には、StackPositioned を使いました。

UserAvatar
└─ Stack
   ├─ 丸いアイコン
   └─ 右下のオンライン状態

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

プロフィールカードのような複雑に見えるUIも、Container・Column・Stack・Textに分解すれば作れる。

次の節では、今回作ったプロフィールカードを編集できるようにするために、Dialogを使ったプロフィール編集画面を作っていきます。

教材トップへ戻る