
【Discord風カラー設計】ダークUIの色・余白・角丸を定数で管理する
この節で学ぶこと
前回の 5-3 では、Flutterアプリの土台として MaterialApp、Scaffold、SafeArea を使いました。
MaterialApp
└─ Scaffold
└─ SafeArea
└─ Row
├─ ServerRail
├─ ChannelSidebar
├─ ChatArea
└─ MemberPanel
今回の 5-4 では、Discord風の見た目を作るために、色・余白・角丸を整理して管理する方法を学びます。
Discord風UIでは、ただ黒い背景にすればよいわけではありません。
左端のサーバー一覧、チャンネル一覧、チャット画面、メンバー一覧で、少しずつ違う暗い色を使うことで、画面に奥行きと区切りが生まれます。
この節で一番大切なのは、次の一文です。
UIの見た目は、色・余白・角丸をルール化すると、全体に統一感が出る。
なぜ色を整理する必要があるのか
前回までのコードでも、すでにDiscord風の色を使っていました。
たとえば、次のようなコードです。
color: Color(0xFF313338)
このように直接色を書くこともできます。
しかし、アプリが大きくなると、同じ色を何度も使うことになります。
color: Color(0xFF313338)
backgroundColor: Color(0xFF313338)
fillColor: Color(0xFF313338)
これでは、あとから色を変更したいときに大変です。
また、Color(0xFF313338) だけ見ても、それが何の色なのか分かりにくいです。
そこで、色に名前をつけて管理します。
color: DiscordColors.background
このように書くと、意味が分かりやすくなります。
DiscordColors.background
↓
Discord風UIの背景色
Discord風UIで使う主な色
今回のDiscord風アプリでは、主に次の色を使います。
| 色の名前 | 使う場所 | 役割 |
|---|---|---|
appRail | 左端のサーバー一覧 | 一番暗い背景 |
sidebar | チャンネル一覧 | 左サイドバーの背景 |
background | チャット画面 | メイン背景 |
panel | メンバー一覧、プロフィールカード | パネル背景 |
input | メッセージ入力欄 | 入力エリア背景 |
hover | hover時の背景 | マウスを重ねたときの色 |
selected | 選択中チャンネル | 選択状態の背景 |
textPrimary | 重要な文字 | 白に近い文字 |
textSecondary | 通常の説明文字 | 少し薄い文字 |
textMuted | 補助的な文字 | さらに薄い文字 |
blurple | ボタン・アクセント | Discord風の青紫 |
green | オンライン表示 | 緑 |
yellow | 離席中表示 | 黄色 |
red | 取り込み中・警告 | 赤 |
Discord風UIでは、暗い色をいくつも使い分けることが大切です。
全部同じ黒
↓
平面的で見づらい
少しずつ違う暗い色
↓
領域の違いが分かりやすい
DiscordColors classを作る
まずは、色をまとめる DiscordColors classを作ります。
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 hover = Color(0xFF35373C);
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 green = Color(0xFF23A559);
static const Color red = Color(0xFFF23F42);
static const Color yellow = Color(0xFFF0B232);
static const Color blurple = Color(0xFF5865F2);
}
このように、色を1か所にまとめておくと、後からデザインを調整しやすくなります。
static constとは何か
ここで、新しい言葉が出てきました。
static const Color appRail = Color(0xFF1E1F22);
初心者にとって、static と const は少し分かりにくいかもしれません。
まず、const は「変わらない値」という意味です。
const
↓
あとから変更しない固定値
色はアプリの中で基本的に変えないため、const にできます。
次に、static は「classから直接使える」という意味です。
DiscordColors.appRail
これは、DiscordColors というclassから、直接 appRail を取り出しているという意味です。
初心者向けには、次のように理解してください。
static const
↓
アプリ全体で共通して使う固定値
Color(0xFF…)の意味
Flutterでは、色を次のように書くことがあります。
Color(0xFF313338)
これは、16進数で色を指定しています。
0xFF313338
このうち、最初の FF は透明度です。
FF
↓
不透明
313338
↓
色そのもの
つまり、Color(0xFF313338) は、「透明ではない、暗いグレー系の色」です。
初心者のうちは、細かい仕組みを完全に覚えなくても大丈夫です。
まずは、次のように理解してください。
Color(0xFF〇〇〇〇〇〇)
↓
Flutterで色を指定する書き方
色を直接書く場合と定数で書く場合
たとえば、左端のサーバー一覧に背景色をつけたいとします。
直接書くなら、こうです。
Container(
color: Color(0xFF1E1F22),
)
定数で書くなら、こうです。
Container(
color: DiscordColors.appRail,
)
見た目は同じです。
しかし、読みやすさが違います。
Color(0xFF1E1F22)
↓
何の色か分かりにくい
DiscordColors.appRail
↓
サーバー一覧の背景色だと分かりやすい
教材や実務では、後者のように名前をつけて管理するほうが安全です。
Discord風の4領域に色を当てる
前回の4カラム構造に、色を割り当てると次のようになります。
| 領域 | 色 |
|---|---|
| サーバー一覧 | DiscordColors.appRail |
| チャンネル一覧 | DiscordColors.sidebar |
| チャット画面 | DiscordColors.background |
| メンバー一覧 | DiscordColors.panel |
コードでは、次のように使います。

