본문 바로가기
Flutter for Beginners

Flutter 포토 스티커

by Records that rule memory 2025. 4. 21.
728x90

포토 스티커 앱

필수 플러그인

image_picker

 

Flutter 앱에서 이미지 또는 비디오를 카메라 또는 갤러리에서 선택할 수 있도록 해주는 공식 플러그인입니다.

 

https://pub.dev/packages/image_picker

 

image_picker | Flutter package

Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera.

pub.dev

기능 설명
갤러리에서 이미지 선택 사용자의 기기에서 이미지 파일을 가져옴
카메라로 사진 촬영 즉석에서 카메라 실행 후 촬영
갤러리/카메라에서 동영상 선택 또는 촬영 이미지뿐 아니라 비디오도 지원
크기 제한, 압축 설정 maxWidth, maxHeight, imageQuality 조절 가능

 

기본 사용 예시

1. 이미지 선택 (갤러리)

final picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
  File imageFile = File(image.path);
  // 이미지 사용
}

 

2. 사진 촬영 (카메라)

final image = await picker.pickImage(source: ImageSource.camera);

 

3. 비디오 촬영

final video = await picker.pickVideo(source: ImageSource.camera);

 

4. 옵션 설정 예시

await picker.pickImage(
  source: ImageSource.gallery,
  maxWidth: 800,
  maxHeight: 600,
  imageQuality: 80, // 0~100 (압축률)
);

포토 어플리케이션 코드

 

1. 플러그인 추가

dependencies:
  flutter:
    sdk: flutter
  image_picker: ^1.0.4
  permission_handler: ^11.0.1

2. 스티커 이미지 추가

flutter:
  assets:
    - assets/star.png
    - assets/like.png
    - assets/heart.png
    - assets/smile.png

heart.png
0.14MB
like.png
0.12MB
smile.png
0.16MB
star.png
0.11MB

3. 포토 스티커 앱 구조

lib/
├── main.dart                // 진입점
├── screen/
│   └── photo_sticker_page.dart     // 메인 페이지
├── widget/
│   ├── sticker_canvas.dart         // 스티커 + 이미지 영역
│   ├── sticker_toolbar.dart        // 하단 스티커 선택 UI
├── model/
│   └── sticker.dart                // Sticker 클래스 정의
├── util/
│   └── image_saver.dart            // 이미지 저장 유틸리티

 

3.1. app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
...
</manifest>

3.1.1. android.permission.READ_MEDIA_IMAGES

  • Android 13(API 33) 이상에서 도입된 퍼미션
  • 사용자가 사진(이미지) 에만 접근할 수 있게 허용함
  • 기존 READ_EXTERNAL_STORAGE을 대체함 (미디어 접근이 세분화됨)
  • Android 13부터 사용자의 프라이버시 보호를 위해 사진/비디오/오디오를 개별적으로 권한 요청

3.1.2. android.permission.READ_EXTERNAL_STORAGE

  • Android 4.1 ~ 12(API 16~32) 사이에 사용되는 퍼미션
  • 외부 저장소(SD카드 포함)에서 파일을 읽기 위해 필요
  • Android 13(API 33)부터는 READ_MEDIA_IMAGES 등으로 분리되어 더 이상 사용하지 않음

3.1.3. android.permission.WRITE_EXTERNAL_STORAGE

  • Android 4.1 ~ 9(API 16~28) 사이에서 사용
  • 외부 저장소에 파일을 쓰기(저장) 위해 필요
  • Android 10(API 29) 이상에서는 Scoped Storage 도입으로 이 권한이 무시되며, MediaStore 방식이나 app-specific 디렉토리를 사용해야 함

3.2. lib/model/sticker.dart

import 'package:flutter/material.dart';

/// Sticker 클래스는 이미지 위에 추가되는 스티커 객체를 나타냅니다.
/// 각 스티커는 고유 ID, 이미지, 위치(offset), 크기(scale), 회전(rotation) 상태를 가집니다.
/// 제스처 처리(이동, 회전, 확대 축소) 중 상태 저장을 위한 필드도 포함됩니다.
class Sticker {
  /// 각 스티커를 구분하는 고유 ID
  final int id;

  /// 스티커로 사용할 이미지 위젯 (Image.asset 등)
  final Image image;

  /// 현재 위치 (좌상단 기준 좌표)
  Offset offset;

  /// 현재 확대/축소 배율 (1.0이 기본 크기)
  double scale;

  /// 현재 회전 각도 (라디안 단위, 0.0은 회전 없음)
  double rotation;

  /// 제스처 시작 시의 스케일 상태 (scale 초기값 저장용)
  double initialScale;

  /// 제스처 시작 시의 회전 상태 (rotation 초기값 저장용)
  double initialRotation;

