CONTENT
ここから
この節で学ぶこと
前回の 5-14 では、スマホでは画面幅が狭いため、サーバー一覧とチャンネル一覧を Drawer に収納する方法を学びました。
PC
↓
4カラム表示
スマホ
↓
ChatAreaを中心に表示
サーバー一覧・チャンネル一覧はDrawerへ
メンバー一覧はendDrawerへ
今回の 5-15 では、さらに一歩進めます。
PCだけ、スマホだけではなく、PC・タブレット・スマホ の3段階でレイアウトを切り替え、どの画面幅でも崩れにくいDiscord風UIを作ります。
今回の完成イメージは、次の通りです。
PC
ServerRail | ChannelSidebar | ChatArea | MemberPanel
タブレット
ServerRail | ChannelSidebar | ChatArea
スマホ
ChatArea
+ Drawer(ServerRail + ChannelSidebar)
+ endDrawer(MemberPanel)
この節で一番大切なのは、次の一文です。
レスポンシブUIでは、画面幅ごとに「何を常時表示し、何を省略し、何を収納するか」を決めることが重要である。
なぜ3段階で考えるのか
初心者のうちは、次のように考えがちです。
PC用
スマホ用
しかし実際には、その間にタブレット幅があります。
たとえば、横幅が広いiPadや小さめのノートPCでは、スマホ用のDrawer表示では窮屈で、PC用の4カラムでは広すぎたり詰まりすぎたりします。
そのため、今回は次の3段階で考えます。
| 画面サイズ | 表示方針 |
|---|---|
| PC | 4カラムを全部表示 |
| タブレット | メンバー欄を省略し、3カラム表示 |
| スマホ | ChatAreaだけを見せ、左右の情報はDrawerに収納 |
この考え方は、Discord風UIだけでなく、管理画面、チャットアプリ、業務アプリ全般で非常によく使います。
今回のレイアウト方針
今回のレスポンシブ設計では、次の基準を使います。
幅 1100px以上
↓
PC
幅 760px以上 1100px未満
↓
タブレット
幅 760px未満
↓
スマホ
この基準で、表示を切り替えます。
PC
↓
ServerRail + ChannelSidebar + ChatArea + MemberPanel
タブレット
↓
ServerRail + ChannelSidebar + ChatArea
スマホ
↓
ChatArea + Drawer + endDrawer
ここで大切なのは、部品を作り直すのではなく、配置を変える ことです。
同じ部品
↓
画面幅によって置き方を変える
今回使う主な部品
ここまでの章で作ってきた部品を、今回まとめて使います。
| 部品名 | 役割 |
|---|---|
ServerRail | 左端のサーバー一覧 |
ChannelSidebar | 左側のチャンネル一覧 |
ChatArea | 中央のチャット画面 |
MemberPanel | 右側のメンバー一覧 |
ChatTopBar | 上部バー |
MobileDiscordLayout | スマホ用レイアウト |
DesktopDiscordLayout | PC・タブレット用レイアウト |
今回の目標は、これらを1つのアプリとしてつなぐことです。
レスポンシブ対応の基本の流れ
今回の処理の流れは、次のようになります。
画面幅を取得する
↓
PCか、タブレットか、スマホか判定する
↓
その画面幅に合うレイアウトを返す
Flutterでは、これを LayoutBuilder で行います。
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
if (width < 760) {
return MobileDiscordLayout(...);
}
return DesktopDiscordLayout(...);
},
)
さらに、PCとタブレットの違いは DesktopDiscordLayout の中で切り替えます。
isTablet = width >= 760 && width < 1100
isDesktop = width >= 1100
LayoutBuilderを復習する
LayoutBuilder は、その場で使える最大幅を取得できるWidgetです。
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
...
},
)
初心者向けには、次のように理解してください。
LayoutBuilder
↓
今この画面で使える横幅を見て、表示内容を決めるWidget
これが、レスポンシブ対応の出発点です。
まずは最小の3段階切り替えを作る
最初に、切り替えの考え方だけを確認するための最小コードを作ります。
次のコードを main.dart に貼り付けてください。
import 'package:flutter/material.dart';
void main() {
runApp(const ResponsiveStepApp());
}
class ResponsiveStepApp extends StatelessWidget {
const ResponsiveStepApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Responsive Step App',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(),
home: const ResponsiveStepPage(),
);
}
}
class ResponsiveStepPage extends StatelessWidget {
const ResponsiveStepPage({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
if (width < 760) {
return const Scaffold(
body: Center(child: Text('スマホレイアウト')),
);
}
if (width < 1100) {
return const Scaffold(
body: Center(child: Text('タブレットレイアウト')),
);
}
return const Scaffold(
body: Center(child: Text('PCレイアウト')),
);
},
);
}
}
実行して確認すること
画面幅を変えて、次のように表示が切り替わることを確認してください。
760未満
↓
スマホレイアウト
760以上 1100未満
↓
タブレットレイアウト
1100以上
↓
PCレイアウト
この「幅で切り替える」考え方が、今回の土台になります。
DesktopDiscordLayoutを作る
次に、PC・タブレット用のレイアウトを作ります。
PCとタブレットでは、どちらも横並びですが、右側メンバー欄を出すかどうかが違います。
PC
↓
メンバー欄あり
タブレット
↓
メンバー欄なし
まず、構造を見てください。
DesktopDiscordLayout
└─ Row
├─ ServerRail
├─ ChannelSidebar
└─ Expanded
└─ Column
├─ ChatTopBar
└─ Expanded
└─ Row
├─ ChatArea
└─ MemberPanel (PCのみ)
コードは次のようになります。
class DesktopDiscordLayout extends StatelessWidget {
const DesktopDiscordLayout({
super.key,
required this.isTablet,
});
final bool isTablet;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: DiscordColors.background,
body: SafeArea(
child: Row(
children: [
const SizedBox(
width: 74,
child: ServerRailMock(),
),
SizedBox(
width: isTablet ? 230 : 250,
child: const ChannelSidebarMock(),
),
Expanded(
child: Column(
children: [
const ChatTopBar(channelName: 'general'),
Expanded(
child: Row(
children: [
const Expanded(
child: ChatAreaMock(),
),
if (!isTablet)
const SizedBox(
width: 260,
child: MemberPanelMock(),
),
],
),
),
],
),
),
],
),
),
);
}
}
isTablet を使う意味
ここでは、isTablet を受け取っています。
final bool isTablet;
この値によって、右側のメンバー欄を出すかどうかを切り替えています。
if (!isTablet)
const SizedBox(
width: 260,
child: MemberPanelMock(),
),
つまり、意味は次の通りです。
isTablet = true
↓
タブレット
↓
MemberPanelを表示しない
isTablet = false
↓
PC
↓
MemberPanelを表示する
また、チャンネル一覧の幅も少し変えています。
width: isTablet ? 230 : 250,
タブレットでは、少しだけ横幅を詰めています。
スマホ用のMobileDiscordLayoutを作る
スマホ用は、前回学んだDrawer収納です。
構造は次の通りです。
MobileDiscordLayout
├─ drawer
│ └─ ServerRail + ChannelSidebar
├─ endDrawer
│ └─ MemberPanel
└─ body
├─ ChatTopBar
└─ ChatArea
コードは次のようになります。
class MobileDiscordLayout extends StatelessWidget {
const MobileDiscordLayout({super.key});
@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: 'general',
onOpenMenu: () {
Scaffold.of(context).openDrawer();
},
onOpenMembers: () {
Scaffold.of(context).openEndDrawer();
},
),
const Expanded(
child: ChatAreaMock(),
),
],
);
},
),
),
);
}
}
ここでは、チャット画面を主役にしています。
常に見せる
↓
ChatArea
必要なときだけ見せる
↓
ServerRail + ChannelSidebar + MemberPanel
DrawerContentの役割
左Drawerには、サーバー一覧とチャンネル一覧を入れます。
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(),
),
],
),
);
}
}
ここでも、今まで作った部品を並べ直しているだけです。
PC
↓
常時横並び
スマホ
↓
Drawerの中で横並び
ChatTopBarをレスポンシブ対応させる
ChatTopBar は、PCとスマホで見た目を少し変えます。
PCでは、メニューアイコンは不要です。
スマホでは、左にメニュー、右にメンバー一覧ボタンが必要です。
そのため、VoidCallback? を使って、必要なときだけボタンを表示します。
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,
),
),
],
),
);
}
}
一覧系Mock部品を用意する
今回の教材では、レイアウト理解に集中するため、ServerRail、ChannelSidebar、ChatArea、MemberPanel は簡易版を使います。
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',
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,
),
),
),
],
),
);
}
}
ChatAreaMock
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: 'レスポンシブ対応では、画面幅ごとに役割を決めるのが大切です。',
color: Color(0xFFEB459E),
),
ChatMessageMock(
name: 'code_senpai',
body: 'PCでは横並び、スマホではDrawer収納に切り替えます。',
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,
),
),
],
),
),
],
),
);
}
}
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) {
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,
),
),
),
],
),
);
}
}
全体をつなぐ最終コード
ここまでの部品をつないだ最終コードです。
import 'package:flutter/material.dart';
void main() {
runApp(const DiscordResponsiveLessonApp());
}
class DiscordResponsiveLessonApp extends StatelessWidget {
const DiscordResponsiveLessonApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Discord Responsive Lesson',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: DiscordColors.background,
),
home: const ResponsiveDiscordPage(),
);
}
}
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 ResponsiveDiscordPage extends StatelessWidget {
const ResponsiveDiscordPage({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final isMobile = width < 760;
final isTablet = width >= 760 && width < 1100;
if (isMobile) {
return const MobileDiscordLayout();
}
return DesktopDiscordLayout(isTablet: isTablet);
},
);
}
}
class DesktopDiscordLayout extends StatelessWidget {
const DesktopDiscordLayout({
super.key,
required this.isTablet,
});
final bool isTablet;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: DiscordColors.background,
body: SafeArea(
child: Row(
children: [
const SizedBox(
width: 74,
child: ServerRailMock(),
),
SizedBox(
width: isTablet ? 230 : 250,
child: const ChannelSidebarMock(),
),
Expanded(
child: Column(
children: [
const ChatTopBar(channelName: 'general'),
Expanded(
child: Row(
children: [
const Expanded(
child: ChatAreaMock(),
),
if (!isTablet)
const SizedBox(
width: 260,
child: MemberPanelMock(),
),
],
),
),
],
),
),
],
),
),
);
}
}
class MobileDiscordLayout extends StatelessWidget {
const MobileDiscordLayout({super.key});
@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: 'general',
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: 'PCでは4カラム、タブレットでは右欄省略、スマホではDrawer収納にします。',
color: Color(0xFFEB459E),
),
ChatMessageMock(
name: 'code_senpai',
body: '同じ部品でも、画面幅によって並べ方を変えるのがレスポンシブ設計です。',
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が見える
タブレット幅で確認すること
ServerRailが見える
ChannelSidebarが見える
ChatAreaが見える
MemberPanelは消える
スマホ幅で確認すること
ChatAreaだけが見える
左上のメニューでdrawerが開く
右上のメンバーアイコンでendDrawerが開く
これで、3段階のレスポンシブ切り替えができています。
今回の処理を図で整理する
今回のレスポンシブ切り替えを図で整理すると、次の通りです。
LayoutBuilder
↓
constraints.maxWidth を取得
width < 760
↓
MobileDiscordLayout
760 <= width < 1100
↓
DesktopDiscordLayout(isTablet: true)
width >= 1100
↓
DesktopDiscordLayout(isTablet: false)
さらに、DesktopDiscordLayout の中ではこうなっています。
isTablet = true
↓
MemberPanelを表示しない
isTablet = false
↓
MemberPanelを表示する
どんなアプリでも使える考え方
今回の内容は、Discord風UIだけに限りません。
たとえば、次のようなアプリでも同じ考え方が使えます。
EC管理画面
医療向け業務システム
在庫管理システム
SNSアプリ
社内チャットツール
共通する考え方は、次の通りです。
1. 画面を部品に分ける
2. 優先度の高い部品を決める
3. 画面幅ごとに何を常時表示するか決める
4. 収納すべきものはDrawerや別画面へ移す
5. 同じ部品を再配置して対応する
この流れが身につくと、どんなUIでも整理して考えやすくなります。
手を動かす練習1:切り替え幅を変える
次の部分を探してください。
final isMobile = width < 760;
final isTablet = width >= 760 && width < 1100;
これを、たとえば次のように変えてみましょう。
final isMobile = width < 700;
final isTablet = width >= 700 && width < 1000;
すると、スマホ・タブレット・PCの切り替わる境目が変わります。
手を動かす練習2:タブレットでもMemberPanelを出してみる
DesktopDiscordLayout の次の部分を探してください。
if (!isTablet)
const SizedBox(
width: 260,
child: MemberPanelMock(),
),
これを一度、条件なしで表示してみます。
const SizedBox(
width: 260,
child: MemberPanelMock(),
),
タブレット幅で見たとき、どのくらい窮屈になるか確認できます。
そのあと、元に戻してください。
手を動かす練習3:スマホで右Drawerを消してみる
MobileDiscordLayout の endDrawer をコメントアウトしてみましょう。
// endDrawer: SizedBox(
// width: MediaQuery.of(context).size.width * 0.86,
// child: const MemberPanelMock(),
// ),
その場合、右上のメンバーアイコンも不要になります。
ChatTopBar(
channelName: 'general',
onOpenMenu: () {
Scaffold.of(context).openDrawer();
},
)
この練習で、「必要なものだけ残す」判断を体験できます。
手を動かす練習4:タブレットでChannelSidebarの幅を変える
次の部分です。
width: isTablet ? 230 : 250,
タブレット時を 200 に変えてみましょう。
width: isTablet ? 200 : 250,
少しチャット画面が広くなります。
このように、同じ部品でも幅を調整するだけで印象が変わります。
手を動かす練習5:スマホでDrawer幅を変える
左Drawerの幅は次のように指定しています。
width: MediaQuery.of(context).size.width * 0.9,
これを 0.8 に変えると、少し狭くなります。
width: MediaQuery.of(context).size.width * 0.8,
右側も同じように調整できます。
よくあるつまずき1:PCでもスマホレイアウトになる
切り替え条件が間違っていると、PCでもスマホレイアウトになります。
次の条件を必ず確認してください。
if (isMobile) {
return const MobileDiscordLayout();
}
return DesktopDiscordLayout(isTablet: isTablet);
isMobile と isTablet の条件が重ならないようにすることが大切です。
よくあるつまずき2:タブレットで横幅が足りずに崩れる
タブレットでは、PCと同じ4カラムを表示すると窮屈になることがあります。
そのため、右側メンバー欄を省略しています。
タブレット
↓
情報量は多いがPCほど広くない
↓
右欄を省略して3カラムにする
この「何を削るか」の判断がレスポンシブ設計では重要です。
よくあるつまずき3:Drawerを開くボタンが動かない
Scaffold.of(context).openDrawer() や openEndDrawer() を使うときは、Builder の中のcontextを使います。
body: SafeArea(
child: Builder(
builder: (context) {
return Column(...);
},
),
),
これがないと、Scaffoldに正しく届かないことがあります。
よくあるつまずき4:長い文字がはみ出す
今回の部品では、各所に overflow: TextOverflow.ellipsis を入れています。
Text(
channelName,
overflow: TextOverflow.ellipsis,
)
レスポンシブUIでは、横幅が小さくなるため、文字の省略はとても重要です。
よくあるつまずき5:全部を同じレイアウトで見せようとしてしまう
初心者がよくやってしまうのは、PC版をそのままスマホに押し込もうとすることです。
PC版そのまま
↓
スマホに縮小
↓
見づらい・押しにくい・崩れる
今回のように、優先順位を決めて見せ方を変える必要があります。
PC
↓
全部見せる
タブレット
↓
少し省略
スマホ
↓
収納して必要なときだけ見せる
この節の確認問題
確認問題1
レスポンシブUIで、PC・タブレット・スマホを切り替えるために使ったWidgetは何ですか。
答え
LayoutBuilder です。
確認問題2
今回の設計で、PCでは何カラム表示ですか。
答え
4カラムです。
確認問題3
今回の設計で、タブレットでは何を省略しましたか。
答え
右側の MemberPanel を省略しました。
確認問題4
今回の設計で、スマホでは何をDrawerに収納しましたか。
答え
左側の ServerRail と ChannelSidebar を drawer に、右側の MemberPanel を endDrawer に収納しました。
確認問題5
スマホで左Drawerを開く処理は何ですか。
答え
Scaffold.of(context).openDrawer() です。
確認問題6
スマホで右Drawerを開く処理は何ですか。
答え
Scaffold.of(context).openEndDrawer() です。
確認問題7
今回のレスポンシブ設計で大切だった考え方は何ですか。
答え
画面幅ごとに、何を常時表示し、何を省略し、何を収納するかを決めることです。
この節のまとめ
この節では、PC・タブレット・スマホで崩れないDiscord風レイアウトを作りました。
今回の中心の流れは、次の通りです。
画面幅を取得する
↓
PC・タブレット・スマホに分類する
↓
PCでは4カラム表示
↓
タブレットではMemberPanelを省略する
↓
スマホではDrawerに収納する
今回できるようになったことは、次の通りです。
PCでは広い画面を活かして全部表示できる
タブレットでは重要な部分だけ残して整理できる
スマホではChatAreaを中心にして、補助情報をDrawerに収納できる
この節で一番大切なのは、次の考え方です。
レスポンシブUIは、同じ部品をそのまま縮小するのではなく、画面幅ごとに情報の優先順位を整理して配置を変えることが本質である。
この章全体を振り返ると、Discord風UIを作る流れは、次のように整理できます。
画面を観察する
↓
必要なデータを考える
↓
画面を部品に分ける
↓
部品をWidgetとして作る
↓
状態を持たせる
↓
ユーザー操作で状態を変える
↓
必要な機能を少しずつ追加する
↓
画面幅ごとに見せ方を変える
ここまで理解できると、Discord風UIに限らず、他のアプリでもかなり整理して実装できるようになります。