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

【データ設計入門】Discord風アプリに必要なclassを考える

この節で学ぶこと

前回の 5-6 では、Discord風のチャンネル一覧UIを作りました。

ChannelSidebar
├─ ChannelCategory
└─ ChannelTile

そこでは、チャンネル1つ分の情報を DiscordChannel classとして整理しました。

今回の 5-7 では、Discord風アプリ全体に必要なデータを整理します。

これまでの教材では、少しずつ次のような情報を扱ってきました。

サーバー情報
チャンネル情報
ユーザー情報
メッセージ情報

これらをバラバラの文字列として持つのではなく、classとして整理していきます。

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

画面に表示する情報は、先にclassとして整理しておくと、UIを作るときに迷いにくくなる。

なぜデータ設計が必要なのか

アプリの画面には、たくさんの情報が表示されます。

Discord風アプリであれば、次のような情報があります。

サーバー名
サーバーアイコン画像
チャンネル名
チャンネルの種類
未読状態
ユーザー名
ユーザーアイコン
オンライン状態
メッセージ本文
投稿時刻

これらをすべて直接Widgetの中に書くと、コードがすぐに読みにくくなります。

たとえば、次のように書くこともできます。

Text('flutter_dev')
Text('@flutter_dev')
Text('Flutter UIを制作中')
Text('Discord風UIをFlutterで再現しています。')

これでも表示はできます。

しかし、ユーザーが増えたり、プロフィール編集機能を作ったり、メッセージ投稿機能を作ったりすると、どの情報がどこにあるのか分からなくなります。

そこで、データをclassとして整理します。

画面に表示する情報
↓
classにまとめる
↓
Widgetに渡す
↓
画面に表示する

今回整理するデータ

Discord風アプリでは、主に次の4つのclassを考えます。

class名表すもの使う場所
DiscordServerサーバー1つ分サーバー一覧
DiscordChannelチャンネル1つ分チャンネル一覧
ChatMessageメッセージ1件分チャット画面
UserProfileユーザー1人分メンバー一覧、プロフィールカード

この4つを整理できると、Discord風アプリのデータ構造が見えてきます。

Discord風アプリ
├─ サーバー
├─ チャンネル
├─ メッセージ
└─ ユーザー

classとは何か

まず、classの意味を復習します。

classは、データの設計図です。

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

class = 同じ形のデータを作るための設計図

たとえば、ユーザー情報を扱いたい場合を考えます。

ユーザー1人には、次のような情報があります。

名前
ハンドル
ステータス
自己紹介
アイコン色
オンライン状態

これを毎回バラバラに書くのではなく、UserProfile というclassにまとめます。

class UserProfile {
  const UserProfile({
    required this.name,
    required this.handle,
    required this.status,
    required this.about,
    required this.avatarColor,
    required this.onlineStatus,
  });

  final String name;
  final String handle;
  final String status;
  final String about;
  final Color avatarColor;
  final OnlineStatus onlineStatus;
}

これで、ユーザー1人分の形が決まります。

propertyとは何か

classの中にある値を、propertyと呼びます。

final String name;
final String handle;
final String status;

これらがpropertyです。

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

property = classが持つ情報

UserProfile のpropertyは、次のようになります。

UserProfile
├─ name
├─ handle
├─ status
├─ about
├─ avatarColor
└─ onlineStatus

つまり、UserProfile は「ユーザー1人分の情報をまとめる箱」です。

constructorとは何か

classから実際のデータを作るときに使う入口がconstructorです。

const UserProfile({
  required this.name,
  required this.handle,
  required this.status,
  required this.about,
  required this.avatarColor,
  required this.onlineStatus,
});

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

constructor = classからデータを作るときに値を受け取る入口

実際にユーザー情報を作るときは、次のように書きます。

const UserProfile(
  name: 'flutter_dev',
  handle: '@flutter_dev',
  status: 'Flutter UIを制作中',
  about: 'Discord風UIをFlutterで再現しています。',
  avatarColor: DiscordColors.blurple,
  onlineStatus: OnlineStatus.online,
)

これで、UserProfile という設計図から、1人分のユーザーデータが作られます。

まずはDiscordServerを整理する

最初に、左端のサーバー一覧で使う DiscordServer を整理します。

サーバー1つ分に必要な情報は、次の通りです。

property内容
nameStringサーバー名
labelString画像がない場合に表示する文字
imageUrlStringサーバーアイコン画像URL
colorColor画像がない場合の背景色

コードにすると、次のようになります。

