외로운 Nova의 작업실

Flutter 프로그래밍 - 9(포토 스티커) 본문

Programming/Flutter

Flutter 프로그래밍 - 9(포토 스티커)

Nova_ 2024. 1. 16. 17:33

안녕하세요, 이번 장에서는 포토 스티커를 사진에 붙이고 수정한 다음, 저장하는 앱을 만들어보겠습니다.

 

- 준비하기

프로젝트 명 : image_editor

 

- 이미지와 폰트 추가

포토 스티커로 사용할 이미지와 폰트를 추가하도록 하겠습니다.

 

- pubspec.yaml 설정하기

이번 프로젝트에서 사용하는 image_picker와 image_gallery_server 패키지를 적용해주겠습니다.

name: image_editor
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1

environment:
  sdk: '>=3.2.4 <4.0.0'

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2
  image_picker: 0.8.4
  image_gallery_saver: 1.7.1
  uuid: 3.0.6

dev_dependencies:
  flutter_test:
    sdk: flutter

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^2.0.0

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter packages.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true
  
  assets:
    - asset/img/

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/assets-and-images/#resolution-aware

  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/assets-and-images/#from-packages

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/custom-fonts/#from-packages

이후 pub get을 눌러줍니다.

 

- 네이티브 권한 설정하기

IOS 먼저 설정하겠습니다. 사진, 카메라, 마이크 권한을 추가하겠습니다. 아래는 info.plist 파일입니다.

    <key>NSPhotoLibraryUsageDescription</key>
    <string>사진첩 권한이 필요해요</string>
    <key>NSCameraUsageDescription</key>
    <string>카메라 권한이 필요해요</string>
    <key>NSMicrophoneUsageDescription</key>
    <string>마이크 권한이 필요해요</string>

 

이제 안드로이드 권한을 설정하겠습니다. 스토리지 권한을 설정해주겠습니다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application
        android:label="image_editor"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher"
        android:requestLegacyExternalStorage="true"> //추가

 

- 프로젝트 초기화 하기

이제 프로젝트를 초기화 하겠습니다. 저번과 동일하게 HomeScreen을 만들어주겠습니다.

import 'package:flutter/material.dart';

class HomeScreen extends StatelessWidget{
  const HomeScreen({Key? key}) : super(key: key);
  
  @override
  Widget build(BuildContext context){
    return Scaffold(
      body: Text("home Screen"),
    );
  }
}

 

아래는 main.dart입니다.

import 'package:flutter/material.dart';
import 'package:image_editor/screen/home_screen.dart';

void main() {
  runApp(const MaterialApp(
    home: HomeScreen(),
  ));
}

 

 

- 구상하기

이번 프로젝트는 하나의 스크린으로 구성되어 있습니다. 아래와 같이 만들것입니다.

 

특징으로는 AppBar와 Footer를 반투명으로 만들 예정입니다.또한 이미지 위에 만들어야하므로 Stack 위젯을 사용하겠습니다.

 

- 앱바 구현하기

먼저 AppBar를 구현하겠습니다. component 디렉토리를 만들고 그안에 main_app_bar.dart 파일을 만들어 넣어놓겠습니다.

import 'package:flutter/material.dart';

class MainAppBar extends StatelessWidget{
  final VoidCallback onPickImage; //이미지 선택 버튼을 눌렀을 때 실행할 함수
  final VoidCallback onSaveImage; //이미지 저장 버튼을 눌렀을 때 실행할 함수
  final VoidCallback onDeleteItem; //이미지 삭제 버튼을 눌렀을 때 실행할 함수

  //생성자
  const MainAppBar({
    required this.onPickImage,
    required this.onSaveImage,
    required this.onDeleteItem,
    Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context){
    return Container(
      height: 100,
      decoration: BoxDecoration(
        color: Colors.white.withOpacity(0.9),
      ),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround, //자식들에게 동일하게 공간 할당
        crossAxisAlignment: CrossAxisAlignment.end, //자식들에게 세로는 맨 위에 할당
        children: [
          IconButton(onPressed: onPickImage,
              icon: Icon(
                Icons.image_search_outlined,
                color: Colors.grey[700], //조금 연한 그레이
              )),
          IconButton(onPressed: onDeleteItem,
              icon: Icon(
                Icons.delete_forever_outlined,
                color: Colors.grey[700],
              )),
          IconButton(onPressed: onSaveImage,
              icon: Icon(
                Icons.save,
                color: Colors.grey[700],
              ))
        ],
      ),
    );
  }
}

 