  /// 제스처 시작 시의 위치 상태 (offset 초기값 저장용)
  Offset initialOffset;

  /// 생성자 - 기본 scale, rotation은 1.0과 0.0으로 초기화됨
  /// initialXXX 값들은 제스처 시작 시 갱신되어 사용됨
  Sticker({
    required this.id,
    required this.image,
    required this.offset,
    this.scale = 1.0,
    this.rotation = 0.0,
    this.initialScale = 1.0,
    this.initialRotation = 0.0,
    this.initialOffset = Offset.zero,
  });

  /// 같은 스티커인지 비교하기 위해 ID 기준으로 비교 연산자 오버라이드
  @override
  bool operator ==(Object other) => other is Sticker && other.id == id;

  /// 해시코드도 ID 기준으로 계산
  @override
  int get hashCode => id.hashCode;
}

 

3.3. lib/widget/sticker_canvas.dart

import 'dart:io';
import 'package:flutter/material.dart';
import '../model/sticker.dart';


/// Transform	회전과 크기 적용
/// GestureDetector	스티커를 드래그, 핀치, 회전 가능하게 함
/// onScaleStart	조작 시작 시 원래 상태 저장
/// onScaleUpdate	조작 중 회전/확대/이동을 동시에 적용
/// RepaintBoundary	결과 이미지를 저장할 수 있게 캡처 영역 설정

/// StickerCanvas는 배경 이미지 위에 스티커들을 배치하고 조작할 수 있는 화면입니다.
/// 각 스티커는 GestureDetector를 통해 이동, 회전, 스케일 조작이 가능합니다.
class StickerCanvas extends StatefulWidget {
  final File imageFile; // 배경 이미지 파일
  final List<Sticker> stickers; // 현재 스티커 목록
  final Sticker? selectedSticker; // 선택된 스티커
  final GlobalKey repaintKey; // 이미지 저장을 위한 RepaintBoundary 키

  // 스티커 이벤트 콜백
  final Function(Sticker) onSelect; // 스티커 선택 시 콜백
  final Function(Sticker, Offset) onMove; // 이동 시 호출
  final Function(Sticker, double) onScale; // 스케일 조절 시 호출
  final Function(Sticker, double) onRotate; // 회전 시 호출

  const StickerCanvas({
    super.key,
    required this.imageFile,
    required this.stickers,
    required this.selectedSticker,
    required this.repaintKey,
    required this.onSelect,
    required this.onMove,
    required this.onScale,
    required this.onRotate,
  });

  @override
  State<StickerCanvas> createState() => _StickerCanvasState();
}

class _StickerCanvasState extends State<StickerCanvas> {
  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      key: widget.repaintKey, // 저장 시 캡처 대상
      child: Stack(
        children: [
          // 배경 이미지 출력
          Image.file(widget.imageFile),

          // 모든 스티커를 화면에 표시
          ...widget.stickers.map((sticker) {
            return Positioned(
              left: sticker.offset.dx,
              top: sticker.offset.dy,
              child: GestureDetector(
                // 탭하면 해당 스티커를 선택 상태로 지정
                onTap: () => widget.onSelect(sticker),

                // 제스처 시작 시 기준 상태 저장 (이동/회전/스케일의 기준점)
                onScaleStart: (details) {
                  sticker.initialScale = sticker.scale;
                  sticker.initialRotation = sticker.rotation;
                  sticker.initialOffset = sticker.offset;
                },

                // 제스처 중 스케일, 회전, 이동을 동시에 처리
                onScaleUpdate: (details) {
                  if (sticker == widget.selectedSticker) {
                    // 이동: 포컬포인트의 변화량을 offset으로 적용
                    widget.onMove(sticker, details.focalPointDelta);

                    // 확대/축소: details.scale은 상대값이라 누적 적용 필요
                    widget.onScale(sticker, sticker.initialScale * details.scale);

                    // 회전: 누적 회전 적용
                    widget.onRotate(sticker, sticker.initialRotation + details.rotation);
                  }
                },

                // 스티커에 변환 적용 (회전 + 확대/축소)
                child: Transform(
                  alignment: Alignment.center,
                  transform: Matrix4.identity()
                    ..rotateZ(sticker.rotation) // 회전 적용
                    ..scale(sticker.scale),     // 스케일 적용
                  child: Opacity(
                    opacity: sticker == widget.selectedSticker ? 0.7 : 1.0, // 선택 시 살짝 투명
                    child: sticker.image, // 스티커 이미지 출력
                  ),
                ),
              ),
            );
          }),
        ],
      ),
    );
  }
}

 

3.4. lib/widget/sticker_toolbar.dart

import 'package:flutter/material.dart';

