FlutterでマンガアプリっぽいUIを作ってみた

普段はAndroidアプリ開発をしているFlutter初心者が、Flutterの勉強を兼ねてマンガアプリっぽいUIを作ってみたので、コードなどを解説してみたいと思います。

作ってみたUIは↓のような感じです。
[GitHub](https://github.com/tsumuchan/FlutterManga) に全体のコードを公開しています。
![1597245648.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/995/ef3ce8fc-351b-7d7f-8937-9794fcf7e34f.gif)

# 仕様
一口にマンガアプリっぽいUIといっても様々なため、今回はマンガを見る画面（ビューワー）に限定し、

1. 左右にスワイプして、ページを切り替えられる
- 画像を読み込んで表示できる
- 画像の拡大縮小ができる
- スライダーでページを移動できる
- 画面タップで全画面表示の切り替えができる
- ページ番号の表示がある
- ビューワーの最後のページには、マンガページとは違うページ（最終ページ）がある

という仕様のもと作成しました。

# コード

メインとなるコードは下記のような感じです。

Flutterプロジェクトを作成した際に自動生成される `_MyHomePageState` を適宜修正した形となっています。

以降、仕様で記載した点に関して、実装した箇所を解説していこうと思います。

```dart
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](https://api.flutter.dev/flutter/widgets/PageView-class.html) があります。（Androidで言うところの `ViewPager` みたいなやつです。）

`PageView` をそのまま使っても良かったのですが、UX的な観点で、見えていない次のページを先読みしたかったため、[PreloadPageView](https://pub.dev/packages/preload_page_view) というライブラリを使ってみました。

※ `PageView` の場合、ページを切り替えた際に読み込みが走るため、一瞬黒くなっている様子↓

|PageView|PreloadPageView|
|:-:|:-:|
|<img width=300 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/995/d4876696-8329-1898-5da3-5d5502a6b745.gif" />|<img width=300  src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/995/062774be-628b-cc28-700f-5e047145ed5e.gif" />|

ポイントとしては、
- `preloadPagesCount: 2` を指定して、次の次のページまで先読みする
- マンガは右から左へとページが続くので、 `reverse: true` を指定する
といったところです。

## 2. 画像を読み込んで表示できる
[NetworkImage](https://api.flutter.dev/flutter/painting/NetworkImage-class.html) を使えばOKです。

（コードでは、ダミーのページ画像を表示するために https://placehold.jp/ を使用させていただいています。）

## 3. 画像の拡大縮小ができる
自前で拡大縮小を実装するのはよくわからなかったため、[PhotoView](https://pub.dev/packages/photo_view) というライブラリを使いました。

左右のページ切り替えと画像の拡大縮小の動作が競合しちゃうかなと思ったのですが、競合せず、いい感じに動作しました。

![1597283491.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/995/89816377-bf42-9a60-345b-6c88c5e2c32e.gif)


## 4. スライダーでページを移動できる
[Slider](https://api.flutter.dev/flutter/material/Slider-class.html) というWidgetを使いました。

スライドさせると `onChanged` が呼ばれるため、そこで現在のページの更新と `PageView` との同期を行っています。

```dart
setState(() {
  _currentIndex = _pages.lengthForIndex() - value.floor();
  _controller.jumpToPage(_currentIndex);
});
```


ポイントとして、そのままのThemeだと `activeTrack` と `inactiveTrack` の高さ（ `trackHeight` ）が異なり、ちょっとした違和感があったため、 `RoundedRectSliderTrackShape` を継承したクラス（ `MyRoundedRectSliderTrackShape` ）を作成し、 `SliderTheme` を使うようにしています。

|Before|After|
|:-:|:-:|
|![1597283574.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/995/ddf4b1aa-b1f2-d71a-e19c-9b1391e6074b.png)|![1597283543.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/995/374eb0df-ff32-620d-3e67-54018d104367.png)|

## 5. 画面タップで全画面表示の切り替えができる
`PhotoView` の `onTapUp` が、タップして指を離したタイミングで呼ばれるので、そこで `_isFullScreen` というメンバ変数を更新し、それに連動して `AppBar` とフッターの表示を更新しています。

```dart
PhotoView(
    onTapUp: (context, details, controllerValue) {
        setState(() {
          _isFullScreen = !_isFullScreen;
        });
    },
    ...
```

## 6. ページ番号の表示がある
特に難しいことはせず、 `Text` を使っています。

マンガアプリの特徴として、ページ番号の母数に関しては、マンガのページ数のみ換算する（最終ページは含まない）ようにしています。

```dart
"${_currentIndex + 1}/${_pages.mangaPageLength()}"
```

また、マンガページだけでページ番号のWidgetを表示にするようにもしています。

```dart
Visibility(
  visible: !(_pages[_currentIndex] is EndPage,
  ...
)
```

## 7. ビューワーの最後のページには、マンガページとは違うページ（最終ページ）がある
マンガページと最終ページを、Kotlinで言うところの `sealed class` っぽく扱いたかったため、[freezed](https://pub.dev/packages/freezed) を使用しました。

`freezed` を用いることで、ページの判別に `when` メソッドを使うことができ、 `PreloadPageView` の `itemBuilder` のところで

```dart
return page.when(
  manga: (url) {
    // マンガページの場合
    // ...
  },
  end: () {
    // 最終ページの場合
    // ...
  },
);
```

といった形でスッキリと記述することが可能です。

# 終わりに
本記事では、簡単ではありますがFlutterでマンガアプリっぽいUIを作った話を記載しました。
「あるかな？」と思った機能やライブラリは、探せばだいたいあったため、開発しやすかったです。

Flutter初心者なため、イケてないところが多々あるかと思いますが、やさしくコメントしていただけるとうれしいです。