이제 이를 홈 스크린에 적용하겠습니다.

import 'package:flutter/material.dart';
import 'package:image_editor/component/main_app_bar.dart';

class HomeScreen extends StatelessWidget{
  const HomeScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context){
    return Scaffold(
      body: Stack(
        fit: StackFit.expand, //자식 위젯들 최대 크기로 펼치기
        children: [
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            child: MainAppBar(
              onPickImage: onPickImage,
              onDeleteItem: onDeleteImage,
              onSaveImage: onSaveImage,
            ),
          )
        ],
      ),
    );
  }
  void onPickImage(){}
  void onDeleteImage(){}
  void onSaveImage(){}
}

 

한번 봐보겠습니다.

 

앱바가 잘 나온것을 확인할 수 있습니다.

 

- 이미지 선택 기능

이제 첫 화면에 "이미지 선택하기" 텍스트를 넣고 이미지가 선택되면 그 이미지를 보여주는 기능을 넣어보겠습니다. 이때는 저번처럼 render_body() 함수를 만들어서 리턴해주는 형식으로 구현합니다. 이때 이미지를 선택하려면 State()를 만들어야하기때문에 Statefull로 바꿔주고 Image_picker을 사용하겠습니다. 아래는 homescreen.dart 입니다.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_editor/component/main_app_bar.dart';
import 'package:image_picker/image_picker.dart';

class HomeScreen extends StatefulWidget{
  const HomeScreen({Key? key}) : super(key: key);
  
  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen>{
  
  XFile? image;
  
  @override
  Widget build(BuildContext context){
    return Scaffold(
      body: Stack(
        fit: StackFit.expand, //자식 위젯들 최대 크기로 펼치기
        children: [
          renderBody(),
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            child: MainAppBar(
              onPickImage: onPickImage,
              onDeleteItem: onDeleteImage,
              onSaveImage: onSaveImage,
            ),
          )
        ],
      ),
    );
  }
  void onPickImage() async{
    
    //갤러리에서 이미지 선택
    final image = await ImagePicker().
    pickImage(source: ImageSource.gallery);
    
    setState(() {
      this.image = image;
    });
  }
  void onDeleteImage(){}
  void onSaveImage(){}
  
  Widget renderBody(){
    if(image != null){
      return Positioned.fill(//스택 크기의 최대 크기 차지
        child: InteractiveViewer( //위젯 확대 및 좌우 이동을 가능하게 하는 위젯
          child: Image.file(
            File(image!.path),
            fit: BoxFit.cover //이미지가 부모위젯에 꽉 채워지게함
          ),
        ),
      );
    }
    else{
      return Center(
        child: TextButton(
          style: TextButton.styleFrom(
            primary: Colors.grey,
          ),
          onPressed: onPickImage,
          child: Text("이미지 선택하기"),
        ),
      );
    }
  }
}

 

한번 봐보겠습니다.

잘 나오는 것을 확인할 수 있습니다.

 

 - Footer 구현

이제 아래쪽에 스티커를 고르는 Footer창을 구현해보겠습니다. 이도 component 폴더에 footer.dart 파일로 만들겠습니다.

import 'package:flutter/material.dart';

// 스티커 선택시 실행할 함수의 형태
typedef OnEmoticonTap = void Function(int id);

class Footer extends StatelessWidget {
  final OnEmoticonTap onEmoticonTap;

  const Footer({
    required this.onEmoticonTap,
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white.withOpacity(0.9), // 불투명도 설정
      height: 150,
      child: ListView.builder(
        scrollDirection: Axis.horizontal, // 수평 스크롤
        itemCount: 7,
        itemBuilder: (context, index) {
          return Padding(
            padding: const EdgeInsets.symmetric(horizontal: 8.0),
            child: GestureDetector(
              onTap: () {
                onEmoticonTap(index + 1); // 스티커 선택할 때 실행할 함수
              },
              child: Image.asset(
                "asset/img/emoticon_${index + 1}.png",
                height: 100,
              ),
            ),
          );
        },
      ),
    );
  }
}

 