class ServerRail extends StatelessWidget {
const ServerRail({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: DiscordColors.appRail,
child: const Center(
child: Text('Server'),
),
);
}
}
class ChannelSidebar extends StatelessWidget {
const ChannelSidebar({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: DiscordColors.sidebar,
child: const Center(
child: Text('Channels'),
),
);
}
}
class ChatArea extends StatelessWidget {
const ChatArea({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: DiscordColors.background,
child: const Center(
child: Text('Chat Area'),
),
);
}
}
class MemberPanel extends StatelessWidget {
const MemberPanel({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: DiscordColors.panel,
child: const Center(
child: Text('Members'),
),
);
}
}
このように、領域ごとに役割のある色を指定します。
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 panel = Color(0xFF232428);
static const Color input = Color(0xFF383A40);
static const Color hover = Color(0xFF35373C);
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 green = Color(0xFF23A559);
static const Color red = Color(0xFFF23F42);
static const Color yellow = Color(0xFFF0B232);
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: const Center(
child: Text('Server'),
),
);
}
}
class ChannelSidebar extends StatelessWidget {
const ChannelSidebar({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: DiscordColors.sidebar,
child: const Center(
child: Text('Channels'),
),
);
}
}
class ChatArea extends StatelessWidget {
const ChatArea({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: DiscordColors.background,
child: const Center(
child: Text('Chat Area'),
),
);
}
}
class MemberPanel extends StatelessWidget {
const MemberPanel({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: DiscordColors.panel,
child: const Center(
child: Text('Members'),
),
);
}
}
テキスト色も分ける
Discord風UIでは、文字色も重要です。
すべての文字を真っ白にすると、画面が強くなりすぎます。
そこで、重要度に応じて文字色を分けます。
| 文字色 | 使う場所 |
|---|---|
textPrimary | チャンネル名、ユーザー名、重要な見出し |
textSecondary | 通常の本文、メッセージ本文 |
textMuted | 補助テキスト、カテゴリ名、時刻 |
たとえば、チャンネル名はやや強く表示します。
Text(
'# general',
style: TextStyle(
color: DiscordColors.textPrimary,
fontSize: 15,
fontWeight: FontWeight.bold,
),
)
補助的なカテゴリ名は、少し薄くします。
Text(
'TEXT CHANNELS',
style: TextStyle(
color: DiscordColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.bold,
),
)
これにより、情報の強弱が分かりやすくなります。
余白も定数で管理する
色と同じように、余白もバラバラに書くと統一感がなくなります。
たとえば、ある場所では 8、別の場所では 13、また別の場所では 19 のようにすると、見た目が揃いにくくなります。
そこで、余白も名前をつけて管理します。
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;
}
使うときは、次のように書きます。
const SizedBox(height: DiscordSpacing.md)
または、Paddingにも使えます。
const Padding(
padding: EdgeInsets.all(DiscordSpacing.lg),
child: Text('Flutter Lab'),
)
このようにすると、余白のルールが整理されます。
角丸も定数で管理する
Discord風UIでは、サーバーアイコンやチャンネル選択背景、入力欄などに角丸が使われます。
角丸も、適当に数値を入れるのではなく、ルール化すると統一感が出ます。
class DiscordRadius {
static const double sm = 6;
static const double md = 8;
static const double lg = 12;
static const double circle = 999;
}
使うときは、次のように書きます。
BorderRadius.circular(DiscordRadius.md)
サーバーアイコンのように丸くしたい場合は、BoxShape.circle を使うこともあります。
BoxDecoration(
color: DiscordColors.blurple,
shape: BoxShape.circle,
)
角丸は、UIの印象を大きく変えます。
角丸が少ない
↓
硬い印象
角丸が多い
↓
やわらかい印象
Discord風UI
↓
小さな角丸と丸いアイコンを組み合わせる
今回追加するデザイン定数
この節では、次の3つの定数classを作ります。
DiscordColors
DiscordSpacing
DiscordRadius
それぞれの役割は次の通りです。
| class名 | 役割 |
|---|---|
DiscordColors | 色をまとめる |
DiscordSpacing | 余白をまとめる |
DiscordRadius | 角丸をまとめる |
この3つを使うことで、アプリ全体の見た目を統一しやすくなります。
まず最小コードで色・余白・角丸を確認する