/// StickerToolbar는 하단에 표시되는 스티커 선택 바입니다.
/// 사용자는 여기에 표시된 스티커를 탭하여 캔버스에 추가할 수 있습니다.
class StickerToolbar extends StatelessWidget {
  /// 보여줄 스티커 이미지 리스트
  final List<Image> stickers;

  /// 스티커를 탭했을 때 실행할 콜백 함수
  final Function(Image) onTap;

  const StickerToolbar({
    super.key,
    required this.stickers,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 80, // 스티커 바의 높이
      padding: const EdgeInsets.symmetric(vertical: 8), // 상하 패딩

      // 수평 스크롤 가능한 스티커 리스트
      child: ListView(
        scrollDirection: Axis.horizontal,
        children: stickers.map((img) {
          return GestureDetector(
            // 사용자가 스티커를 탭하면 콜백(onTap) 호출
            onTap: () => onTap(img),

            child: Container(
              width: 60,
              height: 60,
              margin: const EdgeInsets.symmetric(horizontal: 8), // 스티커 간 좌우 간격
              decoration: BoxDecoration(
                border: Border.all(color: Colors.grey), // 외곽선
                borderRadius: BorderRadius.circular(8), // 라운드 모서리
              ),
              child: img, // 스티커 이미지 출력
            ),
          );
        }).toList(), // 리스트로 변환
      ),
    );
  }
}

 

3.5. lib/util/image_saver.dart

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

/// save()	RepaintBoundary 영역을 캡처해 DCIM 폴더에 저장
/// _requestPermission()	Android에서 갤러리 접근 권한 요청
/// _showPermissionDialog()	권한 거부 시 설정 화면 유도 다이얼로그 표시

/// ImageSaver 클래스는 Flutter 위젯을 캡처하여 PNG로 저장하고,
/// Android의 DCIM 폴더 아래에 이미지 파일을 저장합니다.
class ImageSaver {
  /// 지정된 RepaintBoundary 위젯을 캡처하여 PNG 파일로 저장합니다.
  static Future<void> save(GlobalKey repaintKey, BuildContext context) async {
    // 저장을 위한 권한 요청
    if (!await _requestPermission(context)) return;

    // RepaintBoundary 위젯을 찾아 렌더링 객체로 변환
    final boundary = repaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
    if (boundary == null) return;

    // 현재 위젯을 이미지로 캡처 (3.0배 고해상도)
    final image = await boundary.toImage(pixelRatio: 3.0);

    // PNG 포맷의 바이트 데이터로 변환
    final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
    final pngBytes = byteData!.buffer.asUint8List();

    // 저장 경로 설정: Android의 DCIM/PhotoSticker 폴더
    final directory = Directory('/storage/emulated/0/DCIM/PhotoSticker');
    if (!directory.existsSync()) {
      directory.createSync(recursive: true); // 폴더가 없으면 생성
    }

    // 파일 이름 생성 (타임스탬프 기반)
    final filename = 'sticker_${DateTime.now().millisecondsSinceEpoch}.png';

    // 파일 객체 생성 및 바이트 쓰기
    final file = File('${directory.path}/$filename');
    await file.writeAsBytes(pngBytes);

    // 저장 완료 메시지 표시
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text("이미지가 갤러리에 저장되었습니다: ${file.path}")),
    );
  }

  /// Android 플랫폼에 따라 저장 권한을 요청합니다.
  static Future<bool> _requestPermission(BuildContext context) async {
    if (Platform.isAndroid) {
      // Android 13 이상: READ_MEDIA_IMAGES 권한 요청
      final photosStatus = await Permission.photos.request();

      // Android 12 이하: storage 권한 요청
      final storageStatus = await Permission.storage.request();

      // 둘 중 하나라도 허용되면 true 반환
      if (photosStatus.isGranted || storageStatus.isGranted) {
        return true;
      } else {
        // 거부되었으면 설정으로 유도
        _showPermissionDialog(context);
        return false;
      }
    } else {
      // iOS는 Info.plist에 권한을 설정하면 자동 허용
      return true;
    }
  }

  /// 저장 권한이 거부되었을 때 사용자에게 설정을 안내하는 다이얼로그 표시
  static void _showPermissionDialog(BuildContext context) {
    showDialog(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text("권한이 필요합니다"),
        content: const Text("이미지를 저장하려면 갤러리 권한이 필요합니다.\n설정에서 권한을 허용해주세요."),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text("취소"),
          ),
          TextButton(
            onPressed: () {
              openAppSettings(); // 설정 앱 열기
              Navigator.pop(context);
            },
            child: const Text("설정 열기"),
          ),
        ],
      ),
    );
  }
}

 

3.6. lib/screen/photo_sticker_page.dart

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

/// save()	RepaintBoundary 영역을 캡처해 DCIM 폴더에 저장
/// _requestPermission()	Android에서 갤러리 접근 권한 요청
/// _showPermissionDialog()	권한 거부 시 설정 화면 유도 다이얼로그 표시

