1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutterで薄い本試読アプリ作ってみた!

Last updated at Posted at 2025-07-08

 近所の温泉に浸かりながら、大阪のイベントの準備で他に何かあったかなと考えたりしていたら、そういえばというのがあった。

 展示で電子書籍を試読してもらうのに、SlideBookアプリなどを使っていたけど、あんまり見てもらうチャンスがない感じ。本棚インターフェイスでは、ちょっとアイコンが細かくてダメっぽい。
 ほかにもいくつか電子書籍を閲覧するアプリはあるのだけれど、大抵は自分で電子書籍を読む用のアプリなので展示には向かないところがある。
 そして、たいていは機能面でオーバースペックになっていて、手持ちの古いAndroid端末だと動きが重くて使いにくいというのもある。

 M5stack Japan Tour 2025 Spring Osakaでの展示に使うにはイマイチなので、Flutterを使って自前でサクッと作ってみることにした。といっても、仕様を決めてChatGPT o4にお願いするだけだけど。

 FlutterでPDFを扱えるライブラリはいくつかあるようだけど、pdfxが軽くて良さげだったのでそれを選択。2.9.0が最新だった。

  pdfx | Flutter package
  https://pub.dev/packages/pdfx

 LLMへの指示はこんな感じ。

展示会で古いAndroidタブレットを使って、電子書籍のプレビューをやりたいと思っています。複数の電子書籍を登録してメニューからこれを閲覧できるようにしたいです。メニューは、指定した画像を順次表示して、タップするとそのとき表示された画像の見本のPDFを表示するようにします。コードを生成してもらえますか?
メニューの画像は自動でスライドショウしてください。スワイプでの移動もお願いします。
PDFのピンチズームを入れてください。

 指示された手順に従って、スケルトンを作る。

% flutter create --org com.kinneko --platforms=android pdfview
% cd pdfview
% vi pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  pdfx: ^2.9.0

  assets:
    - assets/sample1_thumb.jpg
    - assets/sample1.pdf
    - assets/sample2_thumb.jpg
    - assets/sample2.pdf
    - assets/sample3_thumb.jpg
    - assets/sample3.pdf

 assetsを掘って、pubspec.yamlのサンプルの指定通りにファイルを入れる。

% mkdir assets
% ls assets
sample1.pdf		sample2.pdf		sample3.pdf
sample1_thumb.jpg	sample2_thumb.jpg	sample3_thumb.jpg

 pdfxライブラリを入れる。結構いろいろ追加されるのね。

% flutter pub add pdfx
"pdfx" is already in "dependencies". Will try to update the constraint.
Resolving dependencies...
Downloading packages... (6.2s)
  async 2.12.0 (2.13.0 available)
+ crypto 3.0.6
+ extension 0.6.0
  fake_async 1.3.2 (1.3.3 available)
+ fixnum 1.1.1
+ flutter_web_plugins 0.0.0 from sdk flutter
  leak_tracker 10.0.8 (11.0.1 available)
  leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
  leak_tracker_testing 3.0.1 (3.0.2 available)
  lints 5.1.1 (6.0.0 available)
  material_color_utilities 0.11.1 (0.12.0 available)
+ pdfx 2.9.0
+ photo_view 0.15.0
+ plugin_platform_interface 2.1.8
+ sprintf 7.0.0
+ synchronized 3.3.1
+ typed_data 1.4.0
+ universal_platform 1.1.0
+ uuid 4.5.1
  vector_math 2.1.4 (2.1.5 available)
  vm_service 14.3.1 (15.0.0 available)
+ web 1.1.1
Changed 13 dependencies!
9 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

 ChatGPT4oが吐いたサンプルをそのままmain.dartに入れる。

% vi lib/main.dart
import 'package:flutter/material.dart';
import 'package:pdfx/pdfx.dart';
import 'dart:async';

void main() => runApp(BookPreviewApp());

class BookPreviewApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '電子書籍プレビュー',
      theme: ThemeData.dark(),
      home: BookMenuPage(),
    );
  }
}

class BookMenuPage extends StatefulWidget {
  @override
  _BookMenuPageState createState() => _BookMenuPageState();
}

class _BookMenuPageState extends State<BookMenuPage> {
  final List<BookItem> books = [
    BookItem('assets/sample1_thumb.jpg', 'assets/sample1.pdf'),
    BookItem('assets/sample2_thumb.jpg', 'assets/sample2.pdf'),
    BookItem('assets/sample3_thumb.jpg', 'assets/sample3.pdf'),
  ];