次のコードを main.dart に貼り付けて実行してください。
import 'package:flutter/material.dart';
void main() {
runApp(const DiscordStylePracticeApp());
}
class DiscordStylePracticeApp extends StatelessWidget {
const DiscordStylePracticeApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Discord Style Practice',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: DiscordColors.background,
),
home: const DiscordStylePracticePage(),
);
}
}
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 hover = Color(0xFF35373C);
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 green = Color(0xFF23A559);
static const Color red = Color(0xFFF23F42);
static const Color yellow = Color(0xFFF0B232);
static const Color blurple = Color(0xFF5865F2);
}
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 sm = 6;
static const double md = 8;
static const double lg = 12;
static const double circle = 999;
}
class DiscordStylePracticePage extends StatelessWidget {
const DiscordStylePracticePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: DiscordColors.background,
body: SafeArea(
child: Center(
child: Container(
width: 320,
padding: const EdgeInsets.all(DiscordSpacing.lg),
decoration: BoxDecoration(
color: DiscordColors.panel,
borderRadius: BorderRadius.circular(DiscordRadius.lg),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DiscordColors.blurple,
shape: BoxShape.circle,
),
child: const Text(
'FL',
style: TextStyle(
color: DiscordColors.textPrimary,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: DiscordSpacing.lg),
const Text(
'Flutter Lab',
style: TextStyle(
color: DiscordColors.textPrimary,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: DiscordSpacing.sm),
const Text(
'Discord風UIの色・余白・角丸を確認するカードです。',
textAlign: TextAlign.center,
style: TextStyle(
color: DiscordColors.textSecondary,
fontSize: 14,
height: 1.5,
),
),
const SizedBox(height: DiscordSpacing.lg),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: DiscordSpacing.md,
vertical: DiscordSpacing.sm,
),
decoration: BoxDecoration(
color: DiscordColors.input,
borderRadius: BorderRadius.circular(DiscordRadius.md),
),
child: const Text(
'Message #general',
style: TextStyle(
color: DiscordColors.textMuted,
fontSize: 14,
),
),
),
],
),
),
),
),
);
}
}
実行して確認すること
実行すると、Discord風のカードが表示されます。
確認してほしいポイントは、次の通りです。
背景が暗い
カードが少し違う暗い色で表示されている
丸いアイコンがある
文字色に強弱がある
入力欄風の角丸ボックスがある
余白が整っている
この段階では、まだチャットアプリではありません。
しかし、Discord風UIに必要な見た目のルールを確認できます。
4カラム構造にデザイン定数を使う

