Flutter Screenshot Capture and Sharing: From RepaintBoundary to share_plus

Capture any Flutter widget as an image and share it cross‑platform. Learn RepaintBoundary, screenshot, and share_plus with production tips.

ASOasis
7 min read
Flutter Screenshot Capture and Sharing: From RepaintBoundary to share_plus

Image used for representation purposes only.

Overview

Capturing a widget as an image and sharing it is a common request in Flutter apps: receipts, certificates, profile cards, charts, or achievement badges. In this guide you’ll learn production-ready patterns to capture screenshots of any Flutter widget and share them across platforms using Share Sheets. We’ll cover the low-level RepaintBoundary API, the screenshot package for convenience, quality tuning, long/scrollable content, and platform gotchas.

When to use which approach

  • RepaintBoundary + RenderRepaintBoundary: Most flexible. Ideal when the widget is already on screen and you want full control over quality and timing.
  • screenshot package (ScreenshotController): Fastest to integrate. Great for on-screen capture or rendering a widget off-screen purely for export.
  • Sharing: Use share_plus for a single, cross‑platform API to share files and bytes.

Dependencies

Add the packages you need in pubspec.yaml. Versions are omitted here so you can pick the latest compatible ones.

dependencies:
  flutter:
    sdk: flutter
  share_plus: ^
  path_provider: ^
  # Optional but highly recommended for convenience
  screenshot: ^

Notes:

  • share_plus supports Android, iOS, macOS, Windows, Linux, and Web (using the Web Share API when available).
  • path_provider is used to create a temporary file before sharing (many share targets expect a file URI).

Approach 1: Pure Flutter with RepaintBoundary

RepaintBoundary lets you isolate a widget’s render tree and convert it to a ui.Image that you then encode to PNG. This is the most robust approach and doesn’t require third‑party packages (other than share_plus and path_provider for sharing convenience).

1) Wrap the widget you want to capture

import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';

class CaptureDemo extends StatefulWidget {
  const CaptureDemo({super.key});

  @override
  State<CaptureDemo> createState() => _CaptureDemoState();
}

class _CaptureDemoState extends State<CaptureDemo> {
  final GlobalKey _repaintKey = GlobalKey();

  Future<Uint8List> _capturePng() async {
    // Ensure the frame is fully painted
    await WidgetsBinding.instance.endOfFrame;

    final boundary = _repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary;

    // Use device pixel ratio for crisp output
    final pixelRatio = MediaQuery.of(_repaintKey.currentContext!).devicePixelRatio;

    final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
    final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
    return byteData!.buffer.asUint8List();
  }