  int currentIndex = 0;
  Timer? _slideshowTimer;

  @override
  void initState() {
    super.initState();
    _startSlideshow();
  }

  void _startSlideshow() {
    _slideshowTimer = Timer.periodic(Duration(seconds: 5), (timer) {
      setState(() {
        currentIndex = (currentIndex + 1) % books.length;
      });
    });
  }

  @override
  void dispose() {
    _slideshowTimer?.cancel();
    super.dispose();
  }

  void _onSwipe(DragEndDetails details) {
    if (details.primaryVelocity == null) return;
    setState(() {
      if (details.primaryVelocity! < 0) {
        currentIndex = (currentIndex + 1) % books.length;
      } else if (details.primaryVelocity! > 0) {
        currentIndex = (currentIndex - 1 + books.length) % books.length;
      }
    });
  }

  void openBook() {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => PDFViewPage(pdfPath: books[currentIndex].pdfPath),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: openBook,
      onHorizontalDragEnd: _onSwipe,
      child: Scaffold(
        body: Center(
          child: Image.asset(
            books[currentIndex].imagePath,
            fit: BoxFit.contain,
          ),
        ),
      ),
    );
  }
}

class BookItem {
  final String imagePath;
  final String pdfPath;

  BookItem(this.imagePath, this.pdfPath);
}

class PDFViewPage extends StatefulWidget {
  final String pdfPath;

  PDFViewPage({required this.pdfPath});

  @override
  _PDFViewPageState createState() => _PDFViewPageState();
}

class _PDFViewPageState extends State<PDFViewPage> {
  late PdfControllerPinch _pdfController;

  @override
  void initState() {
    super.initState();
    _pdfController = PdfControllerPinch(
      document: PdfDocument.openAsset(widget.pdfPath),
    );
  }

  @override
  void dispose() {
    _pdfController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('PDFビューア')),
      body: PdfViewPinch(
        controller: _pdfController,
      ),
    );
  }
}

 Pixel 6aをつないでビルド。

% flutter run

 エラーになった。まぁ、AIChatなんでそんなもんだよねw

e: file:///Users/kinneko/.pub-cache/hosted/pub.dev/pdfx-2.9.0/android/src/main/kotlin/io/scer/pdfx/Messages.kt:211:98 No value passed for parameter 'forPrint'

このエラーは、pdfx のバージョン 2.9.0 以降で PdfDocument.openAsset() に forPrint パラメータの指定が必須になったためです。指定していない場合、コンパイルエラーになります。

-      document: PdfDocument.openAsset(widget.pdfPath),
+      document: PdfDocument.openAsset(widget.pdfPath, forPrint: false),

 修正して再ビルドしてもエラー。

lib/main.dart:113:55: Error: No named parameter with the name 'forPrint'.
      document: PdfDocument.openAsset(widget.pdfPath, forPrint: false),
                                                      ^^^^^^^^