class DiscordServer {
  const DiscordServer({
    required this.name,
    required this.label,
    required this.imageUrl,
    required this.color,
  });

  final String name;
  final String label;
  final String imageUrl;
  final Color color;
}

このclassは、サーバーアイコンを表示するときに使います。

DiscordServer
↓
ServerIcon
↓
サーバーアイコンとして表示

DiscordServerのデータを作る

実際のサーバーデータは、次のように作れます。

const servers = [
  DiscordServer(
    name: 'Flutter Lab',
    label: 'FL',
    imageUrl:
        'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=200&h=200&fit=crop',
    color: DiscordColors.blurple,
  ),
  DiscordServer(
    name: 'Design Hub',
    label: 'UI',
    imageUrl:
        'https://images.unsplash.com/photo-1561070791-2526d30994b5?w=200&h=200&fit=crop',
    color: Color(0xFFEB459E),
  ),
  DiscordServer(
    name: 'AI Studio',
    label: 'AI',
    imageUrl:
        'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=200&h=200&fit=crop',
    color: DiscordColors.green,
  ),
];

ここで大切なのは、UI側に直接サーバー名や画像URLを書かないことです。

データ
↓
servers

表示
↓
ServerRail / ServerIcon

このように分けると、後からサーバーを追加しやすくなります。

次にDiscordChannelを整理する

次に、チャンネル一覧で使う DiscordChannel を整理します。

チャンネル1つ分に必要な情報は、次の通りです。

property内容
nameStringチャンネル名
typeChannelTypeテキストかボイスか
unreadbool未読状態かどうか

コードにすると、次のようになります。

enum ChannelType {
  text,
  voice,
}

class DiscordChannel {
  const DiscordChannel({
    required this.name,
    required this.type,
    this.unread = false,
  });

  final String name;
  final ChannelType type;
  final bool unread;
}

boolとは何か

ここで、bool が出てきます。

final bool unread;

bool は、true または false のどちらかを持つ型です。

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

bool = はい / いいえ を表す型

今回の unread は、未読かどうかを表します。

unread: true
↓
未読

unread: false
↓
未読ではない

未読なら、チャンネルの右側に小さな丸を表示できます。

if (channel.unread)
  Container(
    width: 8,
    height: 8,
    decoration: const BoxDecoration(
      color: DiscordColors.textPrimary,
      shape: BoxShape.circle,
    ),
  ),

このように、データによって見た目を変えられます。

DiscordChannelのデータを作る

チャンネルデータは、次のように作れます。

const channels = [
  DiscordChannel(name: 'general', type: ChannelType.text),
  DiscordChannel(name: 'flutter-ui', type: ChannelType.text),
  DiscordChannel(name: 'design-review', type: ChannelType.text),
  DiscordChannel(
    name: 'announcements',
    type: ChannelType.text,
    unread: true,
  ),
  DiscordChannel(name: 'voice-lounge', type: ChannelType.voice),
  DiscordChannel(name: 'pair-programming', type: ChannelType.voice),
];

このデータをもとに、ChannelSidebar でチャンネル一覧を表示します。

channels
↓
ChannelSidebar
↓
ChannelTile

テキストチャンネルとボイスチャンネルを分けたい場合は、type を見て判断できます。

final textChannels = channels
    .where((channel) => channel.type == ChannelType.text)
    .toList();

次にUserProfileを整理する

次に、ユーザー情報を表す UserProfile を作ります。

ユーザー1人分に必要な情報は、次の通りです。

property内容
nameString表示名
handleString@flutter_dev のようなID
statusString現在のステータスメッセージ
aboutString自己紹介
avatarColorColorアイコンの背景色
avatarUrlStringアイコン画像URL
onlineStatusOnlineStatusオンライン状態

コードにすると、次のようになります。

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

OnlineStatusとは何か

OnlineStatus は、ユーザーのオンライン状態を表します。

enum OnlineStatus {
  online,
  idle,
  doNotDisturb,
  offline,
}

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

意味表示色の例
onlineオンライン
idle離席中黄色
doNotDisturb取り込み中
offlineオフライングレー

このように、状態を enum にしておくと、後から表示を切り替えやすくなります。

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

switchとは何か

ここで switch という書き方が出てきました。

switch (status) {
  case OnlineStatus.online:
    return DiscordColors.green;
  case OnlineStatus.idle:
    return DiscordColors.yellow;
}

switch は、値によって処理を分けるための書き方です。

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

switch = 値の種類ごとに処理を分ける書き方

