Flutter: การใช้ RenderRepaintBoundary เพื่อเปลี่ยน Widget บนจอ เป็นภาพ PNG

อันนี้มาจดบันทึกไว้ ตอนเขียนแอปที่ต้องการจะเอา Widget ที่แสดงบนจอมาพิมพ์ ซึ่งการ RenderRepaintBoundary สามารถเอามา render ตัว Widget ที่ต้องการ ออกมาในความละเอียดที่ระบุ จากนั้นนำมาเป็นภาพ PNG ได้ ซึ่งสามารถนำเอามาใช้งานต่อได้เช่น เอามาบันทึกเป็นไฟล์ PNG ส่วนงานลูกค้าที่เอามาใช้คือต้องการพิมพ์ออกกระดาษ หรือสร้างเป็นไฟล์ PDF ซึ่ืงมันมี Package ที่ช่วยเรื่องนี้อยู่แล้ว

การใช้งาน RenderRepaintBoundary ไปดูตัวอย่างวิธีใช้ของคนอื่นที่เขาแชร์เอาไว้ ไม่ได้คิดเอง 😅 ต้องขอบคุณสังคมแห่งการแบ่งปัน

กำหนด Widget ที่จะใช้งานกับ RenderRepaintBoundary

การสร้าง RenderRepaintBoundary มีวิธีการดังนี้

RenderRepaintBoundary({
  RenderBox? child,
})

ตัวอย่างสร้าง MyWidget โดยเอาไว้ใน RenderRepaintBoundary เพื่อลองเอามาขยายขนาดเป็น 2 เท่าและแปลงเป็นข้อมูลแบบไฟล์ PNG ดู

class MyWidget extends StatelessWidget {
  const MyWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 200,
      child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
        const Text('This is sample widget'),
        OutlinedButton(onPressed: () {}, child: const Text('sample button')),
        Image.asset('asset/Icon-192.png'),
        const Divider(color: Colors.green)
      ]),
    );
  }
}

สร้าง RenderRepaintBoundary

เพื่อให้เข้าถึงตัว RenderRepaintBoundary ที่สร้างขึ้นได้ในฟังก์ชั่นที่ใช้สร้าง PNG โดยตรง เลยกำหนด GlobalKey() เข้าไปด้วยดังนี้

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

  @override
  State<MainApp> createState() => _MainAppState();
}

class _MainAppState extends State<MainApp> {
  final GlobalKey _globalKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    Widget sampleWidget = RepaintBoundary(key: _globalKey, child: const MyWidget());
  }
}

ในฟังก์ชั่นที่ใช้สำหรับสร้าง PNG ชื่อ _createPng() จะมีการอ้างถึง _globalKey ที่เอาไปผูกไว้กับ RepaintBoundary โดยคำสั่ง .currentContext จะคืนค่า Context ของตัว Widget ที่ render อยู่บนจอ ถ้าเป็น null แสดงว่าไม่สามารถเข้าถึงได้แล้ว ในการเขียนใช้งานจริง ควรใส่ try-catch เพื่อดักจับ Exception ด้วย

class _MainAppState extends State<MainApp> {
  final GlobalKey _globalKey = GlobalKey();
  Future<Uint8List?>? _output; // for store result from _createPng()

  Future<Uint8List?> _createPng() async {
    if (_globalKey.currentContext == null) {
      return null;
    }

    RenderRepaintBoundary boundary = _globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
    double ratio = 2; // render in 2x size
    ui.Image image = await boundary.toImage(pixelRatio: ratio);
    ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
    if (byteData != null) {
      return byteData.buffer.asUint8List();
    } else {
      return null;
    }
  }
}

คำสั่ง .toImage() จะทำงานแบบ asynchronous หากใช้คำสั่ง .toImageSync() จะเป็นแบบ synchronous

ผลการทดสอบ

