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

【UIの土台作り】MaterialApp・Scaffold・SafeAreaでアプリ画面を整える

この節で学ぶこと

前回の 5-2 では、Flutterの基本レイアウトである RowColumn を使って、Discord風アプリの4カラム構造を作りました。

Row
├─ ServerRail
├─ ChannelSidebar
├─ ChatArea
└─ MemberPanel

今回の 5-3 では、その画面をFlutterアプリとして整えるために、次の3つを学びます。

MaterialApp
Scaffold
SafeArea

この3つは、Flutterアプリを作るときの土台になるWidgetです。

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

MaterialAppはアプリ全体の土台、Scaffoldは1画面の土台、SafeAreaは安全に表示するための余白を作る。

なぜ土台を整える必要があるのか

前回のコードでは、すでに画面を表示できました。

しかし、実際のアプリでは、ただWidgetを並べるだけでは不十分です。

たとえば、次のようなことを考える必要があります。

アプリ全体のテーマをどうするか
画面の背景色をどうするか
スマホのノッチやステータスバーに重ならないか
デバッグバナーを表示するか
画面全体の基本構造をどう作るか

これらを整えるために、Flutterでは MaterialAppScaffoldSafeArea を使います。

今回作る構造

この節では、前回作った4カラム構造を、次のようなアプリの土台の中に入れます。

DiscordLayoutPracticeApp
└─ MaterialApp
   └─ DiscordHomePage
      └─ Scaffold
         └─ SafeArea
            └─ Row
               ├─ ServerRail
               ├─ ChannelSidebar
               ├─ ChatArea
               └─ MemberPanel

この構造を見ると、Row よりも外側に、いくつかの大きな土台があることが分かります。

MaterialApp
↓
Scaffold
↓
SafeArea
↓
Row

順番に理解していきましょう。

MaterialAppとは何か

MaterialApp は、Flutterアプリ全体の設定をするWidgetです。

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

MaterialApp = アプリ全体の入口と基本設定をまとめるWidget

たとえば、次のような設定を行います。

最初に表示する画面
アプリのタイトル
テーマ
デバッグバナーを表示するかどうか
画面遷移の仕組み

今回のコードでは、次のように使います。

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: DiscordHomePage(),
    );
  }
}

ここで大切なのは、home です。

home: DiscordHomePage(),

これは、「最初に表示する画面は DiscordHomePage です」という意味です。

debugShowCheckedModeBannerとは何か

Flutterアプリを開発中に実行すると、右上に DEBUG という赤いラベルが出ることがあります。

それを消す設定が、次の部分です。

debugShowCheckedModeBanner: false,

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

debugShowCheckedModeBanner: false
↓
右上のDEBUG表示を消す

教材やUI確認のときは、見た目を確認しやすくするために false にしておくと便利です。

Scaffoldとは何か

Scaffold は、1画面の基本構造を作るWidgetです。

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

Scaffold = 1つの画面の土台を作るWidget

Scaffold には、次のような要素を入れられます。

appBar
body
drawer
bottomNavigationBar
floatingActionButton
backgroundColor

今回のDiscord風アプリでは、主に bodybackgroundColor を使います。

return const Scaffold(
  backgroundColor: DiscordColors.background,
  body: SafeArea(
    child: Row(
      children: [
        SizedBox(width: 72, child: ServerRail()),
        SizedBox(width: 240, child: ChannelSidebar()),
        Expanded(child: ChatArea()),
        SizedBox(width: 260, child: MemberPanel()),
      ],
    ),
  ),
);

ここで重要なのは、画面に表示したい中身を body に入れることです。

body: SafeArea(...)

Scaffoldのbodyとは何か

body は、画面のメイン部分です。

今回のDiscord風アプリでは、画面全体に4カラムUIを表示したいので、bodyRow を入れます。

Scaffold
└─ body
   └─ Row
      ├─ ServerRail
      ├─ ChannelSidebar
      ├─ ChatArea
      └─ MemberPanel

つまり、Scaffold は画面全体の入れ物で、その中のメイン表示部分が body です。

SafeAreaとは何か

SafeArea は、スマホのステータスバー、ノッチ、ホームインジケーターなどにUIが重ならないようにしてくれるWidgetです。

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

SafeArea = 画面の危ない部分を避けて、安全な範囲に表示するWidget

たとえば、スマホには上部にステータスバーがあります。