今回の場合は、オンライン状態によって色を変えています。

online
↓
緑

idle
↓
黄色

doNotDisturb
↓
赤

offline
↓
グレー

UserProfileのデータを作る

実際のユーザー情報は、次のように作れます。

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

画像URLを空にしておけば、アイコンには文字や背景色を表示できます。

avatarUrl: ''
↓
画像なし

avatarColor: DiscordColors.blurple
↓
青紫のアイコン背景

画像URLを入れれば、Image.network で表示できます。

avatarUrl:
    'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=120&h=120&fit=crop',

次にChatMessageを整理する

チャット画面では、メッセージ1件分の情報が必要です。

メッセージ1件には、次のような情報があります。

property内容
userNameString投稿者名
handleString投稿者ID
avatarColorColor投稿者アイコン色
avatarUrlString投稿者アイコン画像URL
bodyStringメッセージ本文
timestampDateTime投稿時刻
statusOnlineStatus投稿者の状態
isSystemboolシステムメッセージかどうか

コードにすると、次のようになります。

class ChatMessage {
  const ChatMessage({
    required this.userName,
    required this.handle,
    required this.avatarColor,
    required this.avatarUrl,
    required this.body,
    required this.timestamp,
    required this.status,
    this.isSystem = false,
  });

  final String userName;
  final String handle;
  final Color avatarColor;
  final String avatarUrl;
  final String body;
  final DateTime timestamp;
  final OnlineStatus status;
  final bool isSystem;
}

DateTimeとは何か

DateTime は、日付や時刻を表す型です。

final DateTime timestamp;

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

DateTime = 日付や時刻を表す型

現在時刻を使いたい場合は、次のように書きます。

DateTime.now()

たとえば、今投稿したメッセージなら、次のようにできます。

ChatMessage(
  userName: 'flutter_dev',
  handle: '@flutter_dev',
  avatarColor: DiscordColors.blurple,
  avatarUrl: '',
  body: 'こんにちは!',
  timestamp: DateTime.now(),
  status: OnlineStatus.online,
)

過去のメッセージにしたい場合は、次のようにできます。

timestamp: DateTime.now().subtract(const Duration(minutes: 12)),

これは、「今から12分前」という意味です。

ChatMessageのデータを作る

メッセージ一覧は、List<ChatMessage> として持ちます。

final messages = [
  ChatMessage(
    userName: 'mika_design',
    handle: '@mika',
    avatarColor: const Color(0xFFEB459E),
    avatarUrl:
        'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=120&h=120&fit=crop',
    body: 'Discord風UIは、画面を部品に分けると作りやすいです。',
    timestamp: DateTime.now().subtract(const Duration(minutes: 24)),
    status: OnlineStatus.online,
  ),
  ChatMessage(
    userName: 'code_senpai',
    handle: '@senpai',
    avatarColor: DiscordColors.green,
    avatarUrl:
        'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=120&h=120&fit=crop',
    body: 'データをclassで整理すると、Widgetに渡しやすくなります。',
    timestamp: DateTime.now().subtract(const Duration(minutes: 12)),
    status: OnlineStatus.idle,
  ),
];

このデータをもとに、チャット画面にメッセージを表示します。

messages
↓
ListView.builder
↓
ChatMessageTile

データclassをまとめて確認する

ここまでで、4つのclassが出てきました。

DiscordServer
DiscordChannel
UserProfile
ChatMessage

それぞれの役割を整理すると、次の通りです。

class名何を表すか主なWidget
DiscordServerサーバー1つ分ServerIcon
DiscordChannelチャンネル1つ分ChannelTile
UserProfileユーザー1人分UserAvatar, ProfileCard, MemberTile
ChatMessageメッセージ1件分ChatMessageTile

このように、データclassとWidgetは対応しています。

データclass
↓
画面に表示するWidget

まずはデータだけをprintで確認する

ここでは、いきなりUIに表示せず、まずDartのデータとして扱えるか確認します。

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

import 'package:flutter/material.dart';

void main() {
  final server = DiscordServer(
    name: 'Flutter Lab',
    label: 'FL',
    imageUrl: '',
    color: DiscordColors.blurple,
  );

  final channel = DiscordChannel(
    name: 'general',
    type: ChannelType.text,
  );

  final user = UserProfile(
    name: 'flutter_dev',
    handle: '@flutter_dev',
    status: 'Flutter UIを制作中',
    about: 'Discord風UIをFlutterで再現しています。',
    avatarColor: DiscordColors.blurple,
    avatarUrl: '',
    onlineStatus: OnlineStatus.online,
  );

  final message = ChatMessage(
    userName: user.name,
    handle: user.handle,
    avatarColor: user.avatarColor,
    avatarUrl: user.avatarUrl,
    body: 'データ設計を学んでいます。',
    timestamp: DateTime.now(),
    status: user.onlineStatus,
  );

  print(server.name);
  print(channel.name);
  print(user.name);
  print(message.body);
}