次に、前回の4カラム構造に、今回のデザイン定数を反映します。
次のコードは、DiscordColors、DiscordSpacing、DiscordRadius を使った4カラムの土台です。
import 'package:flutter/material.dart';
void main() {
runApp(const DiscordStyleLayoutApp());
}
class DiscordStyleLayoutApp extends StatelessWidget {
const DiscordStyleLayoutApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Discord Style Layout',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: DiscordColors.background,
colorScheme: ColorScheme.fromSeed(
seedColor: DiscordColors.blurple,
brightness: Brightness.dark,
),
),
home: const DiscordStyleLayoutPage(),
);
}
}
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 hover = Color(0xFF35373C);
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 green = Color(0xFF23A559);
static const Color red = Color(0xFFF23F42);
static const Color yellow = Color(0xFFF0B232);
static const Color blurple = Color(0xFF5865F2);
}
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 sm = 6;
static const double md = 8;
static const double lg = 12;
static const double circle = 999;
}
class DiscordStyleLayoutPage extends StatelessWidget {
const DiscordStyleLayoutPage({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: DiscordSpacing.md),
_serverIcon('FL', DiscordColors.blurple),
const SizedBox(height: DiscordSpacing.md),
_serverIcon('UI', const Color(0xFFEB459E)),
const SizedBox(height: DiscordSpacing.md),
_serverIcon('AI', DiscordColors.green),
const Spacer(),
_serverIcon('+', DiscordColors.green),
const SizedBox(height: DiscordSpacing.md),
],
),
);
}
Widget _serverIcon(String label, Color color) {
return Container(
width: 48,
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(DiscordRadius.circle),
),
child: Text(
label,
style: const TextStyle(
color: DiscordColors.textPrimary,
fontSize: 16,
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: DiscordSpacing.lg),
Padding(
padding: EdgeInsets.symmetric(horizontal: DiscordSpacing.lg),
child: Text(
'Flutter Lab',
style: TextStyle(
color: DiscordColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(height: DiscordSpacing.xl),
Padding(
padding: EdgeInsets.symmetric(horizontal: DiscordSpacing.lg),
child: Text(
'TEXT CHANNELS',
style: TextStyle(
color: DiscordColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(height: DiscordSpacing.sm),
ChannelLabel(
text: '# general',
selected: true,
),
ChannelLabel(
text: '# flutter-ui',
selected: false,
),
ChannelLabel(
text: '# design-review',
selected: false,
),
SizedBox(height: DiscordSpacing.xl),
Padding(
padding: EdgeInsets.symmetric(horizontal: DiscordSpacing.lg),
child: Text(
'VOICE CHANNELS',
style: TextStyle(
color: DiscordColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(height: DiscordSpacing.sm),
ChannelLabel(
text: '🔊 voice-lounge',
selected: false,
),
],
),
);
}
}
class ChannelLabel extends StatelessWidget {
const ChannelLabel({
super.key,
required this.text,
required this.selected,
});
final String text;
final bool selected;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(
horizontal: DiscordSpacing.sm,
vertical: DiscordSpacing.xs,
),
padding: const EdgeInsets.symmetric(
horizontal: DiscordSpacing.sm,
vertical: DiscordSpacing.sm,
),
decoration: BoxDecoration(
color: selected ? DiscordColors.selected : Colors.transparent,
borderRadius: BorderRadius.circular(DiscordRadius.sm),
),
child: Text(
text,
style: TextStyle(
color: selected
? DiscordColors.textPrimary
: DiscordColors.textMuted,
fontSize: 15,
fontWeight: selected ? FontWeight.bold : FontWeight.w500,
),
),
);
}
}
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(DiscordSpacing.xl),
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: DiscordSpacing.sm),
Text(
'This is the start of the #general channel.',
style: TextStyle(
color: DiscordColors.textMuted,
fontSize: 14,
),
),
SizedBox(height: DiscordSpacing.xl),
Text(
'mika_design: Discord風UIをFlutterで作ってみましょう。',
style: TextStyle(
color: DiscordColors.textSecondary,
fontSize: 15,
),
),
SizedBox(height: DiscordSpacing.md),
Text(
'code_senpai: 色・余白・角丸をまとめると、画面が整います。',
style: TextStyle(
color: DiscordColors.textSecondary,
fontSize: 15,
),
),
],
),
),
),
Container(
height: 64,
padding: const EdgeInsets.all(DiscordSpacing.md),
child: Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(
horizontal: DiscordSpacing.lg,
),
decoration: BoxDecoration(
color: DiscordColors.input,
borderRadius: BorderRadius.all(
Radius.circular(DiscordRadius.md),
),
),
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.panel,
child: const Padding(
padding: EdgeInsets.all(DiscordSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ONLINE',
style: TextStyle(
color: DiscordColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: DiscordSpacing.md),
MemberLabel(
name: 'flutter_dev',
statusColor: DiscordColors.green,
),
MemberLabel(
name: 'mika_design',
statusColor: DiscordColors.green,
),
MemberLabel(
name: 'flutter_bot',
statusColor: DiscordColors.green,
),
SizedBox(height: DiscordSpacing.xl),
Text(
'IDLE',
style: TextStyle(
color: DiscordColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: DiscordSpacing.md),
MemberLabel(
name: 'code_senpai',
statusColor: DiscordColors.yellow,
),
],
),
),
);
}
}
class MemberLabel extends StatelessWidget {
const MemberLabel({
super.key,
required this.name,
required this.statusColor,
});
final String name;
final Color statusColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: DiscordSpacing.md),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(DiscordRadius.circle),
),
),
const SizedBox(width: DiscordSpacing.sm),
Expanded(
child: Text(
name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: DiscordColors.textSecondary,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
コードのポイント
今回のコードでは、見た目に関係する値を次の3つに分けました。
DiscordColors
DiscordSpacing
DiscordRadius
たとえば、チャンネルの選択状態では、次のように使っています。
decoration: BoxDecoration(
color: selected ? DiscordColors.selected : Colors.transparent,
borderRadius: BorderRadius.circular(DiscordRadius.sm),
),
これは、次のように読めます。
選択中なら selected 色を使う
選択中でなければ透明
角丸は small のルールを使う
単なる見た目の指定ではなく、ルールに基づいてUIを作っています。
余白がUIの見やすさを決める
UIでは、色だけではなく余白もとても重要です。
たとえば、次のように文字同士が詰まりすぎると読みにくくなります。
TEXT CHANNELS
#general
#flutter-ui
#design-review
余白を入れると、読みやすくなります。
TEXT CHANNELS
# general
# flutter-ui
# design-review
Flutterでは、余白を作る方法がいくつかあります。
| Widget / property | 役割 |
|---|---|
SizedBox | 空間を作る |
Padding | 内側や周囲に余白を作る |
margin | 外側に余白を作る |
EdgeInsets.all | 全方向に同じ余白 |
EdgeInsets.symmetric | 上下・左右の余白 |
EdgeInsets.only | 特定方向だけ余白 |
今回のように DiscordSpacing を作っておくと、余白の数値が整理されます。
marginとpaddingの違い
ここで、margin と padding の違いを確認します。
margin
↓
Widgetの外側の余白
padding
↓
Widgetの内側の余白
たとえば、チャンネル表示では両方を使っています。
Container(
margin: const EdgeInsets.symmetric(
horizontal: DiscordSpacing.sm,
vertical: DiscordSpacing.xs,
),
padding: const EdgeInsets.symmetric(
horizontal: DiscordSpacing.sm,
vertical: DiscordSpacing.sm,
),
child: Text(text),
)
これは、次のような意味です。
margin
↓
チャンネル項目そのものの外側に余白
padding
↓
背景色の内側で文字との余白
選択中の背景をきれいに見せるには、padding が重要です。
角丸でDiscordらしさを出す
Discord風UIでは、角丸の使い方も大切です。
サーバーアイコンは丸く表示します。
borderRadius: BorderRadius.circular(DiscordRadius.circle)
チャンネル選択背景は、少しだけ角丸にします。
borderRadius: BorderRadius.circular(DiscordRadius.sm)
入力欄は、やや丸みを持たせます。
borderRadius: BorderRadius.circular(DiscordRadius.md)
このように、部品ごとに角丸の大きさを変えます。
サーバーアイコン
↓
丸い
チャンネル背景
↓
小さな角丸
入力欄
↓
中くらいの角丸
角丸を全部同じにするのではなく、役割に応じて変えると自然です。
小さな部品を作るとコードが読みやすくなる
今回のコードでは、次のような小さなWidgetを追加しました。
ChannelLabel
MemberLabel
ChannelLabel は、チャンネル1つ分の表示です。
ChannelLabel(
text: '# general',
selected: true,
)
MemberLabel は、メンバー1人分の表示です。
MemberLabel(
name: 'flutter_dev',
statusColor: DiscordColors.green,
)
このように小さなWidgetに分けると、同じようなコードを何度も書かなくて済みます。
同じ形のUI
↓
Widget化する
↓
値だけ変えて使い回す
これは、今後アプリが大きくなるほど重要になります。
手を動かす練習1:背景色を変えてみる
DiscordColors.background を探してください。
static const Color background = Color(0xFF313338);
これを少し明るくしてみます。
static const Color background = Color(0xFF3A3D44);
チャット画面の背景が変わります。
確認できたら、元に戻しても大丈夫です。
手を動かす練習2:選択中チャンネルの色を変えてみる
DiscordColors.selected を探してください。
static const Color selected = Color(0xFF404249);
これを少し青紫に寄せてみます。
static const Color selected = Color(0xFF454B78);
# general の背景色が変わります。
この練習で、定数を1か所変えるだけでUI全体の印象が変わることが分かります。
手を動かす練習3:余白を大きくしてみる
DiscordSpacing.lg を探してください。
static const double lg = 16;
これを 20 に変えてみます。
static const double lg = 20;
全体的に余白が広がります。
余白を変えるだけで、画面のゆったり感が変わります。
手を動かす練習4:角丸を変えてみる
DiscordRadius.md を探してください。
static const double md = 8;
これを 14 に変えてみます。
static const double md = 14;
入力欄の角丸が大きくなります。
Discord風から少し柔らかい印象になります。
このように、角丸はUIの印象に大きく影響します。
よくあるつまずき1:色を直接書いてしまう
最初は、次のように直接色を書いても動きます。
color: Color(0xFF313338)
しかし、アプリ全体で色が増えると管理が難しくなります。
なるべく次のように書きましょう。
color: DiscordColors.background
名前をつけることで、コードの意味が分かりやすくなります。
よくあるつまずき2:余白の数値がバラバラになる
UIを作っていると、なんとなく 7、11、19 のような数値を入れたくなることがあります。
しかし、余白の数値がバラバラだと、画面全体に統一感が出ません。
まずは、次のような基本単位で考えるとよいです。
4
8
12
16
24
今回の DiscordSpacing も、この考え方で作っています。
よくあるつまずき3:全部の文字を白くしてしまう
ダークUIでは、すべての文字を白にすると、強すぎる画面になります。
重要な文字だけを白に近くし、補助的な文字は少し薄くします。
重要な文字
↓
textPrimary
本文
↓
textSecondary
補助情報
↓
textMuted
文字色に強弱をつけると、画面が読みやすくなります。
よくあるつまずき4:角丸を大きくしすぎる
角丸を大きくすると、やわらかい印象になります。
しかし、Discord風UIでは、すべてを大きく丸くしすぎると雰囲気が変わってしまいます。
サーバーアイコン
↓
丸くしてよい
チャンネル選択背景
↓
小さめの角丸
入力欄
↓
中くらいの角丸
部品の役割に合わせて角丸を調整しましょう。
この節の確認問題
確認問題1
色を DiscordColors classにまとめる理由は何ですか。
答え
色の意味が分かりやすくなり、あとから変更しやすくなるためです。
確認問題2
static const は、初心者向けに言うとどのような意味ですか。
答え
アプリ全体で共通して使う固定値、という意味です。
確認問題3
DiscordSpacing は何を管理するためのclassですか。
答え
余白の数値を管理するためのclassです。
確認問題4
DiscordRadius は何を管理するためのclassですか。
答え
角丸の数値を管理するためのclassです。
確認問題5
textPrimary、textSecondary、textMuted を分ける理由は何ですか。
答え
文字の重要度に応じて見た目に強弱をつけ、読みやすくするためです。
確認問題6
margin と padding の違いは何ですか。
答え
margin はWidgetの外側の余白、padding はWidgetの内側の余白です。
この節のまとめ
この節では、Discord風UIの見た目を作るために、色・余白・角丸を定数として管理する方法を学びました。
今回作った定数classは、次の3つです。
DiscordColors
DiscordSpacing
DiscordRadius
それぞれの役割は次の通りです。
DiscordColors
↓
色を管理する
DiscordSpacing
↓
余白を管理する
DiscordRadius
↓
角丸を管理する
また、同じ形のUIを繰り返すために、次のような小さなWidgetも作りました。
ChannelLabel
MemberLabel
この節で一番大切なのは、次の考え方です。
見た目のルールを定数として整理すると、UI全体に統一感が出て、あとから修正しやすくなる。
次の節では、左端のDiscord風サーバーアイコンバーを作っていきます。