728x90
반응형
이전 내용
[플러터] 위치 기반 앱 만들기
이전 내용 [플러터] 비디오 플레이어 앱 만들기이전 내용 [플러터] 주사위 앱 만들기이전 내용 [플러터] D-Day 앱 만들기 (feat. 폰트 반영하기, Cupertino 위젯)이전 내용 [플러터] 이미지 롤링 기능 구
puppy-foot-it.tistory.com
포토 스티커 앱
갤러리에서 이미지를 선택하고, 선택된 이미지를 수정할 수 있는 기능을 구현해 본다.
먼저 사용할 이미지를 [assets] - [img] 폴더에 넣고, 애뮬레이터에서 Files - Downloads 탭에서 나타나는지 확인한다.
그 다음에 pubspec.yaml에 사용할 플러그인들을 추가해 준다.
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
image_picker: ^1.1.2 # 추가
image_gallery_saver: ^2.0.3 # 추가
uuid: ^4.5.1 # 추가
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
assets: # 추가
- assets/img/
다음으로는 AndroidManifest.xml 파일에서 권한 설정을 해준다.
android:requestLegacyExternalStorage="true"
프로젝트 구조
코드
◆ main.dart
import 'package:flutter/material.dart';
import 'package:image_editor/screen/home_screen.dart';
void main() {
runApp(
MaterialApp(
home: HomeScreen(),
),
);
}
◆ screen 디렉터리
- home_screen.dart
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';
import 'package:image_editor/model/sticker_model.dart';
import 'package:image_editor/component/emoticon_sticker.dart';
import 'package:uuid/uuid.dart';
import 'package:flutter/rendering.dart';
import 'dart:ui' as ui;
import 'dart:io';
import 'package:flutter/services.dart';
import 'dart:typed_data';
import 'package:image_gallery_saver/image_gallery_saver.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; // 현재 선택된 스티커 ID
GlobalKey imgKey = GlobalKey(); // 이미지로 번환할 위젯에 입력해줄 키값
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack( // 스크린에 Body, AppBar, Footer 순서로 쌓기
children: [
renderBody(),
MainAppBar(
onPickImage: onPickImage,
onSaveImage: onSaveImage,
onDeleteItem: onDeleteItem,
),
// 이미지 선택 시 Footer에 위치
if (image != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Footer(
onEmoticonTap: onEmoticonTap,
),
),
],
),
);
}
Widget renderBody() {
if (image != null) {
// Stack 크기의 최대 크기만큼 차지
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);
// 스티커의 ID값 함수의 매개변수로 전달
},
imgPath: sticker.imgPath,
isSelected: selectedId == sticker.id,
),
),
),
],
),
),
),
);
} else {
// 이미지 선택이 안 된 경우 이미지 선택 버튼 표시
return Center(
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.grey,
),
onPressed: onPickImage,
child: Text('이미지 선택하기'),
),
);
}
}
void onTransform(String id) {
// 스티커가 변형될 때마다 변형 중인 스티커를 현재 선택한 스티커로 지정
setState(() {
selectedId = id;
});
}
void onEmoticonTap(int index) async {
setState(() {
stickers = {
...stickers,
StickerModel(
id: Uuid().v4(), // 스티커의 고유 ID
imgPath: 'assets/img/emoticon_$index.png',
),
};
});
}
void onPickImage() async {
final image = await ImagePicker().pickImage(source: ImageSource.gallery);
// 갤러리에서 이미지 선택
setState(() {
this.image = image; // 선택한 이미지 저장
});
}
void onDeleteItem() async {
setState(() {
stickers = stickers.where((sticker) => sticker.id != selectedId).toSet();
// 현재 선택돼 있는 스티커 삭제 후 Set로 변환
});
}
void onSaveImage() async {
// 이미지 저장 기능 구현
RenderRepaintBoundary boundary =
imgKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
ui.Image image = await boundary.toImage(); // 바운더리를 이미지로 변경
// byte data 형태로 형태 변경
ByteData? byteData =
await image.toByteData(format: ui.ImageByteFormat.png);
// Uint8List 형태로 형태 변경
Uint8List pngBytes = byteData!.buffer.asUint8List();
// 이미지 저장하기
await ImageGallerySaver.saveImage(pngBytes, quality: 100);
// 저장 후 SnackBar 보여주기
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('저장되었습니다.'),
),
);
}
}
◆ 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;
}
// Set에서 중복 여부 결정 속성
// ID값 같으면 Set 안에서 같은 인스턴스로 인식
@override
int get hashCode => id.hashCode;
}
◆ component 디렉터리
- emotion_sticker.dart
import 'package:flutter/material.dart';
class EmoticonSticker extends StatefulWidget { // 스티커 그리는 위젯
final VoidCallback onTransform;
final String imgPath; // 이미지 경로
final bool 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 context) {
return Transform( // child 위젯 변형 위젯
transform: Matrix4.identity()
..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(
// 테두리는 투명이나 너비는 1로 설정해서 스티커가 선택 및 취소될 때 깜빡이는 현상 제거
border: Border.all(
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,
),
),
),
);
}
}
- 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: SingleChildScrollView( // 가로로 스크롤 가능하게 스티커 구현
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(
7,
(index) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: GestureDetector(
onTap: () {
onEmoticonTap(index + 1); // 스티커 선택 시 실행 함수
},
child: Image.asset(
'assets/img/emoticon_${index + 1}.png',
height: 100,
),
),
),
),
),
),
);
}
}
- 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],
),
),
],
),
);
}
}
★ A problem occurred configuring project ':image_gallery_saver'. 에러 해결 필요
참고 요망 블로그
[참고]
코드팩토리의 플러터 프로그래밍
다음 내용
728x90
반응형
'[앱개발] > Flutter, Dart, Figma' 카테고리의 다른 글
[피그마] 피그마 시작하기 (0) | 2025.05.27 |
---|---|
[플러터] git lfs 로 대용량 파일 깃허브 업로드 (0) | 2025.05.27 |
[플러터] 위치 기반 앱 만들기 (0) | 2025.05.26 |
[플러터] 비디오 플레이어 앱 만들기 (0) | 2025.05.25 |
[플러터] 주사위 앱 만들기 (0) | 2025.05.24 |