/// ImageSaver 클래스는 Flutter 위젯을 캡처하여 PNG로 저장하고,
/// Android의 DCIM 폴더 아래에 이미지 파일을 저장합니다.
class ImageSaver {
  /// 지정된 RepaintBoundary 위젯을 캡처하여 PNG 파일로 저장합니다.
  static Future<void> save(GlobalKey repaintKey, BuildContext context) async {
    // 저장을 위한 권한 요청
    if (!await _requestPermission(context)) return;

    // RepaintBoundary 위젯을 찾아 렌더링 객체로 변환
    final boundary = repaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
    if (boundary == null) return;

    // 현재 위젯을 이미지로 캡처 (3.0배 고해상도)
    final image = await boundary.toImage(pixelRatio: 3.0);

    // PNG 포맷의 바이트 데이터로 변환
    final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
    final pngBytes = byteData!.buffer.asUint8List();

    // 저장 경로 설정: Android의 DCIM/PhotoSticker 폴더
    final directory = Directory('/storage/emulated/0/DCIM/PhotoSticker');
    if (!directory.existsSync()) {
      directory.createSync(recursive: true); // 폴더가 없으면 생성
    }

    // 파일 이름 생성 (타임스탬프 기반)
    final filename = 'sticker_${DateTime.now().millisecondsSinceEpoch}.png';

    // 파일 객체 생성 및 바이트 쓰기
    final file = File('${directory.path}/$filename');
    await file.writeAsBytes(pngBytes);

    // 저장 완료 메시지 표시
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text("이미지가 갤러리에 저장되었습니다: ${file.path}")),
    );
  }

  /// Android 플랫폼에 따라 저장 권한을 요청합니다.
  static Future<bool> _requestPermission(BuildContext context) async {
    if (Platform.isAndroid) {
      // Android 13 이상: READ_MEDIA_IMAGES 권한 요청
      final photosStatus = await Permission.photos.request();

      // Android 12 이하: storage 권한 요청
      final storageStatus = await Permission.storage.request();

      // 둘 중 하나라도 허용되면 true 반환
      if (photosStatus.isGranted || storageStatus.isGranted) {
        return true;
      } else {
        // 거부되었으면 설정으로 유도
        _showPermissionDialog(context);
        return false;
      }
    } else {
      // iOS는 Info.plist에 권한을 설정하면 자동 허용
      return true;
    }
  }

  /// 저장 권한이 거부되었을 때 사용자에게 설정을 안내하는 다이얼로그 표시
  static void _showPermissionDialog(BuildContext context) {
    showDialog(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text("권한이 필요합니다"),
        content: const Text("이미지를 저장하려면 갤러리 권한이 필요합니다.\n설정에서 권한을 허용해주세요."),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text("취소"),
          ),
          TextButton(
            onPressed: () {
              openAppSettings(); // 설정 앱 열기
              Navigator.pop(context);
            },
            child: const Text("설정 열기"),
          ),
        ],
      ),
    );
  }
}

 

3.7. lib/main.dart

import 'package:flutter/material.dart';
import 'screen/photo_sticker_page.dart';

/// 앱의 진입점 (main 함수)
void main() => runApp(const PhotoStickerApp());

/// PhotoStickerApp은 전체 앱을 감싸는 최상위 위젯입니다.
class PhotoStickerApp extends StatelessWidget {
  const PhotoStickerApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '포토스티커', // 앱의 타이틀 (Android에서는 사용되지 않지만 앱 스위처 등에서 사용될 수 있음)
      home: const PhotoStickerPage(), // 앱 시작 시 표시할 메인 페이지
      debugShowCheckedModeBanner: false, // 디버그 배너 제거
    );
  }
}

스티커 붙이기

4. 앱 실행 아이콘 등록

dev_dependencies:
  flutter_test:
    sdk: flutter
    
flutter_icons:
  android: true
  ios: false
  image_path: "assets/app_icon.png"

app_icon.png
0.36MB

 

4.1. 아이콘 생성 터미널에서 아래 명령어 입력:

flutter pub get
flutter pub run flutter_launcher_icons:main

성공하면 android/app/src/main/res/ 경로 아래에 해상도별 아이콘들이 자동으로 생성됩니다.

 

4.2. APK 빌드하기

아래 명령어로 릴리즈용 APK를 빌드하세요:

flutter build apk --release

phto sticker 실행

 

728x90

'Flutter for Beginners' 카테고리의 다른 글

Flutter 게시판 with Hive  (0) 2025.04.23
Flutter Gemini 앱  (0) 2025.04.22
Flutter Google Map  (0) 2025.04.18
Flutter 영상통화  (0) 2025.04.15
Flutter 비디오 플레이어  (0) 2025.04.10