時刻・電波・バッテリー表示

また、機種によってはノッチがあります。

画面上部のカメラ部分

SafeAreaを使わないと、文字やボタンがその部分に重なってしまうことがあります。

そこで、次のように使います。

SafeArea(
  child: Row(
    children: [
      ...
    ],
  ),
)

これにより、画面の安全な範囲にUIを表示できます。

SafeAreaはPCでも必要なのか

PCだけで動かす場合、SafeAreaの効果を強く感じないかもしれません。

しかし、Flutterはスマホアプリも作れるため、最初から SafeArea を入れておくと安全です。

特に今回のように、後でスマホ対応も行うアプリでは、早めに入れておくとよいです。

PC表示
↓
あまり変化を感じないこともある

スマホ表示
↓
ステータスバーやノッチを避けられる

まず最小コードで土台を確認する

最初に、MaterialAppScaffoldSafeArea だけを使った最小コードを書いてみます。

DartPadまたはFlutterプロジェクトの main.dart に貼り付けてください。

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: DiscordBasePage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      backgroundColor: Color(0xFF313338),
      body: SafeArea(
        child: Center(
          child: Text(
            'Discord風アプリの土台',
            style: TextStyle(
              color: Colors.white,
              fontSize: 24,
              fontWeight: FontWeight.bold,
            ),
          ),
        ),
      ),
    );
  }
}

実行して確認すること

実行すると、暗い背景の中央に文字が表示されます。

このコードでは、まだ4カラム構造はありません。

しかし、次の3つの土台はできています。

MaterialApp
Scaffold
SafeArea

まずは、この3つの関係を理解しましょう。

MaterialApp
↓
アプリ全体

Scaffold
↓
1画面の土台

SafeArea
↓
安全な表示領域

MaterialApp・Scaffold・SafeAreaの関係

3つの役割を表にすると、次のようになります。

Widget役割今回の使い方
MaterialAppアプリ全体の設定最初の画面、テーマ、デバッグ表示
Scaffold1画面の土台背景色とbodyを指定
SafeArea安全な表示範囲を確保ステータスバーやノッチを避ける

この3つは、Flutterアプリを作るときに非常によく出てきます。

Discord風カラーをまとめる

次に、背景色などを毎回直接書くのではなく、DiscordColors としてまとめます。

class DiscordColors {
  static const Color appRail = Color(0xFF1E1F22);
  static const Color sidebar = Color(0xFF2B2D31);
  static const Color background = Color(0xFF313338);
  static const Color memberPanel = Color(0xFF232428);
  static const Color input = Color(0xFF383A40);
  static const Color textPrimary = Color(0xFFF2F3F5);
  static const Color textMuted = Color(0xFF949BA4);
}

色をまとめておくと、あとから見たときに意味が分かりやすくなります。

backgroundColor: DiscordColors.background

これは、次のように読めます。

背景色にはDiscordColors.backgroundを使う

Color(0xFF313338) と直接書くよりも、意味が分かりやすいです。

4カラム構造を土台に入れる

次に、前回作った4カラム構造を、MaterialAppScaffoldSafeArea の中に入れます。

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: DiscordBasePage(),
    );
  }
}