이제 이것을 home_screen에 적용해보겠습니다.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_editor/component/main_app_bar.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_editor/component/footer.dart';

class HomeScreen extends StatefulWidget{
  const HomeScreen({Key? key}) : super(key: key);

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen>{

  XFile? image;

  @override
  Widget build(BuildContext context){
    return Scaffold(
      body: Stack(
        fit: StackFit.expand, //자식 위젯들 최대 크기로 펼치기
        children: [
          renderBody(),
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            child: MainAppBar(
              onPickImage: onPickImage,
              onDeleteItem: onDeleteImage,
              onSaveImage: onSaveImage,
            ),
          ),
          
          //image 없으면 footer생성 
          if(image != null)
            Positioned(
              bottom: 0,
              left: 0,
              right: 0,
              child: Footer(
                onEmoticonTap: onEmoticonTap,
              ),
            )
        ],
      ),
    );
  }
  void onPickImage() async{

    //갤러리에서 이미지 선택
    final image = await ImagePicker().
    pickImage(source: ImageSource.gallery);

    setState(() {
      this.image = image;
    });
  }
  void onDeleteImage(){}
  void onSaveImage(){}

  Widget renderBody(){
    if(image != null){
      return Positioned.fill(//스택 크기의 최대 크기 차지
        child: InteractiveViewer( //위젯 확대 및 좌우 이동을 가능하게 하는 위젯
          child: Image.file(
            File(image!.path),
            fit: BoxFit.cover //이미지가 부모위젯에 꽉 채워지게함
          ),
        ),
      );
    }
    else{
      return Center(
        child: TextButton(
          style: TextButton.styleFrom(
            primary: Colors.grey,
          ),
          onPressed: onPickImage,
          child: Text("이미지 선택하기"),
        ),
      );
    }
  }
  
  void onEmoticonTap(int index){}
}

 

한번 실행하보겠습니다.

 

잘 나오는 것을 볼 수 있습니다.

 

- 이모티콘 기능 구현

이제 footer에서 이미티콘을 누르면 이미지위에 이모티콘이 생성되고 이를 드래그하거나 이동, 크기를 변경했을때 화면에 적절히 반영되는 기능을 구현하겠습니다. 이때 새로운 위젯을 하나 생성하는 것으로 컴포넌트 밑에 emoticon_sticker.dart파일을 하나 만들겠습니다.

import 'dart:js_interop';

import 'package:flutter/material.dart';

class EmoticonSticker extends StatefulWidget{
  
  final VoidCallback onTransform; //이미지 변경함수
  final String imgPath; //이미지 경로
  final isSelected; //현재 어떤 이모티콘 스티커가 선택되어 있는지 아는 변수
  
const EmoticonSticker({
    required this.onTransform,
    required this.imgPath,
    required this.isSelected,
    Key? key
}) : super(key: key);
  
  @override
  State<EmoticonSticker> createState() => _EmoticonStickerState();
}

class _EmoticonStickerState extends State<EmoticonSticker>{
  
  double scale = 1; //확대 축소 배율
  double hTransform = 0; //가로의 움직임
  double vTransform = 0; //세로의 움직임
  double actualScale = 1; //위젯의 초기 크기 기준 확대/축소 배율
  
  @override
  Widget build(BuildContext conetxt){
    return Transform(transform: Matrix4.identity() //child 위젯을 변형할 수 있는 위젯
    ..translate(hTransform, vTransform) //상하 움직임 정의
    ..scale(scale, scale), //확대  축소 정의
    
    child: Container(
      decoration: widget.isSelected //선택 상태일때만 색상 구현
          ? BoxDecoration(
          borderRadius: BorderRadius.circular(4.0), //모서리 둥글게
          border: Border.all(
            color: Colors.blue,
            width: 1.0,
          )
      )
          :BoxDecoration(
          border: Border.all( //기본적인 테두리를 1로해서 선택할때 깜빡이는 현상 제거
            width: 1.0,
            color: Colors.transparent,
          )
      ),
      child: GestureDetector(
        onTap: (){ //스티커를 눌렀을때 실행할 함수
          widget.onTransform(); //스티커의 상태가 변경될대마다 실행
        },
        onScaleUpdate: (ScaleUpdateDetails details){
          widget.onTransform(); //스티커의 확대 비율이 변경될때 실행

          setState(() {
            scale = details.scale * actualScale; //최근 확대 비율 기반으로 확대 비율 계산

            vTransform += details.focalPointDelta.dy; //세로 이동 거리 계산
            hTransform += details.focalPointDelta.dx; //가로 이동 거리 계산
          });
        },
        onScaleEnd: (ScaleEndDetails details){
          actualScale = scale;
        },//스티커의 확대 비율의 완료되었을때 실행

        child: Image.asset(widget.imgPath),
      ),
    )
    );
  }
}