class DiscordColors {
  static const Color blurple = Color(0xFF5865F2);
  static const Color green = Color(0xFF23A559);
  static const Color yellow = Color(0xFFF0B232);
  static const Color red = Color(0xFFF23F42);
  static const Color textMuted = Color(0xFF949BA4);
}

class DiscordServer {
  const DiscordServer({
    required this.name,
    required this.label,
    required this.imageUrl,
    required this.color,
  });

  final String name;
  final String label;
  final String imageUrl;
  final Color color;
}

enum ChannelType {
  text,
  voice,
}

class DiscordChannel {
  const DiscordChannel({
    required this.name,
    required this.type,
    this.unread = false,
  });

  final String name;
  final ChannelType type;
  final bool unread;
}

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

class ChatMessage {
  const ChatMessage({
    required this.userName,
    required this.handle,
    required this.avatarColor,
    required this.avatarUrl,
    required this.body,
    required this.timestamp,
    required this.status,
    this.isSystem = false,
  });

  final String userName;
  final String handle;
  final Color avatarColor;
  final String avatarUrl;
  final String body;
  final DateTime timestamp;
  final OnlineStatus status;
  final bool isSystem;
}

実行して確認すること

実行すると、コンソールに次のような内容が表示されます。

Flutter Lab
general
flutter_dev
データ設計を学んでいます。

ここでは、まだアプリ画面は表示していません。

しかし、データclassを使って、サーバー、チャンネル、ユーザー、メッセージを作れるようになりました。

データを作れる
↓
次にWidgetへ渡せる
↓
画面に表示できる

この順番が大切です。

UIに表示する最小アプリを作る

次に、作ったデータをUIに表示してみます。

今回は、サーバー名、チャンネル名、ユーザー名、メッセージ本文を1画面に表示します。

import 'package:flutter/material.dart';

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

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

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

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

enum ChannelType {
  text,
  voice,
}

enum OnlineStatus {
  online,
  idle,
  doNotDisturb,
  offline,
}

class DiscordServer {
  const DiscordServer({
    required this.name,
    required this.label,
    required this.imageUrl,
    required this.color,
  });

  final String name;
  final String label;
  final String imageUrl;
  final Color color;
}

class DiscordChannel {
  const DiscordChannel({
    required this.name,
    required this.type,
    this.unread = false,
  });

  final String name;
  final ChannelType type;
  final bool unread;
}

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

class ChatMessage {
  const ChatMessage({
    required this.userName,
    required this.handle,
    required this.avatarColor,
    required this.avatarUrl,
    required this.body,
    required this.timestamp,
    required this.status,
    this.isSystem = false,
  });

  final String userName;
  final String handle;
  final Color avatarColor;
  final String avatarUrl;
  final String body;
  final DateTime timestamp;
  final OnlineStatus status;
  final bool isSystem;
}

final server = DiscordServer(
  name: 'Flutter Lab',
  label: 'FL',
  imageUrl: '',
  color: DiscordColors.blurple,
);

final channel = DiscordChannel(
  name: 'general',
  type: ChannelType.text,
);

final currentUser = UserProfile(
  name: 'flutter_dev',
  handle: '@flutter_dev',
  status: 'Flutter UIを制作中',
  about: 'Discord風UIをFlutterで再現しています。',
  avatarColor: DiscordColors.blurple,
  avatarUrl: '',
  onlineStatus: OnlineStatus.online,
);