class DiscordColors {
  static const Color appRail = Color(0xFF1E1F22);
  static const Color sidebar = Color(0xFF2B2D31);
  static const Color background = Color(0xFF313338);
  static const Color memberPanel = Color(0xFF232428);
  static const Color input = Color(0xFF383A40);
  static const Color textPrimary = Color(0xFFF2F3F5);
  static const Color textMuted = Color(0xFF949BA4);
}

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      backgroundColor: DiscordColors.background,
      body: SafeArea(
        child: Row(
          children: [
            SizedBox(
              width: 72,
              child: ServerRail(),
            ),
            SizedBox(
              width: 240,
              child: ChannelSidebar(),
            ),
            Expanded(
              child: ChatArea(),
            ),
            SizedBox(
              width: 260,
              child: MemberPanel(),
            ),
          ],
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.appRail,
      child: Column(
        children: [
          const SizedBox(height: 12),
          _serverIcon('FL', Colors.indigoAccent),
          const SizedBox(height: 12),
          _serverIcon('UI', Colors.pinkAccent),
          const SizedBox(height: 12),
          _serverIcon('AI', Colors.green),
          const Spacer(),
          _serverIcon('+', Colors.green),
          const SizedBox(height: 12),
        ],
      ),
    );
  }

  Widget _serverIcon(String label, Color color) {
    return Container(
      width: 48,
      height: 48,
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: color,
        shape: BoxShape.circle,
      ),
      child: Text(
        label,
        style: const TextStyle(
          color: Colors.white,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.sidebar,
      child: const Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(height: 16),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text(
              'Flutter Lab',
              style: TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 16,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          SizedBox(height: 24),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text(
              'TEXT CHANNELS',
              style: TextStyle(
                color: DiscordColors.textMuted,
                fontSize: 12,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          SizedBox(height: 8),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text(
              '# general',
              style: TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 15,
              ),
            ),
          ),
          SizedBox(height: 8),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text(
              '# flutter-ui',
              style: TextStyle(
                color: DiscordColors.textMuted,
                fontSize: 15,
              ),
            ),
          ),
          SizedBox(height: 8),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text(
              '# design-review',
              style: TextStyle(
                color: DiscordColors.textMuted,
                fontSize: 15,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.background,
      child: Column(
        children: [
          Expanded(
            child: Container(
              padding: const EdgeInsets.all(24),
              alignment: Alignment.topLeft,
              child: const Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    'Welcome to #general!',
                    style: TextStyle(
                      color: DiscordColors.textPrimary,
                      fontSize: 28,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  SizedBox(height: 8),
                  Text(
                    'This is the start of the #general channel.',
                    style: TextStyle(
                      color: DiscordColors.textMuted,
                      fontSize: 14,
                    ),
                  ),
                  SizedBox(height: 32),
                  Text(
                    'mika_design: Discord風UIをFlutterで作ってみましょう。',
                    style: TextStyle(
                      color: DiscordColors.textPrimary,
                      fontSize: 15,
                    ),
                  ),
                  SizedBox(height: 12),
                  Text(
                    'code_senpai: RowとColumnだけでも大枠は作れます。',
                    style: TextStyle(
                      color: DiscordColors.textPrimary,
                      fontSize: 15,
                    ),
                  ),
                ],
              ),
            ),
          ),
          Container(
            height: 64,
            padding: const EdgeInsets.all(12),
            child: Container(
              alignment: Alignment.centerLeft,
              padding: const EdgeInsets.symmetric(horizontal: 16),
              decoration: const BoxDecoration(
                color: DiscordColors.input,
                borderRadius: BorderRadius.all(Radius.circular(8)),
              ),
              child: const Text(
                'Message #general',
                style: TextStyle(
                  color: DiscordColors.textMuted,
                  fontSize: 15,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.memberPanel,
      child: const Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'ONLINE',
              style: TextStyle(
                color: DiscordColors.textMuted,
                fontSize: 12,
                fontWeight: FontWeight.bold,
              ),
            ),
            SizedBox(height: 12),
            Text(
              '🟢 flutter_dev',
              style: TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 15,
              ),
            ),
            SizedBox(height: 10),
            Text(
              '🟢 mika_design',
              style: TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 15,
              ),
            ),
            SizedBox(height: 10),
            Text(
              '🟡 code_senpai',
              style: TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 15,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

実行して確認すること

このコードを実行すると、前回作った4カラム構造が、Flutterアプリの土台の中にきれいに入ります。

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

MaterialAppがアプリ全体を包んでいる
Scaffoldが1画面の土台になっている
SafeAreaの中に4カラムUIが入っている

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

MaterialApp
└─ Scaffold
   └─ SafeArea
      └─ Row
         ├─ ServerRail
         ├─ ChannelSidebar
         ├─ ChatArea
         └─ MemberPanel

この構造が、今後のDiscord風アプリの基本になります。

ThemeDataでアプリ全体の雰囲気を整える

次に、MaterialApptheme を追加します。

theme は、アプリ全体の見た目の設定です。

たとえば、明るいテーマ・暗いテーマ・基本色・フォントなどを指定できます。

theme: ThemeData(
  useMaterial3: true,
  brightness: Brightness.dark,
  scaffoldBackgroundColor: DiscordColors.background,
),

これを入れると、アプリ全体をダークテーマとして扱いやすくなります。

ThemeDataを入れたMaterialApp

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Discord Layout Practice',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.dark,
        scaffoldBackgroundColor: DiscordColors.background,
        colorScheme: ColorScheme.fromSeed(
          seedColor: Color(0xFF5865F2),
          brightness: Brightness.dark,
        ),
      ),
      home: const DiscordBasePage(),
    );
  }
}

ここでは、const MaterialApp ではなくなっています。

理由は、ThemeData(...)ColorScheme.fromSeed(...) を使っているためです。

ただし、homeDiscordBasePage() には const をつけられます。

titleとは何か

MaterialApp には、title を設定できます。

title: 'Discord Layout Practice',

これは、アプリの名前のようなものです。

Webやタスクスイッチャーなどで使われることがあります。

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

title = アプリ名の設定

UIそのものに大きく表示されるわけではありませんが、アプリ全体の設定として入れておくとよいです。

useMaterial3とは何か

次の設定があります。

useMaterial3: true,

これは、FlutterのMaterial Design 3を使う設定です。

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

useMaterial3: true
↓
新しいMaterial Designの見た目・仕組みを使う

ただし、今回のDiscord風UIでは、多くの見た目を自分で作るので、この設定だけでDiscord風になるわけではありません。

brightnessとは何か

次の設定があります。

brightness: Brightness.dark,

これは、アプリ全体を暗いテーマとして扱う設定です。

Discord風アプリはダークUIなので、Brightness.dark が自然です。

Brightness.light
↓
明るいテーマ

Brightness.dark
↓
暗いテーマ

scaffoldBackgroundColorとは何か

次の設定があります。

scaffoldBackgroundColor: DiscordColors.background,

これは、Scaffold の基本背景色を指定する設定です。

もちろん、Scaffold 側でも次のように書けます。

backgroundColor: DiscordColors.background

ただし、アプリ全体で同じ背景色を使う場合は、ThemeData に入れておくと便利です。

SafeAreaを外すとどうなるか

試しに、SafeArea を外すとどうなるか考えてみましょう。

今のコードは次のようになっています。

body: SafeArea(
  child: Row(
    children: [
      ...
    ],
  ),
),

もしSafeAreaを外すと、次のようになります。

body: Row(
  children: [
    ...
  ],
),

PCでは大きな違いがないかもしれません。

しかし、スマホでは上部のステータスバーやノッチとUIが重なる可能性があります。

そのため、スマホ対応も考えるアプリでは、基本的に SafeArea を使うのが安全です。

さらにDiscord風にするためにAppBarは使わない

Scaffold には appBar を指定できます。

Scaffold(
  appBar: AppBar(
    title: Text('Discord'),
  ),
  body: ...
)

しかし、Discord風UIでは、一般的な上部AppBarよりも、独自のチャンネルバーを作るほうが自然です。

そのため、今回は appBar は使いません。

代わりに、後の節で ChatTopBar という独自の上部バーを作ります。

一般的なFlutterアプリ
↓
ScaffoldのappBarを使う

Discord風アプリ
↓
bodyの中に独自のChatTopBarを作る

このように、再現したいUIによって、Scaffoldの使い方も変わります。

ここまでの完成コード

今回の内容を整理したコードです。

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Discord Layout Practice',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.dark,
        scaffoldBackgroundColor: DiscordColors.background,
        colorScheme: ColorScheme.fromSeed(
          seedColor: DiscordColors.blurple,
          brightness: Brightness.dark,
        ),
      ),
      home: const DiscordBasePage(),
    );
  }
}

