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

【メッセージ送信機能】入力した文字をチャット欄に追加する

この節で学ぶこと

前回の 5-8 では、Discord風アプリの中央に表示されるチャット画面UIを作りました。

ChatArea
├─ ChannelIntro
├─ ChatMessageTile
└─ ChatInputBar

前回の段階では、入力欄に文字を入力することはできました。

ただし、送信ボタンを押しても、入力した文字はコンソールに表示されるだけでした。

入力する
↓
Sendを押す
↓
debugPrintで確認
↓
画面には追加されない

今回の 5-9 では、入力欄に書いたメッセージを、実際にチャット欄へ追加します。

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

入力した文字をChatMessageとしてListに追加し、setStateで画面を更新すると、チャット送信機能が作れる。

今回作る機能

今回作るのは、次のようなメッセージ送信機能です。

1. 入力欄に文字を書く
2. Sendボタンを押す
3. 入力された文字を取得する
4. 空文字なら送信しない
5. ChatMessageを新しく作る
6. messagesのListに追加する
7. setStateで画面を更新する
8. 入力欄を空にする

完成すると、次のように動きます。

Message #general に「こんにちは」と入力
↓
Sendを押す
↓
チャット欄の一番下に「こんにちは」が追加される
↓
入力欄が空になる

これは、チャットアプリの基本中の基本です。

前回までの状態を確認する

前回の ChatArea では、TextEditingController を使って入力文字を取得できるようにしました。

final TextEditingController controller = TextEditingController();

そして、送信処理では、入力された文字をコンソールに表示していました。

void submit() {
  final text = controller.text.trim();

  if (text.isEmpty) {
    return;
  }

  debugPrint('入力された文字: $text');
  controller.clear();
}

今回、この submit を変更します。

今までは、入力された文字を確認するだけでした。

debugPrintで確認するだけ

今回からは、入力された文字を ChatMessage として追加します。

ChatMessageを作る
↓
Listに追加する
↓
画面に表示する

状態としてmessagesを持つ

チャット欄にメッセージを追加するには、表示しているメッセージ一覧を変更できる必要があります。

前回は、外側で作った messages を受け取って表示していました。

final List<ChatMessage> messages;

しかし、今回は送信するたびにメッセージを増やしたいです。

そのため、_ChatAreaState の中で、変更可能なListとして持ちます。

late List<ChatMessage> localMessages;

localMessages は、この画面内で管理するメッセージ一覧です。

localMessages
↓
チャット欄に表示するメッセージ一覧

lateとは何か

ここで、late という言葉が出てきました。

late List<ChatMessage> localMessages;

late は、「あとで必ず値を入れます」という意味です。

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

late = 今は値を入れないが、使う前に必ず入れるという宣言

今回の localMessages は、initState の中で値を入れます。

@override
void initState() {
  super.initState();
  localMessages = [...widget.messages];
}

このように、画面が作られる最初のタイミングで、外から受け取った messages をコピーします。

initStateとは何か

initState は、StatefulWidget が最初に作られたときに一度だけ実行される処理です。

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

initState = 画面が最初に作られるときの準備処理

今回のように、最初に必要なデータを準備したいときに使います。

@override
void initState() {
  super.initState();
  localMessages = [...widget.messages];
}

このコードは、外から受け取った widget.messages を、画面内で変更できる localMessages にコピーしています。

[…widget.messages]とは何か

次の書き方を見てください。

localMessages = [...widget.messages];

これは、widget.messages の中身を新しいListとしてコピーする書き方です。

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

[...元のList]
↓
元のListの中身をコピーして、新しいListを作る

なぜコピーするのでしょうか。

理由は、外から受け取ったListをそのまま変更するよりも、画面内専用のListとして持ったほうが分かりやすいからです。

widget.messages
↓
外から受け取った初期メッセージ

localMessages
↓
この画面内で増やしていくメッセージ

submitでメッセージを追加する

今回の中心になるのは、submit です。

まず完成形を見てみます。