../../.pub-cache/hosted/pub.dev/pdfx-2.9.0/lib/src/renderer/interfaces/document.dart:45:30: Context: Found this candidate, but the arguments don't match.
  static Future<PdfDocument> openAsset(String name, {String? password}) {
                             ^^^^^^^^^
Target kernel_snapshot_program failed: Exception


FAILURE: Build failed with an exception.

pubspec.yaml で 2.4.0 を指定してください。これは forPrint を必要とせず、安定動作するバージョンです。

 なんか、面倒なこと言うね。
 えらく前のバージョンにロールバックをしろと言うことか。
 まぁ、そのあたりにこだわりはない。動けばいいので指示に従う。

% vi pubspec.yaml
  pdfx: 2.4.0

% flutter pub get
Resolving dependencies...
Downloading packages... (8.2s)
  async 2.12.0 (2.13.0 available)
+ device_info_plus 9.1.2 (11.4.0 available)
+ device_info_plus_platform_interface 7.0.2
  fake_async 1.3.2 (1.3.3 available)
+ ffi 2.1.4
+ file 7.0.1
+ js 0.6.7 (0.7.2 available)
  leak_tracker 10.0.8 (11.0.1 available)
  leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
  leak_tracker_testing 3.0.1 (3.0.2 available)
  lints 5.1.1 (6.0.0 available)
  material_color_utilities 0.11.1 (0.12.0 available)
< pdfx 2.4.0 (was 2.9.0) (2.9.0 available)
< photo_view 0.14.0 (was 0.15.0) (0.15.0 available)
< uuid 3.0.7 (was 4.5.1) (4.5.1 available)
  vector_math 2.1.4 (2.1.5 available)
  vm_service 14.3.1 (15.0.0 available)
+ win32 5.12.0
+ win32_registry 1.1.5 (2.1.0 available)
These packages are no longer being depended on:
- fixnum 1.1.1
- sprintf 7.0.0
- web 1.1.1
Changed 13 dependencies!
15 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.
% flutter clean
(snip)

 さっきの変更はもとに戻してビルド。

またエラー。

* What went wrong:
A problem occurred configuring project ':pdfx'.
> Could not create an instance of type com.android.build.api.variant.impl.LibraryVariantBuilderImpl.
   > Namespace not specified. Specify a namespace in the module's build file: /Users/kinneko/.pub-cache/hosted/pub.dev/pdfx-2.4.0/android/build.gradle. See https://d.android.com/r/tools/upgrade-assistant/set-namespace for information about setting the namespace.

     If you've specified the package attribute in the source AndroidManifest.xml, you can use the AGP Upgrade Assistant to migrate to the namespace value in the build file. Refer to https://d.android.com/r/tools/upgrade-assistant/agp-upgrade-assistant for general information about using the AGP Upgrade Assistant.

このエラーは、pdfx の 2.4.0 バージョンが Android Gradle Plugin (AGP) 7.0以降 に対応しておらず、namespace が build.gradle に定義されていないため発生しています。

 これも深く追わない。素直に従う。でも、「pdfx: ^2.6.0」って書けって言われたけど、そしたら2.9.0になってしまうのでは? なので2.6.0決め打ちで。

% vi pubspec.yaml
  pdfx: 2.6.0
% flutter pub get
% flutter clean
% flutter run

 うーん、想定通りではあるのだけど、PDFの閲覧が縦スクロールなのね。

image.png

image.png

PDFの閲覧をページ単位のスワイプにできますか?

    scrollDirection: Axis.horizontal で横スクロール
    pageSnap: true により1ページずつのスワイプ切り替えが有効

 変更してみるけど、ビルドエラー。

lib/main.dart:130:9: Error: No named parameter with the name 'pageSnap'.
        pageSnap: true,
        ^^^^^^^^
../../.pub-cache/hosted/pub.dev/pdfx-2.6.0/lib/src/viewer/simple/pdf_view.dart:20:9: Context: Found this candidate, but the arguments don't match.
  const PdfView({
        ^^^^^^^
Target kernel_snapshot_program failed: Exception

pdfx 2.6.0 の PdfView ウィジェットには pageSnap パラメータは存在しません。これは PageView のプロパティであり、PdfView では内部実装に依存しているため、直接使えません。
body: PdfView(
controller: _pdfController,
scrollDirection: Axis.horizontal,
),
これだけで ページ単位のスワイプ は基本的に有効です(各ページが画面全体に表示される仕様)。明示的にスナップ動作を設定する必要はありません。

 いや、おまえだろ...
 コード出せっていったら、だいぶ後退したコード出してきた。

 めんどくさいので手動で修正する。

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('PDFビューア')),
      body: PdfViewPinch(
        controller: _pdfController,
        scrollDirection: Axis.horizontal,
      ),
    );
  }

 ビルド通ったアプリは、必要条件は満たしている感じ。

 PDFの表示位置もよくないかな。これは、まぁいいか。

image.png

 PDF表示画面の「PDFビューア」はよくないので、「メニューに戻る」にする。

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('メニューに戻る')),
      body: PdfViewPinch(
        controller: _pdfController,
        scrollDirection: Axis.horizontal,
      ),
    );
  }

 7インチのタブレットで表示してみると、メニューの画像が小さくして黒枠出てしまう。これはあかんね。

 あと、PDFの起動が遅くてタイムラグがある。データが大きいからしょうがないところあるけど。スクロールもイマイチでスムース感がない。うーん。

image.png

image.png

メニュー画像を端末画面の横幅いっぱいに拡大したい場合は、Image.asset() の fit を BoxFit.fitWidth にし、縦方向にスクロールが発生しないように SingleChildScrollView などを使わずに Center の代わりに SizedBox.expand や Align を使うのが簡潔です。