final message = ChatMessage(
  userName: 'mika_design',
  handle: '@mika',
  avatarColor: Color(0xFFEB459E),
  avatarUrl: '',
  body: 'データをclassで整理すると、画面づくりが分かりやすくなります。',
  timestamp: DateTime.now(),
  status: OnlineStatus.online,
);

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: DiscordColors.background,
      body: SafeArea(
        child: Center(
          child: Container(
            width: 420,
            padding: const EdgeInsets.all(20),
            decoration: BoxDecoration(
              color: DiscordColors.panel,
              borderRadius: BorderRadius.circular(12),
            ),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  server.name,
                  style: const TextStyle(
                    color: DiscordColors.textPrimary,
                    fontSize: 24,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(height: 8),
                Text(
                  '#${channel.name}',
                  style: const TextStyle(
                    color: DiscordColors.textMuted,
                    fontSize: 16,
                  ),
                ),
                const SizedBox(height: 24),
                Row(
                  children: [
                    CircleAvatar(
                      backgroundColor: currentUser.avatarColor,
                      child: Text(currentUser.label),
                    ),
                    const SizedBox(width: 12),
                    Expanded(
                      child: Text(
                        currentUser.name,
                        overflow: TextOverflow.ellipsis,
                        style: const TextStyle(
                          color: DiscordColors.textPrimary,
                          fontSize: 16,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 24),
                Text(
                  message.userName,
                  style: const TextStyle(
                    color: DiscordColors.textPrimary,
                    fontSize: 15,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(height: 6),
                Text(
                  message.body,
                  style: const TextStyle(
                    color: DiscordColors.textSecondary,
                    fontSize: 15,
                    height: 1.5,
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

extension UserProfileLabel on UserProfile {
  String get label {
    if (name.trim().isEmpty) {
      return '?';
    }

    if (name.length >= 2) {
      return name.substring(0, 2).toUpperCase();
    }

    return name[0].toUpperCase();
  }
}

extensionとは何か

このコードでは、最後に extension が出てきました。

extension UserProfileLabel on UserProfile {
  String get label {
    ...
  }
}

extension は、すでにあるclassに便利な機能を追加する書き方です。

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

extension = 既存のclassに後から便利機能を追加する書き方

今回は、UserProfile からアイコン用の短いラベルを作っています。

currentUser.label

たとえば、flutter_dev なら、先頭2文字を使って FL のように表示できます。

ただし、初心者のうちは extension を無理に使わなくても大丈夫です。

完成アプリでは、専用の関数でラベルを作る形でも問題ありません。

データとWidgetの関係を整理する

今回の最小アプリでは、データをWidgetに渡して表示しました。

server.name
↓
Textで表示

channel.name
↓
Textで表示

currentUser.avatarColor
↓
CircleAvatarで表示

message.body
↓
Textで表示

このように、classのpropertyをWidgetの中で使うことで、画面が作られます。

Text(server.name)
Text('#${channel.name}')
Text(message.body)

Flutterアプリでは、この流れが非常に多いです。

データを作る
↓
Widgetに渡す
↓
propertyを取り出す
↓
画面に表示する

完成アプリでのデータの使われ方

この章の完成アプリでは、今回作ったclassが次のように使われます。

DiscordServer
↓
ServerRail / ServerIcon

DiscordChannel
↓
ChannelSidebar / ChannelTile / ChatTopBar

ChatMessage
↓
ChatArea / ChatMessageTile

UserProfile
↓
MemberPanel / MemberTile / ProfileCard / UserAvatar

つまり、画面に出てくるほとんどの情報は、どこかのclassから取り出されます。

Widgetに直接文字を書き続けるのではなく、データを渡して表示する形になります。

なぜUIより先にデータを考えるのか

アプリを作るときは、見た目から作ることもできます。

しかし、少し複雑なアプリでは、先にデータを考えると作りやすくなります。

たとえば、チャットアプリを作る場合、次のように考えます。

画面に何を表示するか?
↓
メッセージ
ユーザー
チャンネル

それぞれ何の情報を持つか?
↓
メッセージ本文
投稿者名
投稿時刻
オンライン状態

それをclassにすると?
↓
ChatMessage
UserProfile
DiscordChannel

この順番で考えると、Widgetの設計もしやすくなります。

データが決まる
↓
Widgetの引数が決まる
↓
画面が作りやすくなる

手を動かす練習1:サーバーデータを追加する

servers を作っている場合は、次のサーバーを追加してみましょう。

DiscordServer(
  name: 'Study Room',
  label: 'ST',
  imageUrl: '',
  color: DiscordColors.yellow,
),

サーバー一覧UIに接続している場合は、アイコンが1つ増えます。

まだUIに接続していない場合でも、データとして追加できることを確認してください。

手を動かす練習2:チャンネルを追加する

channels に、次のチャンネルを追加してみましょう。

DiscordChannel(
  name: 'questions',
  type: ChannelType.text,
  unread: true,
),

このデータを ChannelSidebar に渡すと、チャンネル一覧に表示できます。

unread: true にしているため、未読マークも表示できます。

手を動かす練習3:ユーザーの状態を変える

currentUseronlineStatus を変えてみましょう。

onlineStatus: OnlineStatus.idle,

もしステータス色を表示するUIとつながっていれば、緑から黄色に変わります。

データを変えるだけでUIが変わるのが、class設計の大きなメリットです。

手を動かす練習4:メッセージを追加する

messages のListを作っている場合は、メッセージを1件追加してみましょう。

ChatMessage(
  userName: 'flutter_dev',
  handle: '@flutter_dev',
  avatarColor: DiscordColors.blurple,
  avatarUrl: '',
  body: 'classで整理すると、チャット画面にも表示しやすいです。',
  timestamp: DateTime.now(),
  status: OnlineStatus.online,
),

あとで ListView.builder と組み合わせると、メッセージ一覧として表示できます。

よくあるつまずき1:classが多くて混乱する

今回、複数のclassが出てきました。

DiscordServer
DiscordChannel
UserProfile
ChatMessage

最初は多く感じるかもしれません。

しかし、それぞれの役割ははっきりしています。

サーバーならDiscordServer
チャンネルならDiscordChannel
ユーザーならUserProfile
メッセージならChatMessage

画面に表示するものごとにclassを分けているだけです。

よくあるつまずき2:Stringで全部持てばよいのではと思う

たしかに、最初はすべて文字列で持つこともできます。

final userName = 'flutter_dev';
final message = 'こんにちは';

しかし、アプリが大きくなると、文字列だけでは足りません。

たとえば、ユーザーには名前だけでなく、アイコン、ステータス、自己紹介もあります。

ユーザー
├─ 名前
├─ ハンドル
├─ アイコン
├─ ステータス
└─ 自己紹介

このように複数の情報をまとめたいときに、classが便利です。

よくあるつまずき3:enumとStringの違いが分からない

チャンネルの種類を文字列で持つと、打ち間違いが起きやすいです。

type: 'voice'
type: 'voise'

voise と書いても、文字列としては成立してしまいます。

一方、enumなら、用意された選択肢から選びます。

type: ChannelType.voice

間違った名前を書くと、コードを書く段階で気づきやすくなります。

enum
↓
決まった種類を安全に扱える

よくあるつまずき4:DateTimeが難しい

DateTime は、最初は少し難しく感じるかもしれません。

まずは、次の2つだけ覚えれば大丈夫です。

DateTime.now()

これは、現在時刻です。

DateTime.now().subtract(const Duration(minutes: 12))

これは、現在時刻から12分前です。

チャットアプリでは、投稿時刻を表示するために使います。

よくあるつまずき5:データとUIが混ざる

初心者のうちは、Widgetの中に直接データを書きがちです。

Text('flutter_dev')
Text('Discord風UIを作っています')

もちろん最初の練習ではそれでも構いません。

しかし、完成アプリに近づけるには、データとUIを分けます。

データ
↓
UserProfile

UI
↓
ProfileCard

この分離ができると、あとから編集機能や一覧表示を作りやすくなります。

この節の確認問題

確認問題1

DiscordServer は何を表すclassですか。

答え

サーバー1つ分の情報を表すclassです。

確認問題2

DiscordChannel は何を表すclassですか。

答え

チャンネル1つ分の情報を表すclassです。

確認問題3

UserProfile は何を表すclassですか。

答え

ユーザー1人分のプロフィール情報を表すclassです。

確認問題4

ChatMessage は何を表すclassですか。

答え

チャットメッセージ1件分の情報を表すclassです。

確認問題5

enum は何のために使いますか。

答え

決まった種類や状態を安全に扱うために使います。

確認問題6

bool はどのような値を持つ型ですか。

答え

true または false のどちらかを持つ型です。

確認問題7

データをclassにまとめるメリットは何ですか。

答え

画面に表示する情報を整理でき、Widgetに渡しやすくなり、後から機能追加もしやすくなります。

この節のまとめ

この節では、Discord風アプリに必要なデータをclassとして整理しました。

今回扱った主なclassは、次の4つです。

DiscordServer
DiscordChannel
UserProfile
ChatMessage

それぞれ、画面の部品と対応しています。

DiscordServer
↓
サーバー一覧

DiscordChannel
↓
チャンネル一覧

UserProfile
↓
メンバー一覧・プロフィール

ChatMessage
↓
チャット画面

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

画面に表示する情報をclassとして整理すると、Widgetはそのデータを受け取って表示するだけでよくなる。

次の節では、このデータ設計をもとに、中央のチャット画面UIを作っていきます。

教材トップへ戻る