void submit() {
  final text = controller.text.trim();

  if (text.isEmpty) {
    return;
  }

  setState(() {
    localMessages.add(
      ChatMessage(
        userName: 'flutter_dev',
        handle: '@flutter_dev',
        avatarColor: DiscordColors.blurple,
        avatarUrl: '',
        body: text,
        timestamp: DateTime.now(),
        status: OnlineStatus.online,
      ),
    );
  });

  controller.clear();
}

この処理を分解します。

入力文字を取得する
↓
空なら何もしない
↓
ChatMessageを作る
↓
localMessagesに追加する
↓
setStateで画面更新
↓
入力欄を空にする

controller.text.trim()の意味

まず、次の部分です。

final text = controller.text.trim();

controller.text は、入力欄に書かれている文字です。

controller.text
↓
TextFieldに入力された文字

trim() は、前後の空白を取り除く処理です。

'  こんにちは  '
↓ trim()
'こんにちは'

このようにして、送信する文字をきれいにしています。

空文字を送信しない

次の部分を見てください。

if (text.isEmpty) {
  return;
}

これは、入力文字が空なら送信しないという処理です。

空文字
↓
return
↓
ここで処理を終わる

もしこの処理がないと、何も書いていないのに空のメッセージが追加されてしまいます。

チャットアプリでは、空メッセージを送信しない処理が必要です。

ChatMessageを新しく作る

次に、入力された文字を ChatMessage にします。

ChatMessage(
  userName: 'flutter_dev',
  handle: '@flutter_dev',
  avatarColor: DiscordColors.blurple,
  avatarUrl: '',
  body: text,
  timestamp: DateTime.now(),
  status: OnlineStatus.online,
)

ここで大切なのは、body: text です。

body: text,

入力欄に書かれた文字を、メッセージ本文として使っています。

入力欄の文字
↓
text
↓
ChatMessageのbody
↓
チャット画面に表示

DateTime.now()で送信時刻を入れる

メッセージを送信した時刻は、DateTime.now() で入れます。

timestamp: DateTime.now(),

これは、現在時刻を表します。

DateTime.now()
↓
今の時刻

この時刻は、formatTime 関数で表示用の文字に変換されます。

DateTime.now()
↓
formatTime
↓
Just now

localMessages.addとは何か

次の部分を見てください。

localMessages.add(
  ChatMessage(...),
);

add は、Listの最後にデータを追加する処理です。

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

add = Listの最後に1件追加する

たとえば、もともと2件のメッセージがある場合です。

localMessages
├─ 1件目
└─ 2件目

ここに add すると、3件目が追加されます。

localMessages
├─ 1件目
├─ 2件目
└─ 3件目

これで、チャット欄に新しいメッセージが増えます。

setStateで画面を更新する

Listにメッセージを追加するだけでは、画面が更新されないことがあります。

Flutterに「状態が変わったので、画面を描き直してください」と伝える必要があります。

そこで使うのが setState です。

setState(() {
  localMessages.add(...);
});

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

setState
↓
値が変わったので、画面を更新する

今回の場合は、localMessages が変わりました。

localMessagesにメッセージが増えた
↓
setState
↓
ListView.builderが描き直される
↓
新しいメッセージが表示される

controller.clearで入力欄を空にする

送信が終わったら、入力欄を空にします。

controller.clear();

これをしないと、送信後も入力欄に文字が残ったままになります。

送信
↓
controller.clear()
↓
入力欄が空になる

チャットアプリでは、送信後に入力欄が空になるほうが自然です。

まずは送信機能だけを追加した完成コード

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

