
【チャット画面UI】メッセージ一覧と投稿フォームを作る
この節で学ぶこと
前回の 5-7 では、Discord風アプリに必要なデータをclassとして整理しました。
DiscordServer
DiscordChannel
UserProfile
ChatMessage
今回の 5-8 では、その中でも ChatMessage を使って、中央のチャット画面UIを作ります。
Discord風アプリの中心になるのは、やはりチャット画面です。
上部:チャンネル情報
中央:メッセージ一覧
下部:メッセージ入力欄
この節では、まず次の2つを作ります。
メッセージ一覧
投稿フォーム
ただし、この節ではまだ「入力した文字を送信して追加する機能」は本格的には作りません。
まずは、チャット画面の見た目を作ります。
この節で一番大切なのは、次の一文です。
チャット画面は、メッセージ一覧をExpandedで広げ、下部に入力フォームを固定して作る。
今回作る画面の構造
Discord風のチャット画面は、ざっくり分けると次のような構造です。
ChatArea
├─ メッセージ一覧
└─ 投稿フォーム
FlutterのWidgetとして考えると、次のようになります。
ChatArea
└─ Column
├─ Expanded
│ └─ ListView
│ ├─ ChannelIntro
│ ├─ ChatMessageTile
│ └─ ChatMessageTile
└─ ChatInputBar
Column を使う理由は、チャット画面の中で「メッセージ一覧」と「入力欄」を縦に並べたいからです。
上:メッセージ一覧
下:入力欄
そして、メッセージ一覧は残りの高さを使って広がってほしいので、Expanded を使います。
ChatAreaとは何か
ChatArea は、Discord風アプリの中央にあるチャット画面全体を担当するWidgetです。
ChatArea
↓
中央のチャット画面全体
ここには、次の要素が入ります。
チャンネルの開始メッセージ
チャットメッセージ一覧
下部の入力欄
今回の完成イメージは、次のようなものです。
Welcome to #general!
This is the start of the #general channel.
mika_design 24m ago
Discord風UIは、画面を部品に分けると作りやすいです。
code_senpai 12m ago
データをclassで整理すると、Widgetに渡しやすくなります。
[ Message #general ]
ChatMessageTileとは何か
ChatMessageTile は、チャットメッセージ1件分を表示するWidgetです。
ChatMessageTile
├─ 投稿者アイコン
├─ 投稿者名
├─ 投稿時刻
└─ メッセージ本文
Discord風のチャットでは、メッセージ1件にいろいろな情報が含まれます。
誰が投稿したか
いつ投稿したか
何を投稿したか
アイコンは何か
これらを ChatMessage classから取り出して表示します。
ChatInputBarとは何か
ChatInputBar は、下部のメッセージ入力欄です。
ChatInputBar
├─ +アイコン
├─ TextField
└─ Sendボタン
Discordの入力欄は、チャット画面の下に固定されています。
Flutterでは、Column の最後に ChatInputBar を置くことで、下部に表示できます。
Column
├─ Expanded(child: メッセージ一覧)
└─ ChatInputBar
この形は、チャットアプリでよく使う基本形です。
まずChatMessageデータを用意する
前回作った ChatMessage classを使って、表示用のメッセージデータを用意します。
まずは、チャットメッセージ1件分のclassを確認します。
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;
}
このclassには、メッセージ1件を表示するための情報がまとまっています。
| property | 内容 |
|---|---|
userName | 投稿者名 |
handle | 投稿者ID |
avatarColor | アイコン背景色 |
avatarUrl | アイコン画像URL |
body | メッセージ本文 |
timestamp | 投稿時刻 |
status | オンライン状態 |
isSystem | システムメッセージかどうか |
メッセージ一覧では、この ChatMessage を複数持ちます。
final messages = [
ChatMessage(...),
ChatMessage(...),
];
まずは最小のチャット画面を作る
最初は、アイコン画像やhover演出までは入れず、メッセージ一覧と入力欄だけを作ります。
次のコードを main.dart に貼り付けてください。
import 'package:flutter/material.dart';
void main() {
runApp(const ChatAreaPracticeApp());
}
class ChatAreaPracticeApp extends StatelessWidget {
const ChatAreaPracticeApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Chat Area Practice',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: DiscordColors.background,
),
home: const ChatAreaPracticePage(),
);
}
}
class DiscordColors {
static const Color background = Color(0xFF313338);
static const Color panel = Color(0xFF232428);
static const Color input = Color(0xFF383A40);
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 xs = 4;
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 md = 8;
}
enum OnlineStatus {
online,
idle,
doNotDisturb,
offline,
}
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 messages = [
ChatMessage(
userName: 'mika_design',
handle: '@mika',
avatarColor: Color(0xFFEB459E),
avatarUrl: '',
body: 'Discord風UIは、画面を部品に分けると作りやすいです。',
timestamp: DateTime.now().subtract(const Duration(minutes: 24)),
status: OnlineStatus.online,
),
ChatMessage(
userName: 'code_senpai',
handle: '@senpai',
avatarColor: DiscordColors.green,
avatarUrl: '',
body: 'データをclassで整理すると、Widgetに渡しやすくなります。',
timestamp: DateTime.now().subtract(const Duration(minutes: 12)),
status: OnlineStatus.idle,
),
];
class ChatAreaPracticePage extends StatelessWidget {
const ChatAreaPracticePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: DiscordColors.background,
body: SafeArea(
child: ChatArea(
channelName: 'general',
messages: messages,
),
),
);
}
}
class ChatArea extends StatelessWidget {
const ChatArea({
super.key,
required this.channelName,
required this.messages,
});
final String channelName;
final List<ChatMessage> messages;
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(
DiscordSpacing.lg,
DiscordSpacing.xl,
DiscordSpacing.lg,
DiscordSpacing.lg,
),
itemCount: messages.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return ChannelIntro(channelName: channelName);
}
final message = messages[index - 1];
return ChatMessageTile(message: message);
},
),
),
ChatInputBar(channelName: channelName),
],
);
}
}
class ChannelIntro extends StatelessWidget {
const ChannelIntro({
super.key,
required this.channelName,
});
final String channelName;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: DiscordSpacing.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CircleAvatar(
radius: 34,
backgroundColor: DiscordColors.panel,
child: Icon(
Icons.tag_rounded,
color: DiscordColors.textPrimary,
size: 38,
),
),
const SizedBox(height: DiscordSpacing.lg),
Text(
'Welcome to #$channelName!',
style: const TextStyle(
color: DiscordColors.textPrimary,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: DiscordSpacing.sm),
Text(
'This is the start of the #$channelName channel.',
style: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 14,
),
),
const SizedBox(height: DiscordSpacing.lg),
Container(height: 1, color: Colors.white10),
],
),
);
}
}
class ChatMessageTile extends StatelessWidget {
const ChatMessageTile({
super.key,
required this.message,
});
final ChatMessage message;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: DiscordSpacing.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 21,
backgroundColor: message.avatarColor,
child: Text(
buildInitials(message.userName),
style: const TextStyle(
color: DiscordColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: DiscordSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: DiscordSpacing.sm,
children: [
Text(
message.userName,
style: const TextStyle(
color: DiscordColors.textPrimary,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
Text(
formatTime(message.timestamp),
style: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 11,
),
),
],
),
const SizedBox(height: DiscordSpacing.xs),
Text(
message.body,
style: const TextStyle(
color: DiscordColors.textSecondary,
fontSize: 15,
height: 1.4,
),
),
],
),
),
],
),
);
}
}
class ChatInputBar extends StatelessWidget {
const ChatInputBar({
super.key,
required this.channelName,
});
final String channelName;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(
DiscordSpacing.lg,
0,
DiscordSpacing.lg,
DiscordSpacing.lg,
),
child: Container(
height: 46,
padding: const EdgeInsets.symmetric(horizontal: DiscordSpacing.md),
decoration: BoxDecoration(
color: DiscordColors.input,
borderRadius: BorderRadius.circular(DiscordRadius.md),
),
child: Row(
children: [
const Icon(
Icons.add_circle_rounded,
color: DiscordColors.textMuted,
size: 24,
),
const SizedBox(width: DiscordSpacing.md),
Expanded(
child: Text(
'Message #$channelName',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 15,
),
),
),
const Icon(
Icons.emoji_emotions_outlined,
color: DiscordColors.textMuted,
size: 22,
),
],
),
),
);
}
}
String buildInitials(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) {
return '?';
}
if (trimmed.length >= 2) {
return trimmed.substring(0, 2).toUpperCase();
}
return trimmed[0].toUpperCase();
}
String formatTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inMinutes < 1) {
return 'Just now';
}
if (difference.inMinutes < 60) {
return '${difference.inMinutes}m ago';
}
if (difference.inHours < 24) {
return '${difference.inHours}h ago';
}
return '${dateTime.month}/${dateTime.day}/${dateTime.year}';
}
実行して確認すること
実行すると、Discord風のチャット画面が表示されます。
確認してほしいポイントは、次の通りです。
上部に Welcome to #general! が表示される
メッセージが2件表示される
投稿者アイコンが丸く表示される
投稿者名と時刻が横に並ぶ
メッセージ本文が表示される
下部に入力欄風のバーが表示される
この段階では、入力欄はまだ本物の入力欄ではありません。
ただし、見た目としては「投稿フォームらしい形」になっています。
ChatAreaの構造を確認する
ChatArea の構造をもう一度見てみます。
class ChatArea extends StatelessWidget {
const ChatArea({
super.key,
required this.channelName,
required this.messages,
});
final String channelName;
final List<ChatMessage> messages;
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView.builder(...),
),
ChatInputBar(channelName: channelName),
],
);
}
}
ここで大切なのは、Column の中に Expanded と ChatInputBar を入れていることです。
Column
├─ Expanded:メッセージ一覧
└─ ChatInputBar:入力欄
Expanded を使うことで、入力欄を下に残したまま、メッセージ一覧が残りの高さを使います。
これがチャット画面の基本構造です。
なぜListView.builderを使うのか
メッセージ一覧では、ListView.builder を使っています。
ListView.builder(
itemCount: messages.length + 1,
itemBuilder: (context, index) {
...
},
)
ListView.builder は、複数のデータを縦に並べるためのWidgetです。
初心者向けには、次のように理解してください。
ListView.builder = Listの中身を1つずつ画面に並べるWidget
チャットメッセージは、今後どんどん増える可能性があります。
そのため、固定で Text を並べるよりも、ListView.builder を使うほうが自然です。
messages
↓
ListView.builder
↓
ChatMessageTile
↓
画面に表示
itemCountにmessages.length + 1を使う理由
今回のコードでは、次のように書いています。
itemCount: messages.length + 1,
なぜ + 1 しているのでしょうか。
理由は、最初に ChannelIntro を表示しているからです。
index 0
↓
ChannelIntro
index 1
↓
messages[0]
index 2
↓
messages[1]
つまり、ListView の最初に「チャンネルの開始メッセージ」を入れて、その後にメッセージ一覧を表示しています。
そのため、実際のメッセージ数より1つ多くなります。
messages[index - 1]の意味
次のコードを見てください。
if (index == 0) {
return ChannelIntro(channelName: channelName);
}
final message = messages[index - 1];
return ChatMessageTile(message: message);
index == 0 のときは、ChannelIntro を表示します。
そのため、メッセージは index == 1 から始まります。
index 1
↓
messages[0]
index 2
↓
messages[1]
だから、messages[index - 1] と書いています。
初心者向けには、次のように理解してください。
先頭にChannelIntroを1つ入れているので、メッセージの番号を1つずらしている
ChannelIntroを分解する
ChannelIntro は、チャンネルの最初に表示される案内部分です。
#アイコン
Welcome to #general!
This is the start of the #general channel.
区切り線
コードでは、次のように作っています。
class ChannelIntro extends StatelessWidget {
const ChannelIntro({
super.key,
required this.channelName,
});
final String channelName;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: DiscordSpacing.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CircleAvatar(...),
const SizedBox(height: DiscordSpacing.lg),
Text('Welcome to #$channelName!'),
const SizedBox(height: DiscordSpacing.sm),
Text('This is the start of the #$channelName channel.'),
const SizedBox(height: DiscordSpacing.lg),
Container(height: 1, color: Colors.white10),
],
),
);
}
}
channelName を受け取っているので、general 以外のチャンネルにも使えます。
ChannelIntro(channelName: 'flutter-ui')
こうすると、表示は次のようになります。
Welcome to #flutter-ui!
This is the start of the #flutter-ui channel.
このように、固定文字ではなく、データを受け取って表示できるようにしておくと便利です。
ChatMessageTileを分解する
ChatMessageTile は、メッセージ1件分を表示します。
アイコン | ユーザー名 時刻
| 本文
横方向にアイコンと本文エリアを並べるので、外側は Row です。
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(...),
SizedBox(width: 12),
Expanded(
child: Column(...),
),
],
)
本文エリアの中では、ユーザー名、時刻、本文を縦に並べます。
Column
├─ ユーザー名 + 時刻
└─ 本文
つまり、ここでも Row と Column を組み合わせています。
メッセージ1件
↓
Rowで横に分ける
↓
右側はColumnで縦に並べる
Wrapとは何か
ユーザー名と時刻の表示には、Wrap を使っています。
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: DiscordSpacing.sm,
children: [
Text(message.userName),
Text(formatTime(message.timestamp)),
],
)
Wrap は、横に並べたものが入りきらないときに、折り返してくれるWidgetです。
初心者向けには、次のように理解してください。
Wrap = 横に並べ、入りきらないときは次の行に折り返すWidget
Row でも横並びにできますが、幅が足りないとoverflowすることがあります。
ユーザー名が長い場合や、スマホ幅で表示する場合は、Wrap のほうが安全です。
Row
↓
横に入りきらないとoverflowしやすい
Wrap
↓
入りきらないと折り返せる
buildInitials関数の役割
今回のアイコンは、画像ではなくユーザー名の先頭文字を表示しています。
そのために、buildInitials 関数を使っています。
String buildInitials(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) {
return '?';
}
if (trimmed.length >= 2) {
return trimmed.substring(0, 2).toUpperCase();
}
return trimmed[0].toUpperCase();
}
この関数は、ユーザー名からアイコン用の短い文字を作ります。
mika_design
↓
MI
code_senpai
↓
CO
空文字
↓
?
trim() は、前後の空白を取り除く処理です。
' mika_design '
↓
'mika_design'
substring(0, 2) は、先頭2文字を取り出す処理です。
mika_design
↓
mi
toUpperCase() は、大文字にする処理です。
mi
↓
MI
formatTime関数の役割
チャット画面では、投稿時刻をそのまま表示すると少し見づらいです。
たとえば、DateTime をそのまま出すと、次のようになることがあります。
2026-05-13 05:20:31.123
チャットアプリでは、次のように表示したほうが自然です。
Just now
12m ago
2h ago
そのために、formatTime 関数を作っています。
String formatTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inMinutes < 1) {
return 'Just now';
}
if (difference.inMinutes < 60) {
return '${difference.inMinutes}m ago';
}
if (difference.inHours < 24) {
return '${difference.inHours}h ago';
}
return '${dateTime.month}/${dateTime.day}/${dateTime.year}';
}
この関数は、投稿時刻を見やすい文字に変換しています。
1分未満
↓
Just now
60分未満
↓
12m ago
24時間未満
↓
2h ago
それ以上
↓
月/日/年
ChatInputBarを分解する
ChatInputBar は、下部の投稿フォーム風UIです。
+アイコン
Message #general
絵文字アイコン
コードでは、次のような構造になっています。
Container(
padding: ...,
child: Container(
height: 46,
decoration: BoxDecoration(...),
child: Row(
children: [
Icon(Icons.add_circle_rounded),
Expanded(child: Text('Message #general')),
Icon(Icons.emoji_emotions_outlined),
],
),
),
)
外側の Container は、画面端との余白を作るために使っています。
内側の Container は、入力欄の背景や角丸を作るために使っています。
外側Container
↓
入力欄の周囲に余白を作る
内側Container
↓
入力欄そのものの見た目を作る
このように、Containerを二重に使うことはよくあります。
ここまでの段階でできていること
この節の前半コードで、次のことができました。
ChatMessageデータをListで持つ
ListView.builderでメッセージ一覧を表示する
ChannelIntroを先頭に表示する
ChatMessageTileでメッセージ1件を表示する
ChatInputBarで入力欄風UIを表示する
ただし、まだできていないこともあります。
実際に文字を入力する
送信ボタンでメッセージを追加する
入力後にTextFieldを空にする
自動スクロールする
これらは次の節で扱います。
この節では、まず「メッセージ一覧と投稿フォームの見た目」を作ることが目的です。
TextFieldを使った入力フォームにする
ここからは、見た目だけの入力欄を、実際に入力できる TextField に変えていきます。
TextField は、文字を入力するためのWidgetです。
初心者向けには、次のように理解してください。
TextField = ユーザーが文字を入力できるWidget
まず、ChatInputBar を次のように変更します。
class ChatInputBar extends StatelessWidget {
const ChatInputBar({
super.key,
required this.channelName,
required this.controller,
required this.onSubmit,
});
final String channelName;
final TextEditingController controller;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(
DiscordSpacing.lg,
0,
DiscordSpacing.lg,
DiscordSpacing.lg,
),
child: Container(
minHeight: 46,
padding: const EdgeInsets.symmetric(horizontal: DiscordSpacing.md),
decoration: BoxDecoration(
color: DiscordColors.input,
borderRadius: BorderRadius.circular(DiscordRadius.md),
),
child: Row(
children: [
const Icon(
Icons.add_circle_rounded,
color: DiscordColors.textMuted,
size: 24,
),
const SizedBox(width: DiscordSpacing.md),
Expanded(
child: TextField(
controller: controller,
style: const TextStyle(
color: DiscordColors.textPrimary,
fontSize: 15,
),
cursorColor: DiscordColors.textPrimary,
decoration: InputDecoration(
hintText: 'Message #$channelName',
hintStyle: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 15,
),
border: InputBorder.none,
),
onSubmitted: (_) => onSubmit(),
),
),
const SizedBox(width: DiscordSpacing.sm),
GestureDetector(
onTap: onSubmit,
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: DiscordColors.blurple,
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: const Text(
'Send',
style: TextStyle(
color: DiscordColors.textPrimary,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
}
}
TextEditingControllerとは何か
ここで、新しく TextEditingController が出てきました。
final TextEditingController controller;
TextEditingController は、TextField に入力された文字を管理するためのものです。
初心者向けには、次のように理解してください。
TextEditingController = 入力欄の文字を取り出したり、消したりするための道具
たとえば、入力された文字を取り出すには、次のようにします。
controller.text
入力欄を空にするには、次のようにします。
controller.clear()
この節では、まだ完全な送信処理は作りませんが、入力欄を本物の TextField にしておきます。
VoidCallbackとは何か
ChatInputBar には、onSubmit も渡しています。
final VoidCallback onSubmit;
VoidCallback は、押されたときなどに実行する処理です。
初心者向けには、次のように理解してください。
VoidCallback = 引数なしで実行する関数
今回の場合は、送信ボタンを押したときに実行する処理です。
GestureDetector(
onTap: onSubmit,
child: ...
)
また、キーボードで送信したときにも実行します。
onSubmitted: (_) => onSubmit(),
ChatAreaをStatefulWidgetにする準備
TextEditingController は、基本的に状態を持つWidgetで管理します。
そのため、ChatArea を StatefulWidget に変更します。
ただし、この節では、まだメッセージ追加機能は簡易的に扱います。
次のような構造になります。
class ChatArea extends StatefulWidget {
const ChatArea({
super.key,
required this.channelName,
required this.messages,
});
final String channelName;
final List<ChatMessage> messages;
@override
State<ChatArea> createState() => _ChatAreaState();
}
class _ChatAreaState extends State<ChatArea> {
final TextEditingController controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
void submit() {
final text = controller.text.trim();
if (text.isEmpty) {
return;
}
debugPrint('入力された文字: $text');
controller.clear();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(
DiscordSpacing.lg,
DiscordSpacing.xl,
DiscordSpacing.lg,
DiscordSpacing.lg,
),
itemCount: widget.messages.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return ChannelIntro(channelName: widget.channelName);
}
final message = widget.messages[index - 1];
return ChatMessageTile(message: message);
},
),
),
ChatInputBar(
channelName: widget.channelName,
controller: controller,
onSubmit: submit,
),
],
);
}
}
disposeとは何か
ここで、dispose というものが出てきました。
@override
void dispose() {
controller.dispose();
super.dispose();
}
TextEditingController のような管理用オブジェクトは、使い終わったら片付ける必要があります。
初心者向けには、次のように理解してください。
dispose = 使い終わった道具を片付ける処理
画面が閉じられるときに、controllerを片付けています。
controller.dispose();
最初は少し難しく感じるかもしれませんが、TextEditingController を使ったら dispose で片付ける、と覚えておくとよいです。
debugPrintとは何か
submit の中で、次のように書いています。
debugPrint('入力された文字: $text');
debugPrint は、開発中に値を確認するために使います。
初心者向けには、次のように理解してください。
debugPrint = コンソールに確認用の文字を出す処理
今回の段階では、入力したメッセージを画面に追加する前に、まず入力値が取れているかを確認します。
入力する
↓
Sendを押す
↓
コンソールに文字が出る
↓
入力欄が空になる
次の節では、この入力文字を実際にメッセージ一覧へ追加します。
TextField対応版の完成コード
ここまでを反映した完成コードです。
import 'package:flutter/material.dart';
void main() {
runApp(const ChatAreaPracticeApp());
}
class ChatAreaPracticeApp extends StatelessWidget {
const ChatAreaPracticeApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Chat Area Practice',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: DiscordColors.background,
),
home: const ChatAreaPracticePage(),
);
}
}
class DiscordColors {
static const Color background = Color(0xFF313338);
static const Color panel = Color(0xFF232428);
static const Color input = Color(0xFF383A40);
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 xs = 4;
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 md = 8;
}
enum OnlineStatus {
online,
idle,
doNotDisturb,
offline,
}
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 messages = [
ChatMessage(
userName: 'mika_design',
handle: '@mika',
avatarColor: Color(0xFFEB459E),
avatarUrl: '',
body: 'Discord風UIは、画面を部品に分けると作りやすいです。',
timestamp: DateTime.now().subtract(const Duration(minutes: 24)),
status: OnlineStatus.online,
),
ChatMessage(
userName: 'code_senpai',
handle: '@senpai',
avatarColor: DiscordColors.green,
avatarUrl: '',
body: 'データをclassで整理すると、Widgetに渡しやすくなります。',
timestamp: DateTime.now().subtract(const Duration(minutes: 12)),
status: OnlineStatus.idle,
),
];
class ChatAreaPracticePage extends StatelessWidget {
const ChatAreaPracticePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: DiscordColors.background,
body: SafeArea(
child: ChatArea(
channelName: 'general',
messages: messages,
),
),
);
}
}
class ChatArea extends StatefulWidget {
const ChatArea({
super.key,
required this.channelName,
required this.messages,
});
final String channelName;
final List<ChatMessage> messages;
@override
State<ChatArea> createState() => _ChatAreaState();
}
class _ChatAreaState extends State<ChatArea> {
final TextEditingController controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
void submit() {
final text = controller.text.trim();
if (text.isEmpty) {
return;
}
debugPrint('入力された文字: $text');
controller.clear();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(
DiscordSpacing.lg,
DiscordSpacing.xl,
DiscordSpacing.lg,
DiscordSpacing.lg,
),
itemCount: widget.messages.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return ChannelIntro(channelName: widget.channelName);
}
final message = widget.messages[index - 1];
return ChatMessageTile(message: message);
},
),
),
ChatInputBar(
channelName: widget.channelName,
controller: controller,
onSubmit: submit,
),
],
);
}
}
class ChannelIntro extends StatelessWidget {
const ChannelIntro({
super.key,
required this.channelName,
});
final String channelName;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: DiscordSpacing.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CircleAvatar(
radius: 34,
backgroundColor: DiscordColors.panel,
child: Icon(
Icons.tag_rounded,
color: DiscordColors.textPrimary,
size: 38,
),
),
const SizedBox(height: DiscordSpacing.lg),
Text(
'Welcome to #$channelName!',
style: const TextStyle(
color: DiscordColors.textPrimary,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: DiscordSpacing.sm),
Text(
'This is the start of the #$channelName channel.',
style: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 14,
),
),
const SizedBox(height: DiscordSpacing.lg),
Container(height: 1, color: Colors.white10),
],
),
);
}
}
class ChatMessageTile extends StatefulWidget {
const ChatMessageTile({
super.key,
required this.message,
});
final ChatMessage message;
@override
State<ChatMessageTile> createState() => _ChatMessageTileState();
}
class _ChatMessageTileState extends State<ChatMessageTile> {
bool hovering = false;
@override
Widget build(BuildContext context) {
final message = widget.message;
return MouseRegion(
onEnter: (_) {
setState(() {
hovering = true;
});
},
onExit: (_) {
setState(() {
hovering = false;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
margin: const EdgeInsets.symmetric(vertical: 2),
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 21,
backgroundColor: message.avatarColor,
child: Text(
buildInitials(message.userName),
style: const TextStyle(
color: DiscordColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: DiscordSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: DiscordSpacing.sm,
children: [
Text(
message.userName,
style: TextStyle(
color: message.isSystem
? DiscordColors.yellow
: DiscordColors.textPrimary,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
Text(
formatTime(message.timestamp),
style: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 11,
),
),
],
),
const SizedBox(height: DiscordSpacing.xs),
SelectableText(
message.body,
style: const TextStyle(
color: DiscordColors.textSecondary,
fontSize: 15,
height: 1.4,
),
),
],
),
),
],
),
),
);
}
}
class ChatInputBar extends StatelessWidget {
const ChatInputBar({
super.key,
required this.channelName,
required this.controller,
required this.onSubmit,
});
final String channelName;
final TextEditingController controller;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(
DiscordSpacing.lg,
0,
DiscordSpacing.lg,
DiscordSpacing.lg,
),
child: Container(
minHeight: 46,
padding: const EdgeInsets.symmetric(horizontal: DiscordSpacing.md),
decoration: BoxDecoration(
color: DiscordColors.input,
borderRadius: BorderRadius.circular(DiscordRadius.md),
),
child: Row(
children: [
const Icon(
Icons.add_circle_rounded,
color: DiscordColors.textMuted,
size: 24,
),
const SizedBox(width: DiscordSpacing.md),
Expanded(
child: TextField(
controller: controller,
style: const TextStyle(
color: DiscordColors.textPrimary,
fontSize: 15,
),
cursorColor: DiscordColors.textPrimary,
minLines: 1,
maxLines: 5,
decoration: InputDecoration(
hintText: 'Message #$channelName',
hintStyle: const TextStyle(
color: DiscordColors.textMuted,
fontSize: 15,
),
border: InputBorder.none,
),
onSubmitted: (_) => onSubmit(),
),
),
const SizedBox(width: DiscordSpacing.sm),
GestureDetector(
onTap: onSubmit,
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: DiscordColors.blurple,
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: const Text(
'Send',
style: TextStyle(
color: DiscordColors.textPrimary,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
}
}
String buildInitials(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) {
return '?';
}
if (trimmed.length >= 2) {
return trimmed.substring(0, 2).toUpperCase();
}
return trimmed[0].toUpperCase();
}
String formatTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inMinutes < 1) {
return 'Just now';
}
if (difference.inMinutes < 60) {
return '${difference.inMinutes}m ago';
}
if (difference.inHours < 24) {
return '${difference.inHours}h ago';
}
return '${dateTime.month}/${dateTime.day}/${dateTime.year}';
}
実行して確認すること
このコードを実行すると、先ほどのチャット画面に加えて、入力欄に文字を入力できるようになります。
確認するポイントは、次の通りです。
入力欄に文字を入力できる
Sendボタンを押すと入力欄が空になる
コンソールに入力した文字が表示される
メッセージにマウスを重ねると背景色が変わる
メッセージ本文を選択できる
この節では、送信した文字をまだ画面には追加していません。
次の節で、入力した文字を ChatMessage としてListに追加し、画面に表示する処理を作ります。
SelectableTextとは何か
今回、メッセージ本文には Text ではなく SelectableText を使っています。
SelectableText(
message.body,
style: const TextStyle(...),
)
SelectableText は、ユーザーが文字を選択できるTextです。
初心者向けには、次のように理解してください。
SelectableText = コピーできる文字表示
チャットアプリでは、メッセージをコピーしたいことがあります。
そのため、本文には SelectableText を使うと少し実用的になります。
MouseRegionとAnimatedContainerでhover風にする
ChatMessageTile では、マウスを重ねたときに背景色が変わるようにしています。
MouseRegion(
onEnter: (_) {
setState(() {
hovering = true;
});
},
onExit: (_) {
setState(() {
hovering = false;
});
},
child: AnimatedContainer(...),
)
hovering が true のときだけ、背景色をつけています。
color: hovering ? DiscordColors.hover : Colors.transparent
これにより、PCでマウスを重ねると、Discordらしい反応が出ます。
スマホではhoverがないため、この変化は基本的に確認できません。
入力欄が複数行になる理由
TextField には、次の指定を入れています。
minLines: 1,
maxLines: 5,
これは、入力欄が最小1行、最大5行まで広がるという意味です。
短いメッセージ
↓
1行
長いメッセージ
↓
最大5行まで広がる
Discordの入力欄も、長い文章を入力すると高さが増えます。
今回の指定により、それに近い動きになります。
手を動かす練習1:メッセージを追加する
messages に、次のメッセージを追加してみましょう。
ChatMessage(
userName: 'flutter_dev',
handle: '@flutter_dev',
avatarColor: DiscordColors.blurple,
avatarUrl: '',
body: 'チャット画面の構造が少しずつ見えてきました。',
timestamp: DateTime.now().subtract(const Duration(minutes: 3)),
status: OnlineStatus.online,
),
保存すると、メッセージ一覧に1件追加されます。
この練習で、messages のデータを増やすとUIも増えることを確認できます。
手を動かす練習2:チャンネル名を変える
次の部分を探してください。
ChatArea(
channelName: 'general',
messages: messages,
),
general を flutter-ui に変えてみましょう。
ChatArea(
channelName: 'flutter-ui',
messages: messages,
),
すると、上部の案内文と入力欄の表示が変わります。
Welcome to #flutter-ui!
Message #flutter-ui
この練習で、channelName を渡して表示を変える流れが分かります。
手を動かす練習3:入力欄の高さを変えてみる
ChatInputBar の内側Containerを探してください。
minHeight: 46,
これを 52 に変えてみます。
minHeight: 52,
入力欄が少し大きくなります。
Discord風UIでは、入力欄の余白や高さも印象に大きく影響します。
手を動かす練習4:hover色を変える
DiscordColors.hover を探してください。
static const Color hover = Color(0xFF35373C);
これを少し明るくしてみましょう。
static const Color hover = Color(0xFF3A3D44);
PCでメッセージにマウスを重ねると、背景色の違いが分かります。
手を動かす練習5:時刻表示を日本語にする
formatTime の戻り値を変えると、時刻表示を日本語にできます。
if (difference.inMinutes < 1) {
return 'たった今';
}
if (difference.inMinutes < 60) {
return '${difference.inMinutes}分前';
}
if (difference.inHours < 24) {
return '${difference.inHours}時間前';
}
このように、表示用の関数を変えるだけで、画面全体の時刻表示を変更できます。
よくあるつまずき1:Expandedを入れ忘れて入力欄が下に固定されない
チャット画面では、メッセージ一覧に Expanded を使うことが重要です。
Expanded(
child: ListView.builder(...),
),
ChatInputBar(...)
もし Expanded がないと、メッセージ一覧と入力欄の高さ調整がうまくいかないことがあります。
Expandedあり
↓
メッセージ一覧が残りの高さを使う
入力欄が下に残る
よくあるつまずき2:ListView.builderのindexが分からない
今回の ListView.builder では、先頭に ChannelIntro を入れています。
そのため、メッセージは index - 1 で取り出しています。
if (index == 0) {
return ChannelIntro(channelName: widget.channelName);
}
final message = widget.messages[index - 1];
これは、最初は少しややこしいです。
次のように考えてください。
index 0
↓
ChannelIntro
index 1
↓
messages[0]
index 2
↓
messages[1]
よくあるつまずき3:TextEditingControllerをdisposeし忘れる
TextEditingController を使ったら、dispose で片付けます。
@override
void dispose() {
controller.dispose();
super.dispose();
}
これは、画面が破棄されるときに不要な管理オブジェクトを片付けるためです。
初心者のうちは、次のように覚えてください。
TextEditingControllerを作ったらdisposeで片付ける
よくあるつまずき4:TextFieldの枠線が表示される
Discord風UIでは、入力欄の枠線は目立たせたくありません。
そのため、次のようにしています。
border: InputBorder.none,
これにより、Flutter標準の下線や枠線を消しています。
InputBorder.none
↓
TextFieldの標準枠線を消す
その代わり、外側の Container に背景色と角丸をつけています。
よくあるつまずき5:Rowで文字がはみ出る
メッセージや入力欄では、横幅が足りないことがあります。
そのため、文字が伸びる部分には Expanded を使います。
Expanded(
child: TextField(...),
)
また、長いテキストには必要に応じて overflow: TextOverflow.ellipsis を使います。
Text(
'Message #$channelName',
overflow: TextOverflow.ellipsis,
)
これにより、横幅が足りないときに文字がはみ出しにくくなります。
この節の確認問題
確認問題1
ChatArea は何を担当するWidgetですか。
答え
中央のチャット画面全体を担当するWidgetです。
確認問題2
ChatMessageTile は何を担当するWidgetですか。
答え
チャットメッセージ1件分の表示を担当するWidgetです。
確認問題3
ChatInputBar は何を担当するWidgetですか。
答え
下部のメッセージ入力欄を担当するWidgetです。
確認問題4
メッセージ一覧を表示するために使ったWidgetは何ですか。
答え
ListView.builder です。
確認問題5
メッセージ一覧部分に Expanded を使う理由は何ですか。
答え
入力欄を下に残したまま、メッセージ一覧が残りの高さを使うようにするためです。
確認問題6
ユーザーが文字を入力できるWidgetは何ですか。
答え
TextField です。
確認問題7
TextEditingController は何のために使いますか。
答え
TextField に入力された文字を取り出したり、入力欄を空にしたりするために使います。
確認問題8
SelectableText は何ですか。
答え
ユーザーが選択・コピーできる文字表示Widgetです。
この節のまとめ
この節では、Discord風アプリの中央に表示されるチャット画面UIを作りました。
作った主な部品は、次の通りです。
ChatArea
ChannelIntro
ChatMessageTile
ChatInputBar
また、ChatMessage のListを使って、メッセージ一覧を表示しました。
messages
↓
ListView.builder
↓
ChatMessageTile
チャット画面の基本構造は、次の通りです。
Column
├─ Expanded
│ └─ ListView.builder
└─ ChatInputBar
この節で一番大切なのは、次の考え方です。
チャット画面は、メッセージ一覧をExpandedで広げ、入力欄をColumnの下に置くと作りやすい。
次の節では、今回作った入力欄を使って、実際に入力した文字をチャット欄に追加するメッセージ送信機能を作っていきます。