Transform 위젯으로 이모티콘의 형태가 변했을때, setState()로 하여금 변수를 변경하고 Transform위젯이 변경된 변수를 가지고 위젯의 크기나 모양을 변경하는 코드입니다.

 

- 스티커 붙이기

이제 스티커를 붙일때 필요한 스티커를 클래스로 정의하겠습니다. 여러개 이모티콘이 존재할 수 있기에 id와 imgPath를 가진 클래스로 만들겠습니다. lib/model/sticker_model.dart 파일을 만들겠습니다.

class StickerModel{
  final String id;
  final String imgPath;

  StickerModel({
    required this.id,
    required this.imgPath
});

  @override
  bool operator ==(Object other){ //ID 값이 같은 인스턴스는 같은 스티커로 인식
    return (other as StickerModel).id == id;
  }

  @override //ID값이 같으면 Set 안에서 같은 인스턴스로 인식
  int get hashCode => id.hashCode;
}

 

이제 hoem_screen에서 스티커 선택시 화면에 추가될 스티커를 저장하고 가운데에 스티커를 그려주는 로직을 추가해보겠습니다.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_editor3/component/emoticon_sticker.dart';
import 'package:image_editor3/component/main_app_bar.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_editor3/component/footer.dart';
import 'package:image_editor3/model/sticker_model.dart';

class HomeScreen extends StatefulWidget{
  const HomeScreen({Key? key}) : super(key: key);

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen>{

  XFile? image;
  Set<StickerModel> stickers = {};
  String? selectedId;

  @override
  Widget build(BuildContext context){
    return Scaffold(
      body: Stack(
        fit: StackFit.expand, //자식 위젯들 최대 크기로 펼치기
        children: [
          renderBody(),
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            child: MainAppBar(
              onPickImage: onPickImage,
              onDeleteItem: onDeleteImage,
              onSaveImage: onSaveImage,
            ),
          ),
          
          //image 있으면 footer생성
          if(image != null)
            Positioned(
              bottom: 0,
              left: 0,
              right: 0,
              child: Footer(
                onEmoticonTap: onEmoticonTap,
              ),
            )
        ],
      ),
    );
  }
  void onPickImage() async{

    //갤러리에서 이미지 선택
    final image = await ImagePicker().
    pickImage(source: ImageSource.gallery);

    setState(() {
      this.image = image;
    });
  }
  void onDeleteImage(){}
  void onSaveImage(){}

  Widget renderBody(){
    if(image != null){
      return Positioned.fill(//스택 크기의 최대 크기 차지
        child: InteractiveViewer( //위젯 확대 및 좌우 이동을 가능하게 하는 위젯
          child: Stack(
            fit: StackFit.expand, //크기 최대로 늘려주기
            children: [
              Image.file(
                  File(image!.path),
                  fit: BoxFit.cover //이미지가 부모위젯에 꽉 채워지게함
              ),
              ...stickers.map(
                  (sticker) => Center(
                    child: EmoticonSticker(
                      key: ObjectKey(sticker.id),
                      onTransform: onTransform,
                      imgPath: sticker.imgPath,
                      isSelected: selectedId == sticker.id,
                    )
                  )
              )
            ],
          )
        ),
      );
    }
    else{
      return Center(
        child: TextButton(
          style: TextButton.styleFrom(
            primary: Colors.grey,
          ),
          onPressed: onPickImage,
          child: Text("이미지 선택하기"),
        ),
      );
    }
  }
  
  void onEmoticonTap(int index){}
  void onTransform(){}
}

 

이제 onEmoticonTap()함수를 정의하여 이모티콘을 선택하면 stickers에 스티커를 추가하는 함수를 구현하겠습니다.

  void onEmoticonTap(int index) async {
    setState(() {
      stickers = {
        ...stickers,
        StickerModel(
          id: Uuid().v4(), //스티커 고유 ID
          imgPath: 'asset/img/emoticon_$index.png'
        )
      };
    });
  }

