18
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

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

作ってみたUIは↓のような感じです。
GitHub に全体のコードを公開しています。
1597245648.gif

仕様

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

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

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

コード

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

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 というライブラリを使いました。

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

1597283491.gif

4. スライダーでページを移動できる

Slider というWidgetを使いました。

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

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

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

Before After
1597283574.png 1597283543.png

5. 画面タップで全画面表示の切り替えができる

PhotoViewonTapUp が、タップして指を離したタイミングで呼ばれるので、そこで _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 メソッドを使うことができ、 PreloadPageViewitemBuilder のところで

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

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

終わりに

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

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

18
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?