普段はAndroidアプリ開発をしているFlutter初心者が、Flutterの勉強を兼ねてマンガアプリっぽいUIを作ってみたので、コードなどを解説してみたいと思います。
作ってみたUIは↓のような感じです。
GitHub に全体のコードを公開しています。
仕様
一口にマンガアプリっぽいUIといっても様々なため、今回はマンガを見る画面(ビューワー)に限定し、
- 左右にスワイプして、ページを切り替えられる
- 画像を読み込んで表示できる
- 画像の拡大縮小ができる
- スライダーでページを移動できる
- 画面タップで全画面表示の切り替えができる
- ページ番号の表示がある
- ビューワーの最後のページには、マンガページとは違うページ(最終ページ)がある
という仕様のもと作成しました。
コード
メインとなるコードは下記のような感じです。
Flutterプロジェクトを作成した際に自動生成される _MyHomePageState
を適宜修正した形となっています。
以降、仕様で記載した点に関して、実装した箇所を解説していこうと思います。
class _MyHomePageState extends State<MyHomePage> {
final _controller = PreloadPageController(
initialPage: 0,
);
final _pages = List<Page>();
bool _isFullScreen = false;
int _currentIndex = 0;
@override
void initState() {
super.initState();
Iterable<int>.generate(20).forEach((index) {
_pages.add(
Page.manga(
url: "https://placehold.jp/9fa0b0/ffffff/360x640.png?text=Page ${index + 1}",
),
);
});
_pages.add(Page.end());
}
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: _isFullScreen
? null
: AppBar(
title: Text(widget.title),
),
body: Stack(
children: <Widget>[
Container(
color: Colors.black,
),
// 1. 左右にスワイプして、ページを切り替えられる
PreloadPageView.builder(
preloadPagesCount: 2,
controller: _controller,
onPageChanged: (index) {
setState(() {
_currentIndex = index;
});
},
reverse: true,
itemCount: _pages.length,
itemBuilder: (context, index) {
final page = _pages[index];
return page.when(
manga: (url) {
// 2. 画像を読み込んで表示できる
// 3. 画像の拡大縮小ができる
return PhotoView(
onTapUp: (context, details, controllerValue) {
setState(() {
// 5. 画面タップで全画面表示の切り替えができる
_isFullScreen = !_isFullScreen;
});
},
imageProvider: NetworkImage(url),
);
},
end: () {
// 7. ビューワーの最後のページには、マンガページとは違うページ(最終ページ)がある
return Container(
color: Colors.grey,
child: Center(
child: Text("End Page"),
),
);
},
);
},
),
Align(
alignment: Alignment.bottomCenter,
child: Visibility(
visible: !_isFullScreen,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Visibility(
visible: !(_pages[_currentIndex] is EndPage),
child: Container(
decoration: BoxDecoration(
color: Colors.black45,
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
// 6. ページ番号の表示がある
child: Text(
"${_currentIndex + 1}/${_pages.mangaPageLength()}",
style: TextStyle(
color: Colors.white,
fontSize: 16,
),
),
),
),
SizedBox(
height: 16,
),
Container(
height: 60,
color: Theme.of(context).primaryColor,
// 4. スライダーでページを移動できる
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white,
thumbColor: Colors.white,
trackHeight: 3.0,
trackShape: MyRoundedRectSliderTrackShape()),
child: Slider(
onChanged: (value) {
setState(() {
_currentIndex = _pages.lengthForIndex() - value.floor();
_controller.jumpToPage(_currentIndex);
});
},
value: (_pages.lengthForIndex() - _currentIndex).toDouble(),
min: 0,
max: _pages.lengthForIndex().toDouble(),
),
),
),
],
),
),
),
],
),
);
}
}
1. 左右にスワイプして、ページを切り替えられる
左右にスワイプして切り替えができるWidgetとしては、PageView があります。(Androidで言うところの ViewPager
みたいなやつです。)
PageView
をそのまま使っても良かったのですが、UX的な観点で、見えていない次のページを先読みしたかったため、PreloadPageView というライブラリを使ってみました。
※ PageView
の場合、ページを切り替えた際に読み込みが走るため、一瞬黒くなっている様子↓
PageView | PreloadPageView |
---|---|
![]() |
![]() |
ポイントとしては、
-
preloadPagesCount: 2
を指定して、次の次のページまで先読みする - マンガは右から左へとページが続くので、
reverse: true
を指定する
といったところです。
2. 画像を読み込んで表示できる
NetworkImage を使えばOKです。
(コードでは、ダミーのページ画像を表示するために https://placehold.jp/ を使用させていただいています。)
3. 画像の拡大縮小ができる
自前で拡大縮小を実装するのはよくわからなかったため、PhotoView というライブラリを使いました。
左右のページ切り替えと画像の拡大縮小の動作が競合しちゃうかなと思ったのですが、競合せず、いい感じに動作しました。
4. スライダーでページを移動できる
Slider というWidgetを使いました。
スライドさせると onChanged
が呼ばれるため、そこで現在のページの更新と PageView
との同期を行っています。
setState(() {
_currentIndex = _pages.lengthForIndex() - value.floor();
_controller.jumpToPage(_currentIndex);
});
ポイントとして、そのままのThemeだと activeTrack
と inactiveTrack
の高さ( trackHeight
)が異なり、ちょっとした違和感があったため、 RoundedRectSliderTrackShape
を継承したクラス( MyRoundedRectSliderTrackShape
)を作成し、 SliderTheme
を使うようにしています。
Before | After |
---|---|
![]() |
![]() |
5. 画面タップで全画面表示の切り替えができる
PhotoView
の onTapUp
が、タップして指を離したタイミングで呼ばれるので、そこで _isFullScreen
というメンバ変数を更新し、それに連動して AppBar
とフッターの表示を更新しています。
PhotoView(
onTapUp: (context, details, controllerValue) {
setState(() {
_isFullScreen = !_isFullScreen;
});
},
...
6. ページ番号の表示がある
特に難しいことはせず、 Text
を使っています。
マンガアプリの特徴として、ページ番号の母数に関しては、マンガのページ数のみ換算する(最終ページは含まない)ようにしています。
"${_currentIndex + 1}/${_pages.mangaPageLength()}"
また、マンガページだけでページ番号のWidgetを表示にするようにもしています。
Visibility(
visible: !(_pages[_currentIndex] is EndPage,
...
)
7. ビューワーの最後のページには、マンガページとは違うページ(最終ページ)がある
マンガページと最終ページを、Kotlinで言うところの sealed class
っぽく扱いたかったため、freezed を使用しました。
freezed
を用いることで、ページの判別に when
メソッドを使うことができ、 PreloadPageView
の itemBuilder
のところで
return page.when(
manga: (url) {
// マンガページの場合
// ...
},
end: () {
// 最終ページの場合
// ...
},
);
といった形でスッキリと記述することが可能です。
終わりに
本記事では、簡単ではありますがFlutterでマンガアプリっぽいUIを作った話を記載しました。
「あるかな?」と思った機能やライブラリは、探せばだいたいあったため、開発しやすかったです。
Flutter初心者なため、イケてないところが多々あるかと思いますが、やさしくコメントしていただけるとうれしいです。