이렇게 하면 stickers에 sticker 모델이 추가됩니다. 이제 스티커를 선택하면 onTransform()함수로 어떤 스티커를 선택했는지 전달되며 이 onTransform함수는 selectedId 변수를 변경하도록 로직을 짜보겠습니다.

 onTransform: (){
                        onTransform(sticker.id);
                      },
                      
  void onTransform(String id){

    setState(() {
      selectedId = id;
    });
  }
}

 

이제 한번 실행해보겠습니다.

 

잘 되는 것을 볼 수 있습니다.

정리하자면 이모티콘을 탭하면 onTapEmoticon()함수가 적용되며 이 함수는 Stickers리스트에 Sticker 모델 인스턴스를 추가하는 함수입니다. 이렇게 추가한후에 setState()함수가 적용되고 이내 BodyRender()함수에서 Sticers 리스트에 있는 모든 스티커들을 반환하는 함수입니다. 이때 각 스티커들은 위치, 배율, 키값들을 가지고 있기에 이에 맞춰 인스턴스를 생성해줘야합니다.

 

- 스티커 삭제하기

이제 스티커 붙이기 기능이 잘 구현됬으니 스티커를 삭제하는 기능을 추가하겠습니다. 삭제버튼은 앱바에 이미 구현해놓았으니 ondeleteItem()함수만 작업해주겠습니다.

  void onDeleteImage() async {
    setState((){
      //선택된 스티커 선택후 set으로 변환
      stickers = stickers.where((sticker) => sticker.id != selectedId).toSet();
    });
  }

한번 실행해보겠습니다.

 

잘 지워지는 것을 확인할 수 있습니다.

 

- 이미지 저장하기

마지막 기능인 이미지 저장 기능을 구현하겠습니다. RepaintBoundary 위젯을 사용해서 위젯을 이미지로 추출한 후 갤러리에 저장하겠습니다. RepaintBoundary는 자식 위젯을 이미지로 추출하는 기능이 있습니다. 이 기능을 사용하려면 key 매개변수를 입력해주고 이미지를 추출할때 이 값을 사용해야합니다. imgKey 변수를 선언하고 renderBody() 함수의 InteractiveViewr 위젯을 RepaintBoundary로 감싸서 이미지를 추출하겠습니다.

  Widget renderBody(){
    if(image != null){
      return RepaintBoundary(
        key: imgKey,
        child: Positioned.fill(//스택 크기의 최대 크기 차지
          child: InteractiveViewer( //위젯 확대 및 좌우 이동을 가능하게 하는 위젯
              child: Stack(
                fit: StackFit.expand, //크기 최대로 늘려주기
                children: [
                  Image.file(
                      File(image!.path),
                      fit: BoxFit.cover //이미지가 부모위젯에 꽉 채워지게함
                  ),
                  ...stickers.map(
                          (sticker) => Center(
                          child: EmoticonSticker(
                            key: ObjectKey(sticker.id),
                            onTransform: (){
                              onTransform(sticker.id);
                            },
                            imgPath: sticker.imgPath,
                            isSelected: selectedId == sticker.id,
                          )
                      )
                  )
                ],
              )
          ),
        )
      );
    }

 

이제 onSaveImage()함수에서 해당 RepainBoundary의 key값으로 해당 위젯을 찾고 바이트 데이터로 형태변경해서 ImageGalleryServer로 저장하는 로직을 짜보겠습니다.

  import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'dart:typed_data';
import 'package:image_gallery_saver/image_gallery_saver.dart';
  
  void onSaveImage() async {
    RenderRepaintBoundary boundary = imgKey.currentContext!
        .findRenderObject() as RenderRepaintBoundary;
    ui.Image image = await boundary.toImage();
    ByteData? byteData = await  image.toByteData(format: ui.ImageByteFormat.png);
    Uint8List pngBytes = byteData!.buffer.asUint8List();
    
    await ImageGallerySaver.saveImage(pngBytes, quality: 100);
    
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text("저장되었습니다."))
    );
  }

 

이제 사용해보겠습니다.

 

잘 저장된 것을 확인할 수 있습니다.

Comments