@override
Widget build(BuildContext context) {
  return GestureDetector(
    onTap: openBook,
    onHorizontalDragEnd: _onSwipe,
    child: Scaffold(
      body: SizedBox.expand(
        child: Image.asset(
          books[currentIndex].imagePath,
          fit: BoxFit.fitWidth,
          alignment: Alignment.center,
        ),
      ),
    ),
  );
}

 まぁ、これでいいんじゃないかな。5秒でメニューの表紙は切り替わるし、スワイプで次の候補にスクロールもできる。

image.png

 ターゲットの10インチNexus10に入れてみる。古すぎでマシンスペック低すぎなので、遅いかな...

 しかし、使ってみるとそこまで遅い感じもない。PDFの展開時間はサイズ依存だな。7インチと違って想定通りPDF画面は全画面に近くなる。面倒なので、7インチ向けの調整はしない。

pdfxでページごとのスクロールを行うことができますか?

PdfView(
  controller: _pdfController,
  scrollDirection: Axis.horizontal, // または Axis.vertical
)

 修正を手パッチする。なんか、PDFのレンダリングも早くなった感じ。ロードも軽い。ページ単位だからかな?

 いまんとこ、こんなコード。

import 'package:flutter/material.dart';
import 'package:pdfx/pdfx.dart';
import 'dart:async';

void main() => runApp(BookPreviewApp());

class BookPreviewApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '電子書籍プレビュー',
      theme: ThemeData.dark(),
      home: BookMenuPage(),
    );
  }
}

class BookMenuPage extends StatefulWidget {
  @override
  _BookMenuPageState createState() => _BookMenuPageState();
}

class _BookMenuPageState extends State<BookMenuPage> {
  final List<BookItem> books = [
    BookItem('assets/sample1_thumb.jpg', 'assets/sample1.pdf'),
    BookItem('assets/sample2_thumb.jpg', 'assets/sample2.pdf'),
    BookItem('assets/sample3_thumb.jpg', 'assets/sample3.pdf'),
  ];

  int currentIndex = 0;
  Timer? _slideshowTimer;

  @override
  void initState() {
    super.initState();
    _startSlideshow();
  }

  void _startSlideshow() {
    _slideshowTimer = Timer.periodic(Duration(seconds: 5), (timer) {
      setState(() {
        currentIndex = (currentIndex + 1) % books.length;
      });
    });
  }

  @override
  void dispose() {
    _slideshowTimer?.cancel();
    super.dispose();
  }

  void _onSwipe(DragEndDetails details) {
    if (details.primaryVelocity == null) return;
    setState(() {
      if (details.primaryVelocity! < 0) {
        currentIndex = (currentIndex + 1) % books.length;
      } else if (details.primaryVelocity! > 0) {
        currentIndex = (currentIndex - 1 + books.length) % books.length;
      }
    });
  }

  void openBook() {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) =>
            PDFViewPage(pdfPath: books[currentIndex].pdfPath),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: openBook,
      onHorizontalDragEnd: _onSwipe,
      child: Scaffold(
        body: SizedBox.expand(
          child: Image.asset(
            books[currentIndex].imagePath,
            fit: BoxFit.fitWidth,
            alignment: Alignment.center,
          ),
        ),
      ),
    );
  }
}

class BookItem {
  final String imagePath;
  final String pdfPath;

  BookItem(this.imagePath, this.pdfPath);
}

class PDFViewPage extends StatefulWidget {
  final String pdfPath;

  PDFViewPage({required this.pdfPath});

  @override
  _PDFViewPageState createState() => _PDFViewPageState();
}

class _PDFViewPageState extends State<PDFViewPage> {
  late PdfController _pdfController;

  @override
  void initState() {
    super.initState();
    _pdfController = PdfController(
      document: PdfDocument.openAsset(widget.pdfPath),
    );
  }

  @override
  void dispose() {
    _pdfController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('メニューに戻る')),
      body: PdfView(
        controller: _pdfController,
        scrollDirection: Axis.horizontal,
      ),
    );
  }
}

 使ってみると、スクロールするにはページの半分以上の距離をドラッグする必要がある。ちょっとつらい。スクロールも残しつつ、左右タップでのページ送りに変更したい。

PdfView を Stack に包み、左右半分に透明な GestureDetector を重ねます:

return Scaffold(
  appBar: AppBar(title: Text('メニューに戻る')),
  body: Stack(
    children: [
      PdfView(
        controller: _pdfController,
        scrollDirection: Axis.horizontal,
      ),
      Row(
        children: [
          // 左半分
          Expanded(
            child: GestureDetector(
              behavior: HitTestBehavior.translucent,
              onTap: () async {
                final page = await _pdfController.page;
                _pdfController.goToPage(page - 1);
              },
            ),
          ),
          // 右半分
          Expanded(
            child: GestureDetector(
              behavior: HitTestBehavior.translucent,
              onTap: () async {
                final page = await _pdfController.page;
                _pdfController.goToPage(page + 1);
              },
            ),
          ),
        ],
      ),
    ],
  ),
);

 変更を反映してビルドするとエラーになる。

lib/main.dart:143:36: Error: The method 'goToPage' isn't defined for the class 'PdfController'.
 - 'PdfController' is from 'package:pdfx/src/viewer/simple/pdf_view.dart' ('../../.pub-cache/hosted/pub.dev/pdfx-2.6.0/lib/src/viewer/simple/pdf_view.dart').
Try correcting the name to the name of an existing method, or defining a method named 'goToPage'.
                    _pdfController.goToPage(page - 1);
                                   ^^^^^^^^
lib/main.dart:153:36: Error: The method 'goToPage' isn't defined for the class 'PdfController'.
 - 'PdfController' is from 'package:pdfx/src/viewer/simple/pdf_view.dart' ('../../.pub-cache/hosted/pub.dev/pdfx-2.6.0/lib/src/viewer/simple/pdf_view.dart').
Try correcting the name to the name of an existing method, or defining a method named 'goToPage'.
                    _pdfController.goToPage(page + 1);
                                   ^^^^^^^^
Target kernel_snapshot_program failed: Exception

pdfx 2.6.0 の PdfController には goToPage() は存在せず、代わりに jumpToPage() を使用します。

 またかよ... おめーだろ!

 修正してビルドする。

 左右タップでページめくりできている。スワイプも有効。

 なんか、悪くないね。新しいページをロードするときにはNexus10では少しだけブラックアウトするけど、これはそんなもんだね。背景白にするとチラチラはおさえられるかな?

 一度表示してキャッシュしたページはすぐに表示される。

pdfx の PdfView は、新しいページを読み込む際に一瞬背景が黒になる仕様があります。これは PdfView が内部でページの読み込み中にデフォルトの背景色(黒)を表示しているためです。

PdfView(
  controller: _pdfController,
  scrollDirection: Axis.horizontal,
  backgroundDecoration: BoxDecoration(
    color: Colors.white, // ← 背景を白に変更
  ),
),

 Clineとかなら勝手にやってくれるのかなとか思いながら手パッチする。ChatGPTはアホなので、セッションが長くなると、前のコードへの変更を出せないことがあったり、変なコードを勝手に入れてきたりするのが面倒ね。

 ビルドする。背景、白くならねー。

pdfx の PdfView において、backgroundDecoration が効かないのは仕様上の制限です。特に Android で PdfRenderer を使用している場合、描画時のページ背景色はネイティブレイヤーで黒に固定されている ため、Flutter側から BoxDecoration(color: ...) で背景色を変更しても影響を与えられません。
Flutter 側から pdfx の描画背景を白にすることは現状 不可能に近く、ネイティブ側のコード修正が必要です。

 おまえな...

 まぁ、これでいいか。アイコン変更したり、署名したりも面倒なのでしない。とりあえず、メニューの画像にはQRを入れた。画面遷移したら同じ絵だとモードが変わったことに気が付かないから。

 リリースビルドする。

% flutter build apk --release
% flutter install --release

 データを増やすには、assetsにデータを入れて、pubspec.yamlにファイルパス追記して、main.dartに追記する。

final List<BookItem> books = [
  BookItem('assets/sample1_thumb.jpg', 'assets/sample1.pdf'),
  BookItem('assets/sample2_thumb.jpg', 'assets/sample2.pdf'),
  BookItem('assets/sample3_thumb.jpg', 'assets/sample3.pdf'),
  BookItem('assets/sample4_thumb.jpg', 'assets/sample4.pdf'), // ← 追加
];

 ビルドする前に以下を実行する。

% flutter pub get

 あと、機能追加するとしたら、PDF表示て一定時間が経過したら、メニューに戻るみたいのかな。面倒だからしないけど。あとは、ファイルをアプリ埋め込みにしているけど、これを外部フォルダ参照にするかどうか。それも面倒だな。

 とりあえず、これで3か所で展示してみたけど、これがNexus10で動いていることを知るとびっくりする人が多かった。必要十分で満足満足。

  試読用のアプリ(展示向け) - YouTube
https://www.youtube.com/watch?v=4l1IUJ0JQdA

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?