  Future<void> _shareScreenshot() async {
    final bytes = await _capturePng();

    // Persist to a temp file (widely supported by share targets)
    final dir = await getTemporaryDirectory();
    final file = File('${dir.path}/capture_${DateTime.now().millisecondsSinceEpoch}.png');
    await file.writeAsBytes(bytes, flush: true);

    await Share.shareXFiles(
      [XFile(file.path)],
      text: 'Shared from my Flutter app',
      subject: 'Check this out',
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('RepaintBoundary Capture')),
      floatingActionButton: FloatingActionButton(
        onPressed: _shareScreenshot,
        child: const Icon(Icons.share),
      ),
      body: Center(
        child: RepaintBoundary(
          key: _repaintKey,
          child: Container(
            padding: const EdgeInsets.all(24),
            color: Colors.white, // Solid background avoids transparency artifacts
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Text('Achievement Badge', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
                const SizedBox(height: 8),
                const FlutterLogo(size: 96),
                const SizedBox(height: 8),
                Text('User: Jane Doe\nScore: 9821', textAlign: TextAlign.center),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Why this works

  • RepaintBoundary creates a separate layer for your widget.
  • RenderRepaintBoundary.toImage rasterizes that layer to a bitmap.
  • Using MediaQuery.of(context).devicePixelRatio yields a crisp export on high‑DPI screens.

Common pitfalls and fixes

  • Blank/low‑quality output: Wait for paint using WidgetsBinding.instance.endOfFrame before calling toImage.
  • Transparent backgrounds look odd in some share targets: Give the root a solid color (e.g., Colors.white).
  • Large widgets can be memory‑heavy: Consider a lower pixelRatio or reduce offscreen effects like shadows/blur.

Approach 2: Using the screenshot package

The screenshot package wraps the RepaintBoundary flow and adds captureFromWidget, which renders a widget off-screen solely for export. This is excellent for sharing content that isn’t currently visible (e.g., a long invoice or a high‑resolution poster).

1) Wrap on-screen content for quick capture

import 'package:flutter/material.dart';
import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart';

class ScreenshotQuickDemo extends StatefulWidget {
  const ScreenshotQuickDemo({super.key});

  @override
  State<ScreenshotQuickDemo> createState() => _ScreenshotQuickDemoState();
}

class _ScreenshotQuickDemoState extends State<ScreenshotQuickDemo> {
  final controller = ScreenshotController();

  Future<void> _share() async {
    final bytes = await controller.capture(delay: const Duration(milliseconds: 10), pixelRatio: 2.0);
    if (bytes == null) return;
    final xfile = XFile.fromData(bytes, name: 'capture.png', mimeType: 'image/png');
    await Share.shareXFiles([xfile], text: 'My on-screen capture');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('screenshot package')),
      floatingActionButton: FloatingActionButton(onPressed: _share, child: const Icon(Icons.share)),
      body: Center(
        child: Screenshot(
          controller: controller,
          child: Card(
            elevation: 2,
            child: Padding(
              padding: const EdgeInsets.all(24),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: const [
                  Text('Receipt #A-1024', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
                  SizedBox(height: 12),
                  Text('Subtotal: $42.00\nTax: $3.15\nTotal: $45.15'),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

2) Capture an off-screen widget at export quality

Future<void> exportHighRes(BuildContext context) async {
  final controller = ScreenshotController();

  // Build an off-screen widget with a fixed export width
  final widgetToExport = InheritedTheme.captureAll(
    context,
    Material(
      color: Colors.white,
      child: ConstrainedBox(
        constraints: const BoxConstraints.tightFor(width: 1080), // px target width
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: List.generate(
              30,
              (i) => Padding(
                padding: const EdgeInsets.symmetric(vertical: 8),
                child: Text('Line ${i + 1} — item description goes here'),
              ),
            ),
          ),
        ),
      ),
    ),
  );

  final bytes = await controller.captureFromWidget(
    widgetToExport,
    pixelRatio: 3.0, // adjust for desired sharpness
    delay: const Duration(milliseconds: 20),
  );

  if (bytes == null) return;
  await Share.shareXFiles([
    XFile.fromData(bytes, name: 'export.png', mimeType: 'image/png')
  ], text: 'High-res export');
}

Why captureFromWidget? It removes dependence on what’s currently on screen. You can render arbitrarily tall/complex layouts just for export without impacting your visible UI.

Sharing strategies with share_plus

Most share targets prefer a file URI, but many will also accept in‑memory bytes. share_plus supports both:

  • File path:
await Share.shareXFiles([XFile('/tmp/capture.png')], text: 'Look!');
  • In-memory bytes:
await Share.shareXFiles([
  XFile.fromData(bytes, name: 'capture.png', mimeType: 'image/png')
]);

Tips:

  • Include a name with .png so targets detect the MIME type.
  • Provide optional subject and text for richer share sheets.

Quality, sizing, and backgrounds

  • Pixel ratio: Higher ratios yield sharper images but consume more memory. 2.0–3.0 is a sweet spot for social sharing.
  • Fixed export width: For predictable results, constrain the export layout to a known width (e.g., 1080 px for social posts) and scale pixelRatio accordingly.
  • Backgrounds: Transparent PNGs can look black in preview on some platforms. Wrap content with a solid Container(color: Colors.white) if needed.
  • Fonts and theme: When capturing off-screen, wrap with Material and InheritedTheme.captureAll(context, child) so the widget inherits the current Theme, DefaultTextStyle, and other inherited data.

Capturing long or scrollable content

Capturing the full length of a ListView/ScrollView that’s on screen usually returns only the visible viewport. Consider these patterns:

  • Build an “export” version of your content as a Column (or sliver list materialized into a Column) without scrolling, and render it off-screen via captureFromWidget.
  • For paginated data, prefetch all pages, then build the export widget from the combined data set.
  • Use ConstrainedBox.tightFor(width: targetWidth) to produce a consistent width; let height be natural based on content.

This approach avoids fragile attempts to stitch multiple viewport captures, and it produces a single sharp image.

Timing: capturing after paint and animations

  • Always wait for a completed frame before calling toImage. WidgetsBinding.instance.endOfFrame is reliable.
  • If the widget is mid‑animation, either await the animation’s completion or add a small delay before capture.
  • If boundary.debugNeedsPaint is true, add a short delay and try again.

Example retry loop:

Future<Uint8List> captureWithRetry(GlobalKey key) async {
  for (int i = 0; i < 3; i++) {
    await WidgetsBinding.instance.endOfFrame;
    final boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary;
    if (!boundary.debugNeedsPaint) {
      final pixelRatio = MediaQuery.of(key.currentContext!).devicePixelRatio;
      final image = await boundary.toImage(pixelRatio: pixelRatio);
      final data = await image.toByteData(format: ui.ImageByteFormat.png);
      return data!.buffer.asUint8List();
    }
    await Future.delayed(const Duration(milliseconds: 16));
  }
  throw Exception('Capture failed after retries');
}

Platform notes and gotchas

  • Android FLAG_SECURE: If a route uses setFlagSecure (or a plugin enforces it), screenshots of that content are blocked by design.
  • Web: RepaintBoundary.toImage works on modern Flutter web backends but can have limitations in some browsers or when using certain shaders. Test across browsers.
  • Desktop: share_plus opens the native share mechanism where available; on Linux it may fall back to a chooser depending on the desktop environment.
  • Permissions: Sharing a file from your app’s temporary directory typically requires no runtime permissions. Saving to the user’s Photos or external storage is a separate concern and may require additional steps.

Performance tips

  • Keep your export widget minimal: avoid heavy shadows, large blurs, or huge images.
  • Prefer vector assets (e.g., icons, custom painting) for crisp results at any pixelRatio.
  • For very large exports, consider chunking your UI into sections and sharing multiple images.

Testing checklist

  • Verify crispness on low‑ and high‑DPI devices (emulators and physical devices).
  • Test the share sheet with multiple targets (Messages, Mail, WhatsApp, Slack, Drive, AirDrop, etc.).
  • Try both file-based sharing and in‑memory bytes.
  • Ensure theme and fonts look correct in off‑screen renders.
  • Confirm that long content is fully included and not clipped.

Putting it all together

For many apps, a hybrid approach works best:

  1. Wrap on-screen widgets you frequently share with RepaintBoundary for quick captures.
  2. For “printable” or very long content, build a dedicated export layout and render it off-screen using screenshot’s captureFromWidget at a fixed width and higher pixelRatio.
  3. Share with share_plus using Share.shareXFiles, preferring a real file for maximum compatibility.

With these patterns, you’ll deliver crisp, reliable screenshots and a frictionless sharing experience across mobile, web, and desktop—without leaving the Flutter ecosystem.

Related Posts