import 'package:flutter/material.dart';

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

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

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

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 initialMessages = [
  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 MessageSendPracticePage extends StatelessWidget {
  const MessageSendPracticePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: DiscordColors.background,
      body: SafeArea(
        child: ChatArea(
          channelName: 'general',
          messages: initialMessages,
        ),
      ),
    );
  }
}

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

  late List<ChatMessage> localMessages;

  @override
  void initState() {
    super.initState();
    localMessages = [...widget.messages];
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  void submit() {
    final text = controller.text.trim();

    if (text.isEmpty) {
      return;
    }

    setState(() {
      localMessages.add(
        ChatMessage(
          userName: 'flutter_dev',
          handle: '@flutter_dev',
          avatarColor: DiscordColors.blurple,
          avatarUrl: '',
          body: text,
          timestamp: DateTime.now(),
          status: OnlineStatus.online,
        ),
      );
    });

    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: localMessages.length + 1,
            itemBuilder: (context, index) {
              if (index == 0) {
                return ChannelIntro(channelName: widget.channelName);
              }

              final message = localMessages[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}';
}

実行して確認すること

このコードを実行したら、次の操作を試してください。

1. 入力欄に「こんにちは」と入力する
2. Sendボタンを押す
3. チャット欄に「こんにちは」が追加される
4. 入力欄が空になる
5. もう一度別の文字を入力する
6. メッセージがさらに追加される

この動きが確認できれば、基本的なメッセージ送信機能は完成です。

今回の状態の流れを整理する

今回の処理は、状態の流れとして見ると分かりやすいです。

TextField
↓
controller.text
↓
submit
↓
ChatMessageを作成
↓
localMessages.add
↓
setState
↓
ListView.builderが再描画
↓
ChatMessageTileが増える

この流れが、Flutterでチャット機能を作る基本です。

送信後に自動スクロールする

ここまでで、メッセージは追加できるようになりました。

ただし、メッセージが増えたときに、自動で一番下までスクロールしてくれると、よりチャットアプリらしくなります。

そのために、ScrollController を使います。

final ScrollController scrollController = ScrollController();

ScrollController は、スクロール位置を操作するための道具です。

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

ScrollController = ListViewのスクロール位置を動かすための道具

ScrollControllerをListViewに渡す

まず、_ChatAreaStateScrollController を追加します。

final ScrollController scrollController = ScrollController();

そして、ListView.builder に渡します。

ListView.builder(
  controller: scrollController,
  ...
)

これで、コードからListViewのスクロール位置を動かせるようになります。

animateToで一番下へ移動する

メッセージ送信後に一番下へスクロールするには、次のように書きます。

void scrollToBottom() {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (scrollController.hasClients) {
      scrollController.animateTo(
        scrollController.position.maxScrollExtent,
        duration: const Duration(milliseconds: 220),
        curve: Curves.easeOut,
      );
    }
  });
}

少し難しいので、分解します。

WidgetsBinding.instance.addPostFrameCallbackとは何か

次の部分です。

WidgetsBinding.instance.addPostFrameCallback((_) {
  ...
});

これは、画面の描画が終わったあとに処理を実行するための書き方です。

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

画面が更新されたあとで、スクロール処理を実行する

メッセージを追加した直後は、まだ画面上に新しいメッセージが描画されていない場合があります。

その前にスクロールしようとすると、正しい一番下の位置が分からないことがあります。

そこで、画面更新後にスクロールします。

メッセージ追加
↓
setState
↓
画面が描画される
↓
その後で一番下へスクロール

scrollController.hasClientsとは何か

次の部分です。

if (scrollController.hasClients) {
  ...
}

これは、ScrollController が実際に ListView とつながっているかを確認しています。

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

hasClients
↓
このScrollControllerは使える状態か確認する

安全にスクロール処理をするために入れています。

maxScrollExtentとは何か

次の部分です。

scrollController.position.maxScrollExtent

これは、ListViewの一番下のスクロール位置です。

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

maxScrollExtent = スクロールできる一番下の位置

ここへ animateTo することで、メッセージ一覧の一番下へ移動します。

自動スクロール対応版のChatArea

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

class _ChatAreaState extends State<ChatArea> {
  final TextEditingController controller = TextEditingController();
  final ScrollController scrollController = ScrollController();

  late List<ChatMessage> localMessages;

  @override
  void initState() {
    super.initState();
    localMessages = [...widget.messages];
  }

  @override
  void dispose() {
    controller.dispose();
    scrollController.dispose();
    super.dispose();
  }

  void submit() {
    final text = controller.text.trim();

    if (text.isEmpty) {
      return;
    }

    setState(() {
      localMessages.add(
        ChatMessage(
          userName: 'flutter_dev',
          handle: '@flutter_dev',
          avatarColor: DiscordColors.blurple,
          avatarUrl: '',
          body: text,
          timestamp: DateTime.now(),
          status: OnlineStatus.online,
        ),
      );
    });

    controller.clear();
    scrollToBottom();
  }