class DiscordColors {
  static const Color appRail = Color(0xFF1E1F22);
  static const Color sidebar = Color(0xFF2B2D31);
  static const Color background = Color(0xFF313338);
  static const Color memberPanel = Color(0xFF232428);
  static const Color input = Color(0xFF383A40);
  static const Color textPrimary = Color(0xFFF2F3F5);
  static const Color textMuted = Color(0xFF949BA4);
  static const Color blurple = Color(0xFF5865F2);
}

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      backgroundColor: DiscordColors.background,
      body: SafeArea(
        child: Row(
          children: [
            SizedBox(
              width: 72,
              child: ServerRail(),
            ),
            SizedBox(
              width: 240,
              child: ChannelSidebar(),
            ),
            Expanded(
              child: ChatArea(),
            ),
            SizedBox(
              width: 260,
              child: MemberPanel(),
            ),
          ],
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.appRail,
      child: Column(
        children: [
          const SizedBox(height: 12),
          _serverIcon('FL', Colors.indigoAccent),
          const SizedBox(height: 12),
          _serverIcon('UI', Colors.pinkAccent),
          const SizedBox(height: 12),
          _serverIcon('AI', Colors.green),
          const Spacer(),
          _serverIcon('+', Colors.green),
          const SizedBox(height: 12),
        ],
      ),
    );
  }

  Widget _serverIcon(String label, Color color) {
    return Container(
      width: 48,
      height: 48,
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: color,
        shape: BoxShape.circle,
      ),
      child: Text(
        label,
        style: const TextStyle(
          color: Colors.white,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.sidebar,
      child: const Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(height: 16),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text(
              'Flutter Lab',
              style: TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 16,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          SizedBox(height: 24),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text(
              'TEXT CHANNELS',
              style: TextStyle(
                color: DiscordColors.textMuted,
                fontSize: 12,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          SizedBox(height: 8),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text(
              '# general',
              style: TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 15,
              ),
            ),
          ),
          SizedBox(height: 8),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text(
              '# flutter-ui',
              style: TextStyle(
                color: DiscordColors.textMuted,
                fontSize: 15,
              ),
            ),
          ),
          SizedBox(height: 8),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text(
              '# design-review',
              style: TextStyle(
                color: DiscordColors.textMuted,
                fontSize: 15,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.background,
      child: Column(
        children: [
          Expanded(
            child: Container(
              padding: const EdgeInsets.all(24),
              alignment: Alignment.topLeft,
              child: const Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    'Welcome to #general!',
                    style: TextStyle(
                      color: DiscordColors.textPrimary,
                      fontSize: 28,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  SizedBox(height: 8),
                  Text(
                    'This is the start of the #general channel.',
                    style: TextStyle(
                      color: DiscordColors.textMuted,
                      fontSize: 14,
                    ),
                  ),
                  SizedBox(height: 32),
                  Text(
                    'mika_design: Discord風UIをFlutterで作ってみましょう。',
                    style: TextStyle(
                      color: DiscordColors.textPrimary,
                      fontSize: 15,
                    ),
                  ),
                  SizedBox(height: 12),
                  Text(
                    'code_senpai: RowとColumnだけでも大枠は作れます。',
                    style: TextStyle(
                      color: DiscordColors.textPrimary,
                      fontSize: 15,
                    ),
                  ),
                ],
              ),
            ),
          ),
          Container(
            height: 64,
            padding: const EdgeInsets.all(12),
            child: Container(
              alignment: Alignment.centerLeft,
              padding: const EdgeInsets.symmetric(horizontal: 16),
              decoration: const BoxDecoration(
                color: DiscordColors.input,
                borderRadius: BorderRadius.all(Radius.circular(8)),
              ),
              child: const Text(
                'Message #general',
                style: TextStyle(
                  color: DiscordColors.textMuted,
                  fontSize: 15,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: DiscordColors.memberPanel,
      child: const Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'ONLINE',
              style: TextStyle(
                color: DiscordColors.textMuted,
                fontSize: 12,
                fontWeight: FontWeight.bold,
              ),
            ),
            SizedBox(height: 12),
            Text(
              '🟢 flutter_dev',
              style: TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 15,
              ),
            ),
            SizedBox(height: 10),
            Text(
              '🟢 mika_design',
              style: TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 15,
              ),
            ),
            SizedBox(height: 10),
            Text(
              '🟡 code_senpai',
              style: TextStyle(
                color: DiscordColors.textPrimary,
                fontSize: 15,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

このコードで確認してほしいこと

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

右上のDEBUGバナーが消えている
画面全体がダークテーマになっている
4カラム構造がSafeAreaの中に入っている
中央のChatAreaが残り幅を使っている

そして、コードの外側から内側への流れを確認してください。

main
↓
runApp
↓
DiscordBaseApp
↓
MaterialApp
↓
DiscordBasePage
↓
Scaffold
↓
SafeArea
↓
Row
↓
4つの領域

この流れが、Flutterアプリの基本です。

手を動かす練習1:DEBUGバナーを表示してみる

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

debugShowCheckedModeBanner: false,

これを一度 true にしてみます。

debugShowCheckedModeBanner: true,

右上にDEBUGバナーが表示されます。

確認できたら、また false に戻してください。

手を動かす練習2:背景色を変えてみる

DiscordColors.background を探してください。

static const Color background = Color(0xFF313338);

これを少し明るい色に変えてみます。

static const Color background = Color(0xFF3A3D44);

チャット画面の背景が少し変わります。

この練習で、色をclassにまとめるメリットが分かります。

手を動かす練習3:SafeAreaを外してみる

一度、次の SafeArea を外してみてください。

body: SafeArea(
  child: Row(
    children: [
      ...
    ],
  ),
),

次のように変更します。

body: Row(
  children: [
    ...
  ],
),

PCではあまり違いがないかもしれません。

しかし、スマホ表示ではステータスバーに重なる可能性があります。

確認したら、また SafeArea を戻してください。

手を動かす練習4:Scaffoldの背景色を変えてみる

Scaffold の背景色を変更してみましょう。

backgroundColor: DiscordColors.background,

たとえば、次のようにします。

backgroundColor: Colors.black,

ただし、今回の画面では各領域が背景色を持っているため、変化が分かりにくい場合があります。

これは、子Widgetの Container がそれぞれ色を持っているためです。

Scaffoldの背景
↓
画面全体の下地

Containerの背景
↓
各領域の色

よくあるつまずき1:MaterialAppとScaffoldの違いが分からない

初心者のうちは、MaterialAppScaffold の違いが分かりにくいです。

次のように覚えると分かりやすいです。

MaterialApp
↓
アプリ全体の設定

Scaffold
↓
1つの画面の土台

アプリには複数の画面を作ることがあります。

そのとき、MaterialApp はアプリ全体を包み、各画面ごとに Scaffold を使うイメージです。

よくあるつまずき2:SafeAreaをどこに置くか分からない

基本的には、Scaffoldbody の中に置くと分かりやすいです。

Scaffold(
  body: SafeArea(
    child: ...
  ),
)

今回のように、画面全体のUIを安全な領域に入れたい場合は、この形で十分です。

よくあるつまずき3:constがつけられない

MaterialAppThemeData を入れると、const MaterialApp にできない場合があります。

return MaterialApp(
  theme: ThemeData(...),
  home: const DiscordBasePage(),
);

この場合は、MaterialApp の前の const を外せば大丈夫です。

一方、DiscordBasePage() のように固定で作れるWidgetには const をつけられます。

constでエラーになる
↓
無理につけなくてよい

つけられる場所
↓
つけるとパフォーマンスや読みやすさに少し良い

よくあるつまずき4:ScaffoldのappBarを使うべきか迷う

Flutterでは、画面上部にバーを作るときに appBar を使うことがあります。

しかし、Discord風UIのように独自デザインの上部バーを作りたい場合は、body の中に自分で作ることも多いです。

普通のアプリ
↓
ScaffoldのappBar

Discord風UI
↓
bodyの中でChatTopBarを作る

今回の教材では、Discord風の見た目を優先するため、appBar は使わず、後で独自の上部バーを作ります。

この節の確認問題

確認問題1

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

答え

アプリ全体の設定をするために使います。最初に表示する画面、テーマ、タイトル、デバッグバナーなどを設定できます。

確認問題2

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

答え

1つの画面の土台を作るために使います。bodyappBardrawerbackgroundColor などを設定できます。

確認問題3

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

答え

スマホのステータスバー、ノッチ、ホームインジケーターなどにUIが重ならないように、安全な表示領域を確保するために使います。

確認問題4

debugShowCheckedModeBanner: false は何をしていますか。

答え

画面右上に表示されるDEBUGバナーを非表示にしています。

確認問題5

今回、ScaffoldappBar を使わない理由は何ですか。

答え

Discord風UIでは、一般的なAppBarではなく、独自デザインの上部バーを作るほうが自然だからです。

この節のまとめ

この節では、Flutterアプリの土台となる MaterialAppScaffoldSafeArea を学びました。

それぞれの役割は、次の通りです。

MaterialApp
↓
アプリ全体の設定

Scaffold
↓
1画面の土台

SafeArea
↓
安全な表示範囲の確保

Discord風アプリの4カラム構造は、次のように土台の中に入れました。

MaterialApp
└─ Scaffold
   └─ SafeArea
      └─ Row
         ├─ ServerRail
         ├─ ChannelSidebar
         ├─ ChatArea
         └─ MemberPanel

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

Flutterアプリは、MaterialAppで全体を作り、Scaffoldで1画面を作り、そのbodyの中に実際のUIを組み立てる。

次の節では、Discord風のダークUIをより作りやすくするために、色・余白・角丸などの見た目のルールを整理していきます。

教材トップへ戻る