ตัวอย่าง จะเป็นการแสดง Widget ที่จะใช้ทดลอง ในที่นี้คือ MyWidget และมีปุ่มกด create PNG in x2 size เมื่อกดแล้ว จะเรียก setState() ทำการกำหนดค่า _output = _createPng() และวาดหน้าจอใหม่

  @override
  Widget build(BuildContext context) {
    Widget sampleWidget = RepaintBoundary(key: _globalKey, child: const MyWidget());

    if (_output == null) {
      return MaterialApp(
        home: Scaffold(
          body: Center(
              child: Column(
            children: [
              sampleWidget,
              OutlinedButton(
                  onPressed: () => setState(() => _output = _createPng()),
                  child: const Text('create PNG in x2 size'))
            ],
          )),
        ),
      );
    } else {
      // after user press the button → _output = _createPng()
      return MaterialApp(
        home: Scaffold(
          body: Center(
              child: Column(
            children: [
              sampleWidget,
              const Text('output PNG'),
              Container(
                decoration: BoxDecoration(border: Border.all()),
                child: FutureBuilder(
                  future: _output,
                  builder: (context, snapshot) {
                    if (snapshot.hasData && snapshot.data != null) {
                      return Image.memory(snapshot.data!); // snapshot.data → data from _output
                    } else {
                      return const Text('error: invalid _output');
                    }
                  },
                ),
              )
            ],
          )),
        ),
      );
    }
  }

ผลการทดสอบกดปุ่มเพื่อสร้างภาพ PNG ขนาด 2 เท่า

จากผลการทำงานจะเห็นภาพผลที่ได้ ตัวหนังสือและขอบปุ่มที่แปลงมามีความคมชัด ส่วนภาพ icon ดูเบลอเนื่องจากเป็น bitmap

โปรแกรมทั้งหมด

ไฟล์ main.dart อย่าลืมเตรียมไฟล์ asset/Icon-192.png สำหรับตัวอย่างด้วย หากใช้ภาพอื่นให้เปลี่ยนใน class MyWidget หรือลบทิ้งหากไม่ต้องการ

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

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

void main() {
  runApp(const MainApp());
}

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

  @override
  State<MainApp> createState() => _MainAppState();
}

class _MainAppState extends State<MainApp> {
  final GlobalKey _globalKey = GlobalKey();
  Future<Uint8List?>? _output;

  Future<Uint8List?> _createPng() async {
    if (_globalKey.currentContext == null) {
      return null;
    }

    RenderRepaintBoundary boundary = _globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
    double ratio = 2;
    ui.Image image = await boundary.toImage(pixelRatio: ratio);
    ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
    if (byteData != null) {
      return byteData.buffer.asUint8List();
    } else {
      return null;
    }
  }

  @override
  Widget build(BuildContext context) {
    Widget sampleWidget = RepaintBoundary(key: _globalKey, child: const MyWidget());

    if (_output == null) {
      return MaterialApp(
        home: Scaffold(
          body: Center(
              child: Column(
            children: [
              sampleWidget,
              OutlinedButton(
                  onPressed: () => setState(() => _output = _createPng()),
                  child: const Text('create PNG in x2 size'))
            ],
          )),
        ),
      );
    } else {
      return MaterialApp(
        home: Scaffold(
          body: Center(
              child: Column(
            children: [
              sampleWidget,
              const Text('output PNG'),
              Container(
                decoration: BoxDecoration(border: Border.all(color: const ui.Color.fromARGB(255, 255, 230, 0))),
                child: FutureBuilder(
                  future: _output,
                  builder: (context, snapshot) {
                    if (snapshot.hasData && snapshot.data != null) {
                      return Image.memory(snapshot.data!);
                    } else {
                      return const Text('error: invalid _output');
                    }
                  },
                ),
              )
            ],
          )),
        ),
      );
    }
  }
}

class MyWidget extends StatelessWidget {
  const MyWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 200,
      child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
        const Text('This is sample widget'),
        OutlinedButton(onPressed: () {}, child: const Text('sample button')),
        Image.asset('asset/Icon-192.png'),
        const Divider(color: Colors.green)
      ]),
    );
  }
}