  void scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (scrollController.hasClients) {
        scrollController.animateTo(
          scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 220),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: ListView.builder(
            controller: scrollController,
            padding: const EdgeInsets.fromLTRB(
              DiscordSpacing.lg,
              DiscordSpacing.xl,
              DiscordSpacing.lg,
              DiscordSpacing.lg,
            ),
            itemCount: localMessages.length + 1,
            itemBuilder: (context, index) {
              if (index == 0) {
                return ChannelIntro(channelName: widget.channelName);
              }

              final message = localMessages[index - 1];

              return ChatMessageTile(message: message);
            },
          ),
        ),
        ChatInputBar(
          channelName: widget.channelName,
          controller: controller,
          onSubmit: submit,
        ),
      ],
    );
  }
}

ここで追加したポイントは、次の3つです。

ScrollControllerを追加
ListView.builderにcontrollerを渡す
送信後にscrollToBottomを呼ぶ

また、ScrollController も使い終わったら片付けます。

scrollController.dispose();

送信ボタンの状態を少し改善する

今の送信ボタンは、入力欄が空でも押せます。

空なら何も起きないので問題はありませんが、入力があるときだけボタンを目立たせると、より分かりやすくなります。

そのためには、入力中の文字が変わったときにUIを更新する必要があります。

まず、initState でcontrollerにリスナーを追加します。

@override
void initState() {
  super.initState();
  localMessages = [...widget.messages];

  controller.addListener(() {
    setState(() {});
  });
}

addListener は、入力欄の文字が変わるたびに処理を実行するためのものです。

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

addListener
↓
入力欄の変化を見張る

そして、ChatInputBarcanSend を渡します。

ChatInputBar(
  channelName: widget.channelName,
  controller: controller,
  canSend: controller.text.trim().isNotEmpty,
  onSubmit: submit,
)

canSend は、送信できるかどうかを表す bool です。

canSend = true
↓
送信できる

canSend = false
↓
送信できない

ChatInputBarにcanSendを追加する

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

class ChatInputBar extends StatelessWidget {
  const ChatInputBar({
    super.key,
    required this.channelName,
    required this.controller,
    required this.canSend,
    required this.onSubmit,
  });

