포토 스티커 앱
필수 플러그인
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
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"
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
'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 |