📷

Flutter × スクロールのある画面でフルスクリーンショットをシェアしたい(パワープレイ)

2024/05/03に公開

はじめに

携わっていた案件でよくあるSNSシェア機能(テキストとスクリーンショット画像の組み合わせ)があったのですが、改修でスクロールしないと見えない部分を含めたフルスクリーンショットで画像をシェアしたい、という要件が上がってきた時の実装備忘録です。

※ゴリゴリにパワープレイで実装したアレな方法ではあるので、胸を張っておすすめはしません。

元々のシェア機能

  • 簡単なテキスト
  • 端末の画面領域(視認できる部分)のスクリーンショット

を以下のライブラリを使用して実装していました。これだけでめちゃくちゃ簡単にシェア機能が実装できるので、さくさくパンダだった記憶です。
https://pub.dev/packages/screenshot
https://pub.dev/packages/share_plus

フルスクリーンショットを撮るのは一筋縄では行かなかった

しかしscreenshotでは、スクロール領域を含めたフルスクリーンショットは撮れない壁にぶち当たる。(できるよ、って言っている人はいたけど私の状況ではできなかった。)
どうしよっかなーとネットをググり散らかして丸2日ほど彷徨い、結果screenshotパッケージを使わないでなんとかする方法に辿り着く。

screenshotを使わないとすると、スクリーンショットの画像を生成する実装を独自で用意しないといけなくはなるが、この辺も有識者たちが載せてたコードをコピペ拝借させてもらう。

参考になったURL
https://stackoverflow.com/questions/53646649/how-to-take-screenshot-of-widget-beyond-the-screen-in-flutter

コード

スクリーンショットを生成するコード

import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';

class SocialShareManager {
  SocialShareManager();

  /// SNSシェア
  Future<void> share({
    required String contents,
    File? screenshot,
  }) async {
    // OSデフォルトシェア
    try {
      await Share.shareXFiles(
        [XFile('${screenshot?.path}')],
        text: contents,
      );
    } on Exception {
      // 略
    }
  }

  /// スクリーンショットをしたい画面をRepaintBoundaryで囲む必要がある
  Future<File?> takeScreenshot(GlobalKey globalKey) async {
    try {
      final boundary = globalKey.currentContext!.findRenderObject()
          as RenderRepaintBoundary?;
      if (boundary != null) {
        final image = await boundary.toImage(pixelRatio: 2);
        final byteData = await image.toByteData(
          format: ui.ImageByteFormat.png,
        );
        final pngBytes = byteData!.buffer.asUint8List();
        final tempDir = await getTemporaryDirectory();
        final imageFile = await File('${tempDir.path}/screenshot.png').create();
        return imageFile.writeAsBytes(pngBytes);
      }
      return null;
    } on Exception catch (e) {
      // 略
    }
  }
}

お馴染みpath_providerを使ってます。
https://pub.dev/packages/path_provider

final image = await boundary.toImage(pixelRatio: 2);
ここのpixelRatioを1(デフォルト)で出力すると、かなり荒い画像になるのでここでは2xを指定して解像度を上げてます。

サンプルの画面

import 'package:flutter/material.dart';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:app/utils/social_share_manager.dart';

class DemoPage extends ConsumerStatefulWidget {
  const DemoPage({super.key});

  
  ConsumerState<DemoPage> createState() => DemoPageState();
}

class DemoPageState extends ConsumerState<DemoPage> {
  final GlobalKey globalKey = GlobalKey();

  
  void initState() {
    super.initState();
  }

  
  Widget build(BuildContext context) {
    final shareManager = ref.read(socialShareManagerProvider);

    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: SingleChildScrollView(
          child: RepaintBoundary(
            key: globalKey,
            child: ColoredBox(
              color: Colors.white,
              child: Column(
                children: <Widget>[
                  AppBar(
                    backgroundColor: Theme.of(context).primaryColor,
                    title: const Text('Scroll Screenshot'),
                  ),
                  LayoutBuilder(
                    builder:
                        (BuildContext context, BoxConstraints constraints) {
                      return ListView.builder(
                        physics: const NeverScrollableScrollPhysics(),
                        shrinkWrap: true,
                        itemCount: 30,
                        itemBuilder: (BuildContext context, int index) {
                          return ListTile(
                            title: Text(
                              'Item ${index + 1}',
                            ),
                          );
                        },
                      );
                    },
                  ),
                ],
              ),
            ),
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () async {
            final screenshot = await shareManager.takeScreenshot(
              globalKey,
            );

            await shareManager.share(
              contents: 'sample text',
              screenshot: screenshot,
            );
          },
          child: const Icon(Icons.screenshot),
        ),
      ),
    );
  }
}

RepaintBoundaryにGlobalKeyをセットしてスクリーンショットしたい領域を囲みます。さらにSingleChildScrollViewで囲ってあげるだけ。
あとは先ほどのtakeScreenshotにGlobalKeyを渡して、スクリーンショットを生成します。

結果

端末で見た画面

フローティングアクションボタンを押すとスクリーンショットを撮ってシェアメニューを開きます。
生成できたフルスクリーンショットがこちら↓

懸念事項

最初にお伝えしたように、これListViewをSingleChildScrollViewでwrapして、一気にリストの中身を描画するパワープレイの成せる技です。ので、リストの中身が多ければ多いほどメモリなどに負荷かけます。

ListViewは画面外のアイテムを自動でdisposeし、表示している(またはpreloadしてる)必要な情報のみをレンダリングしてくれるというメリットがあるのですが、それをSingleChildScrollViewで囲むとListViewのメリットを無視して画面外部分をレンダリングさせてます。ので、使い所にはお気をつけてって感じです。

https://stackoverflow.com/questions/62146197/what-is-the-difference-between-listview-and-singlechildscrollview-in-flutter

2024/05時点

https://pub.dev/packages/scroll_screenshot

フルスクリーンショットできるパッケージをpub.devで出している人がおった。
パッケージのコード読むとこの記事と大体同じようなことをしてそうだったので、サクッと試したい人はこちらをどうぞ。

Discussion