  final String channelName;
  final TextEditingController controller;
  final bool canSend;
  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: canSend ? onSubmit : null,
              child: AnimatedContainer(
                duration: const Duration(milliseconds: 150),
                height: 32,
                padding: const EdgeInsets.symmetric(horizontal: 12),
                decoration: BoxDecoration(
                  color: canSend
                      ? DiscordColors.blurple
                      : DiscordColors.panel,
                  borderRadius: BorderRadius.circular(6),
                ),
                alignment: Alignment.center,
                child: Text(
                  'Send',
                  style: TextStyle(
                    color: canSend
                        ? DiscordColors.textPrimary
                        : DiscordColors.textMuted,
                    fontSize: 13,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

これで、入力欄が空のときはボタンが少し暗くなり、入力があるときだけ送信できるように見えます。

onTap: canSend ? onSubmit : null の意味

次の部分を見てください。

onTap: canSend ? onSubmit : null,

これは、送信可能なときだけ onSubmit を実行するという意味です。

canSendがtrue
↓
onSubmitを実行できる

canSendがfalse
↓
何もしない

条件によって処理を変える書き方です。

完成版:自動スクロールと送信ボタン状態つきコード

ここまでを統合した完成コードです。

import 'package:flutter/material.dart';

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

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

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

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 initialMessages = [
  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 MessageSendPracticePage extends StatelessWidget {
  const MessageSendPracticePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: DiscordColors.background,
      body: SafeArea(
        child: ChatArea(
          channelName: 'general',
          messages: initialMessages,
        ),
      ),
    );
  }
}

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();
  final ScrollController scrollController = ScrollController();

  late List<ChatMessage> localMessages;

  @override
  void initState() {
    super.initState();

    localMessages = [...widget.messages];

    controller.addListener(() {
      setState(() {});
    });
  }

  @override
  void dispose() {
    controller.dispose();
    scrollController.dispose();
    super.dispose();
  }

  void submit() {
    final text = controller.text.trim();

    if (text.isEmpty) {
      return;
    }

    setState(() {
      localMessages.add(
        ChatMessage(
          userName: 'flutter_dev',
          handle: '@flutter_dev',
          avatarColor: DiscordColors.blurple,
          avatarUrl: '',
          body: text,
          timestamp: DateTime.now(),
          status: OnlineStatus.online,
        ),
      );
    });

    controller.clear();
    scrollToBottom();
  }

  void scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (scrollController.hasClients) {
        scrollController.animateTo(
          scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 220),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final canSend = controller.text.trim().isNotEmpty;

    return Column(
      children: [
        Expanded(
          child: ListView.builder(
            controller: scrollController,
            padding: const EdgeInsets.fromLTRB(
              DiscordSpacing.lg,
              DiscordSpacing.xl,
              DiscordSpacing.lg,
              DiscordSpacing.lg,
            ),
            itemCount: localMessages.length + 1,
            itemBuilder: (context, index) {
              if (index == 0) {
                return ChannelIntro(channelName: widget.channelName);
              }

              final message = localMessages[index - 1];

              return ChatMessageTile(message: message);
            },
          ),
        ),
        ChatInputBar(
          channelName: widget.channelName,
          controller: controller,
          canSend: canSend,
          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.canSend,
    required this.onSubmit,
  });

  final String channelName;
  final TextEditingController controller;
  final bool canSend;
  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: canSend ? onSubmit : null,
              child: AnimatedContainer(
                duration: const Duration(milliseconds: 150),
                height: 32,
                padding: const EdgeInsets.symmetric(horizontal: 12),
                decoration: BoxDecoration(
                  color: canSend
                      ? DiscordColors.blurple
                      : DiscordColors.panel,
                  borderRadius: BorderRadius.circular(6),
                ),
                alignment: Alignment.center,
                child: Text(
                  'Send',
                  style: TextStyle(
                    color: canSend
                        ? DiscordColors.textPrimary
                        : DiscordColors.textMuted,
                    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ボタンが暗い
文字を入力するとSendボタンが青紫になる
Sendを押すとメッセージが追加される
送信後、入力欄が空になる
メッセージが増えたら一番下へスクロールする

これで、見た目だけではなく、実際に動くチャット機能になりました。

今回の処理を図で理解する

メッセージ送信の流れを、もう一度整理します。

ユーザーがTextFieldに入力
↓
TextEditingControllerが文字を管理
↓
Sendボタンを押す
↓
submitが実行される
↓
controller.text.trim()で文字を取得
↓
空ならreturn
↓
ChatMessageを作る
↓
localMessages.addでListに追加
↓
setStateで画面を更新
↓
controller.clearで入力欄を空にする
↓
scrollToBottomで一番下へ移動

この流れを理解すると、チャットアプリだけでなく、コメント機能や投稿機能にも応用できます。

手を動かす練習1:送信者名を変えてみる

submit の中の次の部分を探してください。

userName: 'flutter_dev',
handle: '@flutter_dev',

これを変更してみましょう。

userName: 'student_user',
handle: '@student',

送信したメッセージの投稿者名が変わります。

この練習で、送信時に作る ChatMessage の中身が画面に反映されることを確認できます。

手を動かす練習2:送信アイコンを追加する

Send という文字の代わりに、送信アイコンにしてみましょう。

child: Icon(
  Icons.send_rounded,
  color: canSend
      ? DiscordColors.textPrimary
      : DiscordColors.textMuted,
  size: 18,
),

見た目が少しチャットアプリらしくなります。

手を動かす練習3:空文字チェックを消してみる

一度、次の処理をコメントアウトしてみてください。

if (text.isEmpty) {
  return;
}

すると、空の状態でも送信されてしまう可能性があります。

確認できたら、必ず元に戻してください。

空メッセージを防ぐ処理は重要

手を動かす練習4:自動スクロール時間を変えてみる

次の部分を探してください。

duration: const Duration(milliseconds: 220),

これを 600 に変えてみます。

duration: const Duration(milliseconds: 600),

スクロールがゆっくりになります。

反対に、100 にすると速くなります。

手を動かす練習5:送信後にBot返信を追加してみる

少し発展練習です。

submit の中で、自分のメッセージを追加したあと、Botの返信も追加してみましょう。

setState(() {
  localMessages.add(
    ChatMessage(
      userName: 'flutter_dev',
      handle: '@flutter_dev',
      avatarColor: DiscordColors.blurple,
      avatarUrl: '',
      body: text,
      timestamp: DateTime.now(),
      status: OnlineStatus.online,
    ),
  );

  localMessages.add(
    ChatMessage(
      userName: 'flutter_bot',
      handle: '@bot',
      avatarColor: DiscordColors.green,
      avatarUrl: '',
      body: 'メッセージを受け取りました。',
      timestamp: DateTime.now(),
      status: OnlineStatus.online,
      isSystem: true,
    ),
  );
});

これで、送信するとBotからの返信も表示されます。

この練習をすると、Listに複数のメッセージを追加する感覚がつかめます。

よくあるつまずき1:送信しても画面に追加されない

送信しても画面に追加されない場合、まず setState の中で localMessages.add しているか確認してください。

setState(() {
  localMessages.add(...);
});

setState がないと、Listの中身が変わっても画面が更新されない場合があります。

Listに追加した
↓
でもsetStateしていない
↓
画面が更新されない

よくあるつまずき2:controller.clearを書き忘れる

controller.clear() を忘れると、送信後も入力欄に文字が残ります。

controller.clear();

送信処理の最後に入れておきましょう。

よくあるつまずき3:disposeし忘れる

TextEditingControllerScrollController は、使い終わったら dispose で片付けます。

@override
void dispose() {
  controller.dispose();
  scrollController.dispose();
  super.dispose();
}

初心者のうちは、次のように覚えてください。

Controllerを作ったらdisposeで片付ける

よくあるつまずき4:入力してもSendボタンの色が変わらない

入力に応じてSendボタンの色を変えるには、controller.addListener で画面を更新する必要があります。

controller.addListener(() {
  setState(() {});
});

これがないと、入力文字が変わっても canSend の見た目が更新されない場合があります。

よくあるつまずき5:スクロールが一番下に行かない

スクロールがうまくいかない場合は、次の3つを確認してください。

ScrollControllerを作っているか
ListView.builderにcontrollerを渡しているか
送信後にscrollToBottomを呼んでいるか

特に、画面更新後にスクロールするために、次の処理が重要です。

WidgetsBinding.instance.addPostFrameCallback((_) {
  ...
});

この節の確認問題

確認問題1

入力欄に書かれた文字を取得するには、何を使いますか。

答え

TextEditingControllercontroller.text を使います。

確認問題2

空文字を送信しないために使った処理は何ですか。

答え

if (text.isEmpty) { return; } です。

確認問題3

新しいメッセージを画面に追加するために、どのListへ追加しましたか。

答え

localMessages に追加しました。

確認問題4

Listにメッセージを追加したあと、画面を更新するために使ったものは何ですか。

答え

setState です。

確認問題5

送信後に入力欄を空にするために使った処理は何ですか。

答え

controller.clear() です。

確認問題6

メッセージ追加後に一番下へスクロールするために使ったControllerは何ですか。

答え

ScrollController です。

確認問題7

入力欄の文字が変わったことを見張るために使った処理は何ですか。

答え

controller.addListener です。

この節のまとめ

この節では、Discord風チャットアプリにメッセージ送信機能を追加しました。

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

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

さらに、ScrollController を使って、送信後に一番下へ自動スクロールする処理も追加しました。

メッセージ追加
↓
画面更新
↓
scrollToBottom
↓
一番下へ移動

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

ユーザー操作でデータを変更し、その変更をsetStateで画面に反映することで、アプリは動くUIになる。

次の節では、Discord風のプロフィールカードUIを作り、ユーザー情報をより本格的に表示していきます。

教材トップへ戻る