前回のブログの続編記事になります。前回は主にFlutterによるヘッダーとフッターについて解説し、Flutterの基本である Widget の考え方についてまとめました。しかし、出来上がった物はヘッダーとフッターのみの画面であったため、ひどく寂しい物だったと思います。ということで、今回はいよいよ Flutter でモバイルアプリの ボディ 部分を作ってみましょう。
目次
今回テーマはモバイルアプリのボディ部分
今回の記事では、前回の『LINEアプリのUIを真似しながらFlutterを学習する』というテーマを引継ぎ、LINEアプリのトーク画面を参考にしながらモバイルアプリにおけるボディ部分を作っていきます。前回と同様にロジックは含まず画面を作るということのみに注力しています。加えて、前回の内容をアップデートする系の記事になるため、ヘッダーとフッターができていることを前提にしています。
また、複雑化を避けるために一部簡略化をしております。「え、完全再現できてないけど!」と思われた方は、是非完全再現を目指してみてはどうでしょうか。
前回記事の完成図から、以下のような状態まで持っていきます。
主に扱うこととしては
- BottomNavigationBar Widget によるフッターアイコンの対応
- ListView によるリストビューの作成
- Navigator による画面遷移
- サードパーティの導入
となります。
前回の記事ではfooter.dart
を作成し、Stateful Widge を利用してアクティブにアイコンが切り替わるようにしました。しかし、アイコンが切り替わるのみで、ボディ部分に対応する画面には変化がありませんでした。
ボディ部分の本格的な作成に入る前に、フッターのアイコンに合わせて対応するボティ画面が切り替わるようにします。
フッターアイコンに対応した画面を作る
上図が前回作成したフッターの画面になります。では、それぞれのフッターアイコンに対応する画面を5つ作りましょう。ここで作る画面は以下の5つです。
- ホーム画面
- トーク画面
- タイムライン画面
- ニュース画面
- ウォレット画面
また、フッターアイコンとボディ画面を対応させることがメインですので、画面は前回のような簡易的な文字だけのものにします。
それでは、プロジェクトのlib
ディレクトリ直下にroutes
というディレクトリを作成し、以下のようなDartファイルを作成します。
- routes/home_route.dart
- routes/talk_route.dart
- routes/timeline_route.dart
- routes/news_route.dart
- routes/wallet_route.dart
Flutter では 画面単位を Route とします。home_route.dart
の例を示します。
home.dart
import 'package:flutter/material.dart';
class Home extends StatelessWidget { // <- (※1)
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("ホーム"), // <- (※2)
),
body: Center(child: Text("ホーム") // <- (※3)
),
);
}
}
appBar プロパティにヘッダー部分が入るのは前回の解説の通りです。今回は、ヘッダーはメインではないので、AppBar Widget のtitle
プロパティに現在の画面がどの画面かを判別するための文字列を入れるだけにします(※2部分)。
前回は割愛していましたが、Scaffold Widgt の body プロパティにアプリ画面のボディ部分が入ります。ひとまずは、これがホーム画面であることがわかるようにボディ部分の中心にホーム
というテキストを表示させることにします(※3部分)。
前回の記事を読んでくださった方は、「あれ?BottomNavigationBar プロパティは?」と思われるかもしれませんが、フッター部分は別のレイヤーで制御するのでなくても大丈夫です。
ひとまず、ホーム画面はこれで完成です。あとは、それぞれのファイルに対応するクラス名「Talk」「TimeLine」「News」「Wallet」(※1部分)を記述し、ヘッダー部分のtitle
プロパティ(※2部分) とボティ部分(※3部分)に「トーク」「タイムライン」「ニュース」「ウォレット」を記述し、それぞれがどの画面であるかがわかるようにしましょう。
これで、5つのとても簡単な画面ができました。
5つの画面を管理するroot.dartをつくる
続いて、前回作成したfooter.dart
を改造して、作成した5つの画面とフッターアイコンを対応させるプログラムを書きます。
footer.dart
はフッター部分だけでなく、5つの画面の遷移を管理することになるので、root.dart
というファイル名に変更します。クラス名もFooter
からRootWidget
クラスに名前を変更します。このプログラムがアプリのルートになります。
以下が、root.dart
のプログラムになります。
ベタ貼りになりますが、アクティブかどうかを判定する仕組みはfooter.dart
のままです。詳しい変更の詳細はあとでまとめます。
root.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
// == 作成したWidget をインポート ==================
import 'routes/home_route.dart';
import 'routes/talk_route.dart';
import 'routes/timeline_route.dart';
import 'routes/wallet_route.dart';
import 'routes/news_route.dart';
// =============================================
class RootWidget extends StatefulWidget {
RootWidget({Key key}) : super(key: key);
@override
_RootWidgetState createState() => _RootWidgetState();
}
class _RootWidgetState extends State<RootWidget> {
int _selectedIndex = 0;
final _bottomNavigationBarItems = <BottomNavigationBarItem>[];
static const _footerIcons = [
Icons.home,
Icons.textsms,
Icons.access_time,
Icons.content_paste,
Icons.work,
];
static const _footerItemNames = [
'ホーム',
'トーク',
'タイムライン',
'ニュース',
'ウォレット',
];
// === 追加部分 ===
var _routes = [
Home(),
Talk(),
TimeLine(),
News(),
Wallet(),
];
// ==============
@override
void initState() {
super.initState();
_bottomNavigationBarItems.add(_UpdateActiveState(0));
for (var i = 1; i < _footerItemNames.length; i++) {
_bottomNavigationBarItems.add(_UpdateDeactiveState(i));
}
}
/// インデックスのアイテムをアクティベートする
BottomNavigationBarItem _UpdateActiveState(int index) {
return BottomNavigationBarItem(
icon: Icon(
_footerIcons[index],
color: Colors.black87,
),
title: Text(
_footerItemNames[index],
style: TextStyle(
color: Colors.black87,
),
)
);
}
BottomNavigationBarItem _UpdateDeactiveState(int index) {
return BottomNavigationBarItem(
icon: Icon(
_footerIcons[index],
color: Colors.black26,
),
title: Text(
_footerItemNames[index],
style: TextStyle(
color: Colors.black26,
),
)
);
}
void _onItemTapped(int index) {
setState(() {
_bottomNavigationBarItems[_selectedIndex] =
_UpdateDeactiveState(_selectedIndex);
_bottomNavigationBarItems[index] = _UpdateActiveState(index);
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _routes.elementAt(_selectedIndex),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed, // これを書かないと3つまでしか表示されない
items: _bottomNavigationBarItems,
currentIndex: _selectedIndex,
onTap: _onItemTapped,
),
);
}
}
簡単に解説します。始めに作成した5つの画面を配列として保持します。
var _routes = [
Home(),
Talk(),
TimeLine(),
News(),
Wallet(),
];
続いて、前回は BottomNavigationBar Widget を返していた部分を変更し、Scaffold Widget を返すようにしました。
@override
Widget build(BuildContext context) {
// Scaffold Widget を返す
return Scaffold(
// ボディ画面は、選択されたアイコンによって変更
body: _routes.elementAt(_selectedIndex),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed, // これを書かないと3つまでしか表示されない
items: _bottomNavigationBarItems,
currentIndex: _selectedIndex,
onTap: _onItemTapped,
),
);
}
bodyプロパティには、先ほど宣言した5つの画面リストが入っており、選択されたアイコンのインデックスによって対応する画面をBodyに描画するという流れになります。5つの画面に対してBottomNavigationBarを付けなかった理由は、こちらで制御するからでした。
それでは、main.dart
も少し変更します。
main.dart
import 'package:flutter/material.dart';
import 'root.dart';
void main() => runApp(App());
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: Colors.blueGrey[900],
),
home: RootWidget(),
);
}
}
Hot Reloadしてみましょう。私の結果を以下のようになりました。
これで、フッターアイコンに対応して画面が切り替わるようになりました。
トーク画面を作る
それでは、今回のメインであるボディ部分について作っていきましょう。真似する画面はLINEアプリの中でも比較的シンプルなトーク画面にしています。ここからはトーク画面であるtalk_route.dart
を編集していきます。
ListViewでビュワーを作る
LINEアプリを使っている人でトーク画面を触ったことないという人はほとんどいないでしょう。ともだちがリスト形式で並んでいますよね。まずは、ListView Widget を使ってリスト式のビュワーを作っていきます。
ひとまず、Center Widget に Text Widget だけという寂しい画面に別れをつげましょう。以下のようにbody
プロパティを変更します。
talk_route.dart
import 'package:flutter/material.dart';
class Talk extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("トーク"),
),
// ==== リストビューを追加 ==========
body: ListView(
padding: const EdgeInsets.all(8),
children: <Widget>[
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
ListTile(title: Text("無駄")),
]
),
// ===============================
);
}
}
少しまともになりましたね。画像は静止画ですがスクロールできると思います。
ListView Widget は children
プロパティを持っており、ここにWidget の配列を指定すると、その Widget をリスト形式で表示してくれるビューを作成してくれます。
children プロパティは Widget であればなんでもよいのですが、ListView Widget がよく用いられるため、これに対応したListTile Widget が存在したので、複数書き並べてみました。
たったこれだけの追加で、リストビューが実現しました。それでも、LINEのトーク画面と比べるとまだまだ雲泥の差ですね。続いては、リストビューの要素となるTileを作り込んでいきましょう。
ListTileを作る
最初のトーク画面では、ListView Widgetのそれぞれの要素にListTileを用いました。リストビューは実現したものの質素ですよね。それはもうずばりリストの要素となるリストタイルをカスタマイズしてないのが原因です。それでは、リストタイルをLINEアプリに似せていきましょう。この節では、サードパーティを用いた最終的に以下のようなタイルを実現します。
ListTile Widget をカスタマイズして作っていくことも考えたのですが、求めていたものがすでにあったので、今回は利用させていただきます。
タイル用のWidgetクラスを作成する
まずはtalk_route.dart
に ListTile Widget を直書きするのをやめましょう。新しくtile.dart
を作成します。
tile.dart
import 'package:flutter/material.dart';
class Tile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(title: Text("無駄"),);
}
}
まずは分けることが大事なので、中身は先ほどと同じです。保存しましたら、talk_route.dart
を編集します。
talk_route.dart
import 'package:flutter/material.dart';
import 'package:flutter_sample/tile.dart'; // <- インポート
class Talk extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("トーク"),
),
// ======= 変更 ===================
body: ListView(
padding: const EdgeInsets.all(8),
children: <Widget>[
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
Tile(),
]
),
// ===============================
);
}
}
やっていることは変わっていないので、Hot Reloadしても画面に変化はありません。これで、リストビュー内のタイルをカスタマイズする準備が整いました!
サードパティを導入する
それでは、サードパティを入れましょう。導入方法はこちらに掲載されています。この記事ではAndroid Studio での導入になります。
flutter_slidableというパッケージを導入します。
- まず、
pubspec.yaml
を開きます。 - dependenciesの部分に今回導入するパッケージを書きます(パッケージのバージョンは変わると思います)。
- パッケージを取得します。
要素を作る
それでは、リストビューで表示する要素であるタイルを作っていきます。
先ほど作成したtile.dart
にパッケージをインポートします。コードは以下のようになります。
tile.dart
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart'; <- // インポート
class Tile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Slidable(
actionPane: SlidableDrawerActionPane(),
actionExtentRatio: 0.20,
child: Container(
color: Colors.white,
child: ListTile(
leading: CircleAvatar(
child: Icon(this.icon),
backgroundColor: Colors.pink,
),
title: Text("タイトル"),
subtitle: Text("サブタイトル"),
onTap: () => {},
),
),
actions: <Widget>[
IconSlideAction(
color: Colors.blue,
icon: Icons.flash_off,
onTap: () => {}, // _showSnackBar('Archive'),
),
IconSlideAction(
color: Colors.indigo,
icon: Icons.volume_off,
onTap: () => {}, // _showSnackBar('Share'),
),
],
secondaryActions: <Widget>[
IconSlideAction(
color: Colors.black45,
iconWidget: Text(
"非表示",
style: TextStyle(color: Colors.white),
),
onTap: () => {}, // _showSnackBar('More'),
),
IconSlideAction(
color: Colors.red,
iconWidget: Text(
"削除",
style: TextStyle(color: Colors.white),
),
onTap: () => {}, // _showSnackBar('Delete'),
),
],
);
}
}
ほとんどサンプルコードのコピペです。一部、UIのアイコンは文字を変更する必要があったため、その変更を加えております。Hot Reloadしてみましょう。
あとは、今回作成した Tile Widget の外から名前やメッセージを指定できるようにします。
tile.dart
(一部追加)
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
class Tile extends StatelessWidget {
// == 追加 ========
IconData icon;
String username;
String message;
// ===============
// == 追加 ========
Tile({IconData icon, String username, String message}) {
this.icon = icon;
this.username = username;
this.message = message;
}
// ===============
@override
Widget build(BuildContext context) {
return Slidable(
actionPane: SlidableDrawerActionPane(),
actionExtentRatio: 0.20,
child: Container(
color: Colors.white,
child: ListTile(
leading: CircleAvatar(
child: Icon(this.icon), // <- 追加:アイコンの設定
backgroundColor: Colors.pink,
),
title: Text(this.username), // <- 追加:ユーザ名の設定
subtitle: Text(this.message), // <- 追加:メッセージの設定
onTap: () => {},
),
),
actions: <Widget>[
IconSlideAction(
color: Colors.blue,
icon: Icons.flash_off,
onTap: () => {}, // _showSnackBar('Archive'),
),
IconSlideAction(
color: Colors.indigo,
icon: Icons.volume_off,
onTap: () => {}, // _showSnackBar('Share'),
),
],
secondaryActions: <Widget>[
IconSlideAction(
color: Colors.black45,
iconWidget: Text(
"非表示",
style: TextStyle(color: Colors.white),
),
onTap: () => {}, // _showSnackBar('More'),
),
IconSlideAction(
color: Colors.red,
iconWidget: Text(
"削除",
style: TextStyle(color: Colors.white),
),
onTap: () => {}, // _showSnackBar('Delete'),
),
],
);
}
}
あとは、適当に雰囲気を出すために呼び出し側のtalk_route.dart
で、文字列を設定します。
talk_route.dart
(一部編集)
import 'package:flutter/material.dart';
import 'package:line_talk/tile.dart';
class Talk extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("トーク"),
),
// ===== 編集 =====================
body: ListView(
// padding: const EdgeInsets.all(8),
children: <Widget>[
Tile(
icon: Icons.person,
username: "鹿太郎",
message: "しかし、鹿しかいない",
),
Tile(
icon: Icons.person,
username: "久米酒",
message: "おいしいよー",
),
Tile(icon: Icons.person, username: "くら", message: "とっても美味しい沖縄のお酒"),
Tile(icon: Icons.person, username: "団長", message: "止まるんじゃ、ねぇぞ"),
Tile(icon: Icons.person, username: "サルーイン", message: "こい"),
Tile(
icon: Icons.person,
username: "がらはど",
message: "だめだ!いくら積まれても..."),
Tile(
icon: Icons.person,
username: "太郎",
message: "だめだ、久しぶりにキレちまったよ"),
Tile(
icon: Icons.person,
username: "Harry",
message: "エクスペクト・パトローナーーム"),
Tile(
icon: Icons.person,
username: "くろひげ",
message: "似合ってるぜぃ、そのきずぅ〜"),
Tile(icon: Icons.person, username: "あすらん", message: "キラァァァァ"),
Tile(icon: Icons.person, username: "知人B", message: "1.14 release !"),
]),
// ================================
);
}
}
見た目はかなりLINEアプリのトーク画面に近づいたのではないでしょうか。
トーク画面とチャット画面間の遷移を作る
最後は、トーク画面からチャット画面への遷移を作ります。リストビュー内のタイルをタップするとチャット画面に遷移するようにします。
簡易的なチャット画面を作ってそこに遷移する
それでは、まず遷移先のチャット画面を作ります。要領はこの記事の最初と同じです。routes/chat_route.dart
を作成し、ひとまずは以下のようにします。
chat_route.dart
import 'package:flutter/material.dart';
class Chat extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("チャット"),
),
body: Center(child: Text("Chat")),
backgroundColor: Colors.cyan,
);
}
}
それでは、トーク画面からこのチャット画面への遷移を作ります。トーク画面のリストビュー内のタイルがタップされた時に遷移を行いたいので、tile.dart
を編集します。タップされた時の処理を空欄にしていたので、そこに遷移処理を書きます。
tile.dart
(タップ時の処理を追加)
import 'routes/chat.dart'; // <- 追加!
/* -- 省略 -- */
Widget build(BuildContext context) {
return Slidable(
actionPane: SlidableDrawerActionPane(),
actionExtentRatio: 0.20,
child: Container(
color: Colors.white,
child: ListTile(
leading: CircleAvatar(
child: Icon(this.icon),
backgroundColor: Colors.pink,
),
title: Text(this.username),
subtitle: Text(this.message),
// リストタイルがタップされた際のイベント
onTap: () => {
// == 追加 =========================
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Chat()))
// ================================
},
),
),
/* -- 省略 -- */
Flutterではページ(Route)の遷移をNavigatorで制御します。一度この状態でHot Reloadしてみましょう。
あれ?実装した記憶はないのに、AppBar に BackButton が追加され、元の画面に戻れるようになっています。これはFlutterの機能で、Navigatorで遷移した場合、戻る機能を自動で付与してくれます。自動で付与されるのをやめたい場合はAppBar の automaticallyImplyLeading プロパティに false を設定するとオフにできます。
あと少しです!想像以上に簡単に遷移ができてしまいましたが、このままでは誰のチャット画面なのかがわかりません。Navigatorの遷移時に文字列を渡すようにします。以下のとおり1行だけ追加してください。
tile.dart
(1行だけ追加)
class Tile extends StatelessWidget {
IconData icon;
String username;
String message;
Tile({IconData icon, String username, String message}) {
this.icon = icon;
this.username = username;
this.message = message;
}
@override
Widget build(BuildContext context) {
return Slidable(
actionPane: SlidableDrawerActionPane(),
actionExtentRatio: 0.20,
child: Container(
color: Colors.white,
child: ListTile(
leading: CircleAvatar(
child: Icon(this.icon),
backgroundColor: Colors.pink,
),
title: Text(this.username),
subtitle: Text(this.message),
onTap: () => {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Chat(
// == 追加 =============
username: this.username,
// ====================
)))
},
),
),
/* -- 省略 --*/
受け取る側も用意します。また、ついでにヘッダー部分も装飾します。
chat_route.dart
(一部追加)
import 'package:flutter/material.dart';
class Chat extends StatelessWidget {
// == 追加 ===============
String username;
Chat({String username}) {
this.username = username;
}
// =======================
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
this.username ?? '', // <- 追加!
),
// == ヘッダーを追加 ===============
actions: [
Padding(
padding: const EdgeInsets.all(4.0),
child: IconButton(
icon: Icon(Icons.search),
onPressed: () => {},
),
),
Padding(
padding: const EdgeInsets.all(4.0),
child: IconButton(
icon: Icon(Icons.call),
onPressed: () => {},
),
),
Padding(
padding: const EdgeInsets.all(4.0),
child: IconButton(
icon: Icon(Icons.dehaze),
onPressed: () => {},
)),
],
// ===============================
),
body: Center(child: Text("Chat")),
backgroundColor: Colors.cyan,
);
}
}
Hot Reloadしてみると…。
できました。
チャット画面のフッターを作る
これで完成!…でもよかったのですが、少し寂しいのであと少し装飾します(前回の反省)。チャット画面にフッターを追加しましょう。
chat_route.dart
(ソースコードは該当部のbottomNavigationBarプロパティ部分のみ)
bottomNavigationBar: BottomAppBar(
child: Row(
children: <Widget>[
IconButton(
icon: Icon(Icons.add),
onPressed: () => {},
),
IconButton(
icon: Icon(Icons.camera_alt),
onPressed: () => {},
),
IconButton(
icon: Icon(Icons.photo),
onPressed: () => {},
),
Expanded(
child: TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Aa',
),
),
),
IconButton(
icon: Icon(Icons.mic),
onPressed: () => {},
),
],
),
),
前回とは違って、別の BottomAppBar Widget を利用しました。
今度こそ完成です。
Androidでもできるよ
最後にAndroidでもきちんとできるできているかどうか確認しましょう。
Androidの実機を所持していないため、AndroidのLINEアプリと同じ動きなのかわかりませんが、ひとまずアプリとして機能していることがわかります。
まとめ
今回のテーマはボティ部分ということで、LINEアプリにおけるトーク部分とチャット部分を作り、2つのRoute間の遷移を作成しました。アプリとしてのロジックはありませんが、アプリ画面は前回よりもかなりアップデートされていると思います。
このLINEアプリの模倣学習に取り組み、多くの人が使うアプリはかなり作り込まれていることが身に染みてわかりました。実は当初はLINEアプリのホーム画面を模倣しようとしていましたが、作りが複雑で断念しております。その結果今回は比較的簡単なトーク画面を採用したわけですが、思い通りのレイアウトをFlutterで実現させる道は険しそうです…(結果的に綺麗なレイアウトにはなりますが!)。