2024. 3. 23. 17:58ㆍ모바일어플개발/Flutter
안녕하세요~ totally 개발자입니다.
플러터 앱을 개발하다보면 플러터 화면에 표시된 위젯들 중 일부 또는 전체를 이미지로 변환해서 저장하는 기능을 제공해야 하는 경우가 있습니다. 이를 구축하기 위해 아래 패키지를 사용할 수 있습니다.
https://pub.dev/packages/flutter_image_saver
flutter_image_saver | Flutter package
Simple and effective cross platform image saver for flutter, supported web and desktop.
pub.dev
Step 1: 먼저 pubspec.yaml 파일을 열어주시고 flutter_image_saver 패키지를 추가합니다.

Step 2: 패키지 dart.ui, rendering, flutter_image_saver를 각각 추가해줍니다.

Step 3: repaintBoundary 변수를 선언합니다.

Step 4: 아래처럼 간단하게 UI를 구성합니다. (모든 소스 코드는 맨 아래에 있습니다)

Step 5: body 부분에 SingleChildScrollView 바깥으로 RepaintBoundary 위젯을 감싸주시고 위에서 선언한 GlobalKey인 repaintBoundary 변수를 할당합니다. 즉 이 방식은 RepaintBoundary 위젯으로 감싸줌으로 원하는 범위를 설정할 수 있도록 해줍니다. 여기에서는 body 전체를 범위로 설정한 것입니다.

Step 6: 아래 save() 메소드를 선언해서 만들어줍니다. 맨 아래에 복사를 위한 전체 코드가 있습니다만 직접 타이핑 해보시면서 과정을 이해하는 것을 권장합니다. 먼저 38-39번째 줄에서 렌더링할 부분을 찾고, 40번째 줄에서 이 범위를 이미지로 변환하고, 41번째 줄에서 이를 바이트 데이터 png로 변환합니다. 42번쨰 줄에서 이 이미지를 flutter.png라는 이름으로 저장합니다. 43-45번째 줄을 통해 저장된 경로가 있는 경우 여부를 판단해서 결과를 표시합니다. (아래 코드는 pub dev 문서와 동일합니다)

Step 7: save 메소드를 호출할 수 있도록 버튼을 만들어줍니다.

Step 8: 안드로이드 설정을 위해 android > app > src > main > AndroidManifest.xml에 들어가셔서 아래 코드를 넣습니다.
android:requestLegacyExternalStorage="true"

Step 9: ios 설정을 위해 ios > Runner > info.plist에 다음 내용을 추가합니다 <string>에 있는 내용은 개발하시는 목적에 따라 문구를 적절하게 작성해주셔야 앱 심사할 때 거절되지 않습니다. 이 점 반드시 유의하시기 바랍니다.
<key>NSPhotoLibraryAddUsageDescription</key>
<string>내용을 이미지로 저장하도록 허용합니다.</string>

Step 10: 실행해서 테스트한 결과입니다.

버튼을 누르면 권한 관련 팝업이 나오고 저장이 됩니다.

아래처럼 갤러리에 들어가면 아래처럼 확인이 됩니다.

[전체 소스 코드]
import 'package:flutter/material.dart'; | |
import 'dart:ui'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter_image_saver/flutter_image_saver.dart'; | |
void main() { | |
runApp(const MyApp()); | |
} | |
class MyApp extends StatelessWidget { | |
const MyApp({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
title: 'Test App', | |
home: const TestView(), | |
debugShowCheckedModeBanner: false, | |
theme: ThemeData( | |
useMaterial3: true, | |
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), | |
), | |
); | |
} | |
} | |
class TestView extends StatefulWidget { | |
const TestView({super.key}); | |
@override | |
State<TestView> createState() => _TestViewState(); | |
} | |
class _TestViewState extends State<TestView> { | |
final repaintBoundary = GlobalKey(); | |
void save() async { | |
final boundary = repaintBoundary.currentContext!.findRenderObject()! | |
as RenderRepaintBoundary; | |
final image = await boundary.toImage(pixelRatio: 2); | |
final byteData = await image.toByteData(format: ImageByteFormat.png); | |
final path = await saveImage(byteData!.buffer.asUint8List(), 'flutter.png'); | |
final message = path.isEmpty ? 'Saved' : 'Saved to $path'; | |
ScaffoldMessenger.of(context) | |
.showSnackBar(SnackBar(content: Text(message))); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title: const Text("Widget To Image"), | |
centerTitle: true, | |
), | |
floatingActionButton: FloatingActionButton( | |
onPressed: save, | |
child: const Icon(Icons.download), | |
), | |
body: RepaintBoundary( | |
key: repaintBoundary, | |
child: SingleChildScrollView( | |
child: Container( | |
color: Colors.white, | |
child: Column( | |
children: [ | |
Text("제목 1", style: TextStyle(fontSize: 24)), | |
Text("내용 1-1"), | |
Text("내용 1-2"), | |
Container( | |
width: 200, | |
height: 200, | |
color: Colors.green, | |
), | |
Text("제목 2", style: TextStyle(fontSize: 24)), | |
Text("내용 2-1"), | |
Text("내용 2-2"), | |
Container( | |
width: 200, | |
height: 200, | |
color: Colors.purple, | |
), | |
], | |
), | |
), | |
), | |
), | |
); | |
} | |
} |