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

【スマホ対応】Drawerでサーバー一覧・チャンネル一覧を収納する

この節で学ぶこと

前回の 5-13 では、Discord風アプリの右側に表示するメンバー一覧UIを作りました。

MemberPanel
├─ ONLINE
├─ MemberTile
├─ IDLE
├─ MemberTile
└─ ProfileCard

ここまでで、Discord風アプリに必要な主要パーツがかなり揃ってきました。

ServerRail
ChannelSidebar
ChatArea
MemberPanel
ProfileCard
ProfileEditorDialog

ただし、ここで大きな問題があります。

PCでは、次のような4カラム構造で表示できます。

ServerRail | ChannelSidebar | ChatArea | MemberPanel

しかし、スマホでは画面幅が狭いため、この4カラム構造をそのまま表示できません。

そこで今回の 5-14 では、スマホ画面では左側のサーバー一覧とチャンネル一覧を Drawer に収納する方法を学びます。

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

スマホでは、常に全部を横並びにせず、必要なメニューをDrawerに収納して、メイン画面はChatAreaに集中させる。

なぜスマホ対応が必要なのか

PCでは横幅が広いため、Discord風の画面を次のように表示できます。

左端:サーバー一覧
左側:チャンネル一覧
中央:チャット画面
右側:メンバー一覧

しかし、スマホでは横幅が限られています。

スマホ画面
↓
横幅が狭い
↓
4カラムをそのまま表示できない

無理に4つの領域を横に並べると、次のような問題が起きます。

文字がはみ出る
アイコンがつぶれる
チャット画面が狭くなる
RenderFlex overflowが起きる
操作しづらい

そこで、スマホでは表示方法を変えます。

PC
↓
4カラム表示

スマホ
↓
チャット画面をメインに表示
サーバー一覧・チャンネル一覧はDrawerに収納
メンバー一覧は右側Drawerに収納

今回作るスマホ対応の考え方

今回のスマホ対応では、次のように画面を切り替えます。

PC表示
ServerRail | ChannelSidebar | ChatArea | MemberPanel

スマホ表示
ChatAreaだけをメイン表示
左Drawerに ServerRail + ChannelSidebar
右Drawerに MemberPanel

イメージとしては、次のようになります。

スマホ通常時
┌────────────────────┐
│ # general           │
├────────────────────┤
│                    │
│   ChatArea          │
│                    │
└────────────────────┘

左メニューを開く
┌──────────────┬─────┐
│ ServerRail   │     │
│ ChannelSide  │背面 │
└──────────────┴─────┘

右メニューを開く
┌─────┬──────────────┐
│背面 │ MemberPanel  │
└─────┴──────────────┘

このように、スマホでは「全部を見せる」のではなく、「必要なときに開く」設計にします。

Drawerとは何か

Drawer は、画面の端からスライドして出てくるメニューです。

Flutterでは、Scaffolddrawer を設定すると、左側から開くメニューを作れます。

Scaffold(
  drawer: Drawer(
    child: ...
  ),
)

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

Drawer
↓
画面の横から出てくるメニュー

スマホアプリでは、メニューやナビゲーションを収納するためによく使います。

endDrawerとは何か

Flutterには、左側から開く drawer だけでなく、右側から開く endDrawer もあります。

Scaffold(
  drawer: 左側メニュー,
  endDrawer: 右側メニュー,
)

今回のDiscord風アプリでは、次のように使います。

drawer
↓
サーバー一覧・チャンネル一覧

endDrawer
↓
メンバー一覧

つまり、スマホ画面では左右のDrawerを使って、PC版の左右パネルを収納します。

今回作るレイアウト

今回作るスマホレイアウトは、次のような構造です。

MobileDiscordLayout
├─ Scaffold
│  ├─ drawer
│  │  └─ ServerRail + ChannelSidebar
│  ├─ endDrawer
│  │  └─ MemberPanel
│  └─ body
│     ├─ ChatTopBar
│     └─ ChatArea

今まで作ってきた部品を、スマホ用に配置し直します。

新しく全部作り直す
↓
ではない

既存のWidgetを組み合わせ直す
↓
スマホ対応にする

これがとても重要です。

レスポンシブ対応とは何か

レスポンシブ対応とは、画面サイズに応じてレイアウトを変えることです。

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

レスポンシブ対応
↓
PC・タブレット・スマホで見やすく表示を変えること

今回のように、PCでは横並び、スマホではDrawer収納にするのもレスポンシブ対応です。

広い画面
↓
横並び

狭い画面
↓
収納・切り替え

Flutterでは、LayoutBuilderMediaQuery を使って画面幅を取得し、表示を切り替えます。

LayoutBuilderとは何か

LayoutBuilder は、そのWidgetが使える幅や高さを調べられるWidgetです。

LayoutBuilder(
  builder: (context, constraints) {
    final width = constraints.maxWidth;

    if (width < 760) {
      return MobileLayout();
    }

    return DesktopLayout();
  },
)

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

LayoutBuilder
↓
今使える画面幅を見て、表示するWidgetを切り替えるもの

今回の教材では、画面幅が狭い場合にスマホ用レイアウトを表示します。

width < 760
↓
スマホ用

width >= 760
↓
PC・タブレット用

まず最小のDrawerを作る

最初に、Drawerの動きを理解するために、シンプルなサンプルを作ります。

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

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Drawer Practice',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(),
      home: const DrawerPracticePage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF313338),
      drawer: Drawer(
        backgroundColor: const Color(0xFF2B2D31),
        child: ListView(
          padding: const EdgeInsets.all(16),
          children: const [
            Text(
              'Flutter Lab',
              style: TextStyle(
                color: Color(0xFFF2F3F5),
                fontSize: 20,
                fontWeight: FontWeight.bold,
              ),
            ),
            SizedBox(height: 24),
            Text(
              '# general',
              style: TextStyle(
                color: Color(0xFFF2F3F5),
                fontSize: 16,
              ),
            ),
            SizedBox(height: 12),
            Text(
              '# flutter-ui',
              style: TextStyle(
                color: Color(0xFFB5BAC1),
                fontSize: 16,
              ),
            ),
          ],
        ),
      ),
      appBar: AppBar(
        backgroundColor: const Color(0xFF313338),
        title: const Text('# general'),
      ),
      body: const Center(
        child: Text(
          'ここにチャット画面が入ります',
          style: TextStyle(
            color: Color(0xFFF2F3F5),
            fontSize: 20,
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
    );
  }
}

実行して確認すること

このコードを実行すると、上部にメニューアイコンが表示されます。

確認するポイントは、次の通りです。

左上のメニューアイコンを押す
↓
左からDrawerが開く
↓
Flutter Labやチャンネル名が表示される
↓
外側をタップすると閉じる

これがDrawerの基本です。

Scaffoldのdrawerを理解する

Drawerは、Scaffold の中に設定します。

Scaffold(
  drawer: Drawer(...),
  body: ...
)

Scaffold は、画面の土台です。

drawer を指定すると、左側から開くメニューを作れます。

Scaffold
├─ drawer
└─ body

スマホ対応では、この drawer にサーバー一覧とチャンネル一覧を入れます。

Builderとは何か

次に、ボタンからDrawerを開く方法を確認します。

通常、AppBar のメニューアイコンを使えば自動で開けます。

しかし、Discord風UIでは、自作のTopBarからDrawerを開きたいことがあります。

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

Builder(
  builder: (context) {
    return IconButton(
      onPressed: () {
        Scaffold.of(context).openDrawer();
      },
      icon: const Icon(Icons.menu),
    );
  },
)

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

Builder
↓
Scaffoldに近いcontextを作るためのWidget

Scaffold.of(context).openDrawer() は、現在のScaffoldのDrawerを開く処理です。

Scaffold.of(context).openDrawer()
↓
左側Drawerを開く

右側のDrawerを開く場合は、次のようにします。

Scaffold.of(context).openEndDrawer();

ChatTopBarをスマホ対応にする

スマホでは、上部バーに次の2つのボタンが必要です。

左側:メニューを開くボタン
右側:メンバー一覧を開くボタン

つまり、ChatTopBar には、必要に応じてボタン用の処理を渡せるようにします。

class ChatTopBar extends StatelessWidget {
  const ChatTopBar({
    super.key,
    required this.channelName,
    this.onOpenMenu,
    this.onOpenMembers,
  });

  final String channelName;
  final VoidCallback? onOpenMenu;
  final VoidCallback? onOpenMembers;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 52,
      color: DiscordColors.background,
      padding: const EdgeInsets.symmetric(horizontal: 10),
      child: Row(
        children: [
          if (onOpenMenu != null)
            IconButton(
              onPressed: onOpenMenu,
              icon: const Icon(
                Icons.menu_rounded,
                color: DiscordColors.textPrimary,
              ),
            ),
          const Icon(
            Icons.tag_rounded,
            color: DiscordColors.textMuted,
            size: 22,
          ),
          const SizedBox(width: 8),
          Expanded(
            child: Text(
              channelName,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 16,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          if (onOpenMembers != null)
            IconButton(
              onPressed: onOpenMembers,
              icon: const Icon(
                Icons.people_alt_rounded,
                color: DiscordColors.textPrimary,
              ),
            ),
        ],
      ),
    );
  }
}

VoidCallback?とは何か

ここで、VoidCallback? が出てきました。

final VoidCallback? onOpenMenu;

VoidCallback は、引数なしで実行する関数です。

? がついているので、値がない場合もあります。

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

VoidCallback
↓
実行できる処理

VoidCallback?
↓
処理がある場合も、ない場合もある

今回、PCではDrawerを開く必要がありません。

そのため、onOpenMenu がない場合は、メニューボタンを表示しません。

if (onOpenMenu != null)
  IconButton(...)

これにより、同じ ChatTopBar をPCでもスマホでも使えます。

スマホ用レイアウトを作る

ここから、スマホ用の MobileDiscordLayout を作ります。

構造は次の通りです。

MobileDiscordLayout
├─ drawer
│  └─ ServerRail + ChannelSidebar
├─ endDrawer
│  └─ MemberPanel
└─ body
   ├─ ChatTopBar
   └─ ChatArea

最初は、簡易的な部品で作ります。

class MobileDiscordLayout extends StatelessWidget {
  const MobileDiscordLayout({
    super.key,
    required this.channelName,
  });

  final String channelName;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: DiscordColors.background,
      drawerScrimColor: Colors.black54,
      drawer: SizedBox(
        width: MediaQuery.of(context).size.width * 0.9,
        child: const DrawerContent(),
      ),
      endDrawer: SizedBox(
        width: MediaQuery.of(context).size.width * 0.86,
        child: const MemberPanelMock(),
      ),
      body: SafeArea(
        child: Builder(
          builder: (context) {
            return Column(
              children: [
                ChatTopBar(
                  channelName: channelName,
                  onOpenMenu: () {
                    Scaffold.of(context).openDrawer();
                  },
                  onOpenMembers: () {
                    Scaffold.of(context).openEndDrawer();
                  },
                ),
                const Expanded(
                  child: Center(
                    child: Text(
                      'ここにChatAreaが入ります',
                      style: TextStyle(
                        color: DiscordColors.textPrimary,
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}

MediaQueryとは何か

ここで、MediaQuery が出てきました。

MediaQuery.of(context).size.width

これは、画面幅を取得するための書き方です。

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

MediaQuery
↓
画面サイズなどの情報を取得するもの

今回のコードでは、Drawerの幅を画面幅の90%にしています。

width: MediaQuery.of(context).size.width * 0.9

つまり、スマホ画面のほとんどを使ってDrawerを表示します。

画面幅が400px
↓
400 * 0.9 = 360px
↓
Drawer幅は360px

drawerScrimColorとは何か

次の設定があります。

drawerScrimColor: Colors.black54,

これは、Drawerが開いたときに、背景側にかかる半透明の色です。

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

drawerScrimColor
↓
Drawerの背後にかかる暗いフィルター色

これがあることで、Drawerが前面に出ていることが分かりやすくなります。

Drawerの中身を作る

左側Drawerには、サーバー一覧とチャンネル一覧を入れます。

今回は簡易版として、次のような DrawerContent を作ります。

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.sidebar,
      child: Row(
        children: const [
          SizedBox(
            width: 72,
            child: ServerRailMock(),
          ),
          Expanded(
            child: ChannelSidebarMock(),
          ),
        ],
      ),
    );
  }
}

ここで重要なのは、Drawerの中でも Row を使っていることです。

DrawerContent
└─ Row
   ├─ ServerRailMock
   └─ ChannelSidebarMock

スマホでも、Drawerの中では左にサーバー一覧、右にチャンネル一覧という構造にします。

ServerRailMockを作る

まずは簡易的なサーバー一覧を作ります。

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.appRail,
      child: ListView(
        padding: const EdgeInsets.symmetric(vertical: 10),
        children: const [
          ServerCircle(label: 'FL', color: DiscordColors.blurple),
          ServerCircle(label: 'UI', color: Color(0xFFEB459E)),
          ServerCircle(label: 'AI', color: DiscordColors.green),
          ServerCircle(label: '+', color: DiscordColors.green),
        ],
      ),
    );
  }
}

class ServerCircle extends StatelessWidget {
  const ServerCircle({
    super.key,
    required this.label,
    required this.color,
  });

  final String label;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 48,
      height: 48,
      margin: const EdgeInsets.symmetric(
        horizontal: 12,
        vertical: 6,
      ),
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: color,
        shape: BoxShape.circle,
      ),
      child: Text(
        label,
        style: const TextStyle(
          color: DiscordColors.textPrimary,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }
}

ChannelSidebarMockを作る

次に、簡易的なチャンネル一覧を作ります。

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.sidebar,
      child: ListView(
        padding: const EdgeInsets.all(16),
        children: const [
          Text(
            'Flutter Lab',
            style: TextStyle(
              color: DiscordColors.textPrimary,
              fontSize: 16,
              fontWeight: FontWeight.bold,
            ),
          ),
          SizedBox(height: 24),
          Text(
            'TEXT CHANNELS',
            style: TextStyle(
              color: DiscordColors.textMuted,
              fontSize: 12,
              fontWeight: FontWeight.bold,
            ),
          ),
          SizedBox(height: 10),
          ChannelMockTile(name: 'general', selected: true),
          ChannelMockTile(name: 'flutter-ui'),
          ChannelMockTile(name: 'design-review'),
          SizedBox(height: 24),
          Text(
            'VOICE CHANNELS',
            style: TextStyle(
              color: DiscordColors.textMuted,
              fontSize: 12,
              fontWeight: FontWeight.bold,
            ),
          ),
          SizedBox(height: 10),
          ChannelMockTile(name: 'voice-lounge', voice: true),
          ChannelMockTile(name: 'pair-programming', voice: true),
        ],
      ),
    );
  }
}

class ChannelMockTile extends StatelessWidget {
  const ChannelMockTile({
    super.key,
    required this.name,
    this.selected = false,
    this.voice = false,
  });

  final String name;
  final bool selected;
  final bool voice;

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.symmetric(vertical: 2),
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
      decoration: BoxDecoration(
        color: selected ? DiscordColors.selected : Colors.transparent,
        borderRadius: BorderRadius.circular(6),
      ),
      child: Row(
        children: [
          Icon(
            voice ? Icons.volume_up_rounded : Icons.tag_rounded,
            color: selected
                ? DiscordColors.textPrimary
                : DiscordColors.textMuted,
            size: 20,
          ),
          const SizedBox(width: 8),
          Expanded(
            child: Text(
              name,
              overflow: TextOverflow.ellipsis,
              style: TextStyle(
                color: selected
                    ? DiscordColors.textPrimary
                    : DiscordColors.textMuted,
                fontSize: 15,
                fontWeight: FontWeight.w600,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

右側DrawerのMemberPanelを作る

右側Drawerには、メンバー一覧を入れます。

今回は簡易版として MemberPanelMock を作ります。

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.sidebar,
      child: ListView(
        padding: const EdgeInsets.all(16),
        children: const [
          Text(
            'ONLINE',
            style: TextStyle(
              color: DiscordColors.textMuted,
              fontSize: 12,
              fontWeight: FontWeight.bold,
            ),
          ),
          SizedBox(height: 12),
          MemberMockTile(name: 'flutter_dev', color: DiscordColors.blurple),
          MemberMockTile(name: 'mika_design', color: Color(0xFFEB459E)),
          MemberMockTile(name: 'flutter_bot', color: DiscordColors.green),
          SizedBox(height: 20),
          Text(
            'IDLE',
            style: TextStyle(
              color: DiscordColors.textMuted,
              fontSize: 12,
              fontWeight: FontWeight.bold,
            ),
          ),
          SizedBox(height: 12),
          MemberMockTile(name: 'code_senpai', color: DiscordColors.yellow),
        ],
      ),
    );
  }
}

class MemberMockTile extends StatelessWidget {
  const MemberMockTile({
    super.key,
    required this.name,
    required this.color,
  });

  final String name;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 7),
      child: Row(
        children: [
          CircleAvatar(
            radius: 17,
            backgroundColor: color,
            child: Text(
              name.substring(0, 2).toUpperCase(),
              style: const TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 12,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          const SizedBox(width: 12),
          Expanded(
            child: Text(
              name,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(
                color: DiscordColors.textSecondary,
                fontSize: 14,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

完成コード

ここまでをまとめた、スマホDrawer対応の練習コードです。

import 'package:flutter/material.dart';

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

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

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

class DiscordColors {
  static const Color appRail = Color(0xFF1E1F22);
  static const Color sidebar = Color(0xFF2B2D31);
  static const Color background = Color(0xFF313338);
  static const Color panel = Color(0xFF232428);
  static const Color input = Color(0xFF383A40);
  static const Color selected = Color(0xFF404249);

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

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

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final width = constraints.maxWidth;
        final isMobile = width < 760;

        if (isMobile) {
          return const MobileDiscordLayout(channelName: 'general');
        }

        return const DesktopDiscordLayoutMock();
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: DiscordColors.background,
      body: SafeArea(
        child: Row(
          children: const [
            SizedBox(width: 72, child: ServerRailMock()),
            SizedBox(width: 240, child: ChannelSidebarMock()),
            Expanded(
              child: Column(
                children: [
                  ChatTopBar(channelName: 'general'),
                  Expanded(child: ChatAreaMock()),
                ],
              ),
            ),
            SizedBox(width: 260, child: MemberPanelMock()),
          ],
        ),
      ),
    );
  }
}

class MobileDiscordLayout extends StatelessWidget {
  const MobileDiscordLayout({
    super.key,
    required this.channelName,
  });

  final String channelName;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: DiscordColors.background,
      drawerScrimColor: Colors.black54,
      drawer: SizedBox(
        width: MediaQuery.of(context).size.width * 0.9,
        child: const DrawerContent(),
      ),
      endDrawer: SizedBox(
        width: MediaQuery.of(context).size.width * 0.86,
        child: const MemberPanelMock(),
      ),
      body: SafeArea(
        child: Builder(
          builder: (context) {
            return Column(
              children: [
                ChatTopBar(
                  channelName: channelName,
                  onOpenMenu: () {
                    Scaffold.of(context).openDrawer();
                  },
                  onOpenMembers: () {
                    Scaffold.of(context).openEndDrawer();
                  },
                ),
                const Expanded(
                  child: ChatAreaMock(),
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.sidebar,
      child: Row(
        children: const [
          SizedBox(
            width: 72,
            child: ServerRailMock(),
          ),
          Expanded(
            child: ChannelSidebarMock(),
          ),
        ],
      ),
    );
  }
}

class ChatTopBar extends StatelessWidget {
  const ChatTopBar({
    super.key,
    required this.channelName,
    this.onOpenMenu,
    this.onOpenMembers,
  });

  final String channelName;
  final VoidCallback? onOpenMenu;
  final VoidCallback? onOpenMembers;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 52,
      color: DiscordColors.background,
      padding: const EdgeInsets.symmetric(horizontal: 10),
      child: Row(
        children: [
          if (onOpenMenu != null)
            IconButton(
              onPressed: onOpenMenu,
              icon: const Icon(
                Icons.menu_rounded,
                color: DiscordColors.textPrimary,
              ),
            ),
          const Icon(
            Icons.tag_rounded,
            color: DiscordColors.textMuted,
            size: 22,
          ),
          const SizedBox(width: 8),
          Expanded(
            child: Text(
              channelName,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 16,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          if (onOpenMembers != null)
            IconButton(
              onPressed: onOpenMembers,
              icon: const Icon(
                Icons.people_alt_rounded,
                color: DiscordColors.textPrimary,
              ),
            ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.background,
      child: Column(
        children: [
          Expanded(
            child: ListView(
              padding: const EdgeInsets.all(18),
              children: const [
                CircleAvatar(
                  radius: 32,
                  backgroundColor: DiscordColors.panel,
                  child: Icon(
                    Icons.tag_rounded,
                    color: DiscordColors.textPrimary,
                    size: 34,
                  ),
                ),
                SizedBox(height: 16),
                Text(
                  'Welcome to #general!',
                  style: TextStyle(
                    color: DiscordColors.textPrimary,
                    fontSize: 26,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                SizedBox(height: 8),
                Text(
                  'This is the start of the #general channel.',
                  style: TextStyle(
                    color: DiscordColors.textMuted,
                    fontSize: 14,
                  ),
                ),
                SizedBox(height: 24),
                Divider(color: Colors.white12),
                SizedBox(height: 20),
                ChatMessageMock(
                  name: 'mika_design',
                  body: 'スマホではDrawerに収納すると画面が見やすくなります。',
                  color: Color(0xFFEB459E),
                ),
                ChatMessageMock(
                  name: 'code_senpai',
                  body: 'PCでは4カラム、スマホではChatArea中心に切り替えます。',
                  color: DiscordColors.green,
                ),
              ],
            ),
          ),
          Container(
            margin: const EdgeInsets.fromLTRB(12, 0, 12, 14),
            height: 46,
            padding: const EdgeInsets.symmetric(horizontal: 12),
            decoration: BoxDecoration(
              color: DiscordColors.input,
              borderRadius: BorderRadius.circular(8),
            ),
            child: const Row(
              children: [
                Icon(
                  Icons.add_circle_rounded,
                  color: DiscordColors.textMuted,
                ),
                SizedBox(width: 10),
                Expanded(
                  child: Text(
                    'Message #general',
                    style: TextStyle(
                      color: DiscordColors.textMuted,
                      fontSize: 15,
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class ChatMessageMock extends StatelessWidget {
  const ChatMessageMock({
    super.key,
    required this.name,
    required this.body,
    required this.color,
  });

  final String name;
  final String body;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 18),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          CircleAvatar(
            backgroundColor: color,
            child: Text(
              name.substring(0, 2).toUpperCase(),
              style: const TextStyle(
                color: DiscordColors.textPrimary,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  name,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    color: DiscordColors.textPrimary,
                    fontWeight: FontWeight.bold,
                    fontSize: 15,
                  ),
                ),
                const SizedBox(height: 4),
                Text(
                  body,
                  style: const TextStyle(
                    color: DiscordColors.textSecondary,
                    height: 1.4,
                    fontSize: 15,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.appRail,
      child: ListView(
        padding: const EdgeInsets.symmetric(vertical: 10),
        children: const [
          ServerCircle(label: 'FL', color: DiscordColors.blurple),
          ServerCircle(label: 'UI', color: Color(0xFFEB459E)),
          ServerCircle(label: 'AI', color: DiscordColors.green),
          ServerCircle(label: '+', color: DiscordColors.green),
        ],
      ),
    );
  }
}

class ServerCircle extends StatelessWidget {
  const ServerCircle({
    super.key,
    required this.label,
    required this.color,
  });

  final String label;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 48,
      height: 48,
      margin: const EdgeInsets.symmetric(
        horizontal: 12,
        vertical: 6,
      ),
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: color,
        shape: BoxShape.circle,
      ),
      child: Text(
        label,
        style: const TextStyle(
          color: DiscordColors.textPrimary,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.sidebar,
      child: ListView(
        padding: const EdgeInsets.all(16),
        children: const [
          Text(
            'Flutter Lab',
            overflow: TextOverflow.ellipsis,
            style: TextStyle(
              color: DiscordColors.textPrimary,
              fontSize: 16,
              fontWeight: FontWeight.bold,
            ),
          ),
          SizedBox(height: 24),
          Text(
            'TEXT CHANNELS',
            style: TextStyle(
              color: DiscordColors.textMuted,
              fontSize: 12,
              fontWeight: FontWeight.bold,
            ),
          ),
          SizedBox(height: 10),
          ChannelMockTile(name: 'general', selected: true),
          ChannelMockTile(name: 'flutter-ui'),
          ChannelMockTile(name: 'design-review'),
          SizedBox(height: 24),
          Text(
            'VOICE CHANNELS',
            style: TextStyle(
              color: DiscordColors.textMuted,
              fontSize: 12,
              fontWeight: FontWeight.bold,
            ),
          ),
          SizedBox(height: 10),
          ChannelMockTile(name: 'voice-lounge', voice: true),
          ChannelMockTile(name: 'pair-programming', voice: true),
        ],
      ),
    );
  }
}

class ChannelMockTile extends StatelessWidget {
  const ChannelMockTile({
    super.key,
    required this.name,
    this.selected = false,
    this.voice = false,
  });

  final String name;
  final bool selected;
  final bool voice;

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.symmetric(vertical: 2),
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
      decoration: BoxDecoration(
        color: selected ? DiscordColors.selected : Colors.transparent,
        borderRadius: BorderRadius.circular(6),
      ),
      child: Row(
        children: [
          Icon(
            voice ? Icons.volume_up_rounded : Icons.tag_rounded,
            color: selected
                ? DiscordColors.textPrimary
                : DiscordColors.textMuted,
            size: 20,
          ),
          const SizedBox(width: 8),
          Expanded(
            child: Text(
              name,
              overflow: TextOverflow.ellipsis,
              style: TextStyle(
                color: selected
                    ? DiscordColors.textPrimary
                    : DiscordColors.textMuted,
                fontSize: 15,
                fontWeight: FontWeight.w600,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.sidebar,
      child: ListView(
        padding: const EdgeInsets.all(16),
        children: const [
          Text(
            'ONLINE',
            style: TextStyle(
              color: DiscordColors.textMuted,
              fontSize: 12,
              fontWeight: FontWeight.bold,
            ),
          ),
          SizedBox(height: 12),
          MemberMockTile(name: 'flutter_dev', color: DiscordColors.blurple),
          MemberMockTile(name: 'mika_design', color: Color(0xFFEB459E)),
          MemberMockTile(name: 'flutter_bot', color: DiscordColors.green),
          SizedBox(height: 20),
          Text(
            'IDLE',
            style: TextStyle(
              color: DiscordColors.textMuted,
              fontSize: 12,
              fontWeight: FontWeight.bold,
            ),
          ),
          SizedBox(height: 12),
          MemberMockTile(name: 'code_senpai', color: DiscordColors.yellow),
        ],
      ),
    );
  }
}

class MemberMockTile extends StatelessWidget {
  const MemberMockTile({
    super.key,
    required this.name,
    required this.color,
  });

  final String name;
  final Color color;

  @override
  Widget build(BuildContext context) {
    final label = name.length >= 2 ? name.substring(0, 2).toUpperCase() : name;

    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 7),
      child: Row(
        children: [
          CircleAvatar(
            radius: 17,
            backgroundColor: color,
            child: Text(
              label,
              style: const TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 12,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          const SizedBox(width: 12),
          Expanded(
            child: Text(
              name,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(
                color: DiscordColors.textSecondary,
                fontSize: 14,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

実行して確認すること

このコードを実行したら、画面幅を変えて確認してください。

PC幅では、次のように表示されます。

ServerRail | ChannelSidebar | ChatArea | MemberPanel

スマホ幅では、次のように表示されます。

ChatTopBar
ChatArea

スマホ幅で確認するポイントは、次の通りです。

左上のメニューアイコンを押す
↓
左Drawerが開く
↓
サーバー一覧とチャンネル一覧が表示される

右上のメンバーアイコンを押す
↓
右Drawerが開く
↓
メンバー一覧が表示される

これで、PCとスマホで画面の見せ方を変えられるようになります。

今回の処理を整理する

今回のスマホ対応の流れは、次の通りです。

LayoutBuilderで画面幅を見る
↓
width < 760 ならスマホ用レイアウト
↓
スマホではChatAreaをメイン表示
↓
ServerRailとChannelSidebarはdrawerに入れる
↓
MemberPanelはendDrawerに入れる

PCとスマホの違いは、部品そのものではなく、部品の置き場所です。

同じ部品
↓
PCでは横に並べる
↓
スマホではDrawerに収納する

これがレスポンシブUIの基本です。

手を動かす練習1:スマホ判定の幅を変える

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

final isMobile = width < 760;

これを 900 に変えてみましょう。

final isMobile = width < 900;

すると、より広い画面でもスマホ用レイアウトになります。

このように、切り替え幅を調整できます。

手を動かす練習2:Drawerの幅を変える

左Drawerの幅は、次のように指定しています。

width: MediaQuery.of(context).size.width * 0.9,

これを 0.8 に変えてみましょう。

width: MediaQuery.of(context).size.width * 0.8,

Drawerが少し狭くなります。

手を動かす練習3:右Drawerの幅を変える

右Drawerの幅は、次の部分です。

width: MediaQuery.of(context).size.width * 0.86,

これを 0.75 に変えてみましょう。

width: MediaQuery.of(context).size.width * 0.75,

右側のメンバー一覧が少しコンパクトになります。

手を動かす練習4:スマホでもメンバーアイコンを非表示にする

ChatTopBar の右側メンバーアイコンを消したい場合は、onOpenMembers を渡さなければ表示されません。

ChatTopBar(
  channelName: channelName,
  onOpenMenu: () {
    Scaffold.of(context).openDrawer();
  },
)

これで右側のメンバーアイコンは表示されません。

手を動かす練習5:Drawer内のチャンネルを増やす

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

ChannelMockTile(name: 'questions'),
ChannelMockTile(name: 'announcements'),

Drawer内のチャンネル一覧が増えます。

よくあるつまずき1:Scaffold.of(context).openDrawer() が動かない

Scaffold.of(context).openDrawer() が動かない場合は、Builder を使っているか確認してください。

body: SafeArea(
  child: Builder(
    builder: (context) {
      return Column(...);
    },
  ),
),

Scaffold.of(context) は、Scaffoldの内側のcontextから呼ぶ必要があります。

そのため、Builder で新しいcontextを作っています。

よくあるつまずき2:スマホで横にはみ出る

スマホで横にはみ出る場合は、次を確認してください。

Rowを使いすぎていないか
Expandedを入れているか
TextOverflow.ellipsisを使っているか
Drawer幅が広すぎないか

特に、テキストが長い場合は、次の指定が重要です。

overflow: TextOverflow.ellipsis,

よくあるつまずき3:Drawerの中身が縦に入りきらない

Drawerの中にたくさんのチャンネルやサーバーがある場合は、ListView を使います。

ListView(
  children: [...]
)

Column だけで作ると、縦に入りきらないときにoverflowすることがあります。

要素が増える可能性がある
↓
ListViewを使う

よくあるつまずき4:PC表示でもメニューアイコンが出る

PC表示でメニューアイコンを出したくない場合は、onOpenMenu を渡さないようにします。

ChatTopBar(channelName: 'general')

ChatTopBar 側では、次の条件で表示しています。

if (onOpenMenu != null)
  IconButton(...)

つまり、処理を渡したときだけボタンが出ます。

よくあるつまずき5:endDrawerが開かない

右側Drawerを開くには、次を使います。

Scaffold.of(context).openEndDrawer();

左側Drawerとは違います。

左Drawer
↓
openDrawer()

右Drawer
↓
openEndDrawer()

名前が似ているので注意してください。

この節の確認問題

確認問題1

スマホでサーバー一覧・チャンネル一覧を収納するために使ったWidgetは何ですか。

答え

Drawer です。

確認問題2

右側のメンバー一覧を収納するために使ったものは何ですか。

答え

endDrawer です。

確認問題3

画面幅によってPC表示とスマホ表示を切り替えるために使ったWidgetは何ですか。

答え

LayoutBuilder です。

確認問題4

画面幅を取得するために使ったものは何ですか。

答え

constraints.maxWidth または MediaQuery.of(context).size.width です。

確認問題5

左Drawerを開くための処理は何ですか。

答え

Scaffold.of(context).openDrawer() です。

確認問題6

右Drawerを開くための処理は何ですか。

答え

Scaffold.of(context).openEndDrawer() です。

確認問題7

Drawerの中身が縦に長くなる場合に使うべきWidgetは何ですか。

答え

ListView です。

この節のまとめ

この節では、Discord風アプリをスマホ対応させるために、Drawerを使ってサーバー一覧・チャンネル一覧を収納しました。

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

PCでは4カラム表示
↓
スマホでは横幅が足りない
↓
ChatAreaをメインに表示する
↓
ServerRailとChannelSidebarはdrawerに収納する
↓
MemberPanelはendDrawerに収納する

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

MobileDiscordLayout
DrawerContent
ChatTopBar
Drawer
endDrawer

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

スマホ対応では、PCの画面をそのまま小さくするのではなく、優先度の高い画面だけを残し、補助的な情報はDrawerに収納する。

次の節では、ここまで作ってきた部品を統合し、Discord風チャットアプリ全体の完成コードを読み解いていきます。

教材トップへ戻る