외로운 Nova의 작업실
Flutter 프로그래밍 - 9(포토 스티커) 본문
안녕하세요, 이번 장에서는 포토 스티커를 사진에 붙이고 수정한 다음, 저장하는 앱을 만들어보겠습니다.
- 준비하기
프로젝트 명 : 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("저장되었습니다."))
);
}
이제 사용해보겠습니다.
잘 저장된 것을 확인할 수 있습니다.
'Programming > Flutter' 카테고리의 다른 글
Flutter 프로그래밍 - 11(일정 관리 앱) (1) | 2024.01.21 |
---|---|
Flutter 프로그래밍 - 10(코팩 튜브) (0) | 2024.01.20 |
Flutter 프로그래밍 - 8(동영상 플레이어) (0) | 2024.01.14 |
Flutter 프로그래밍 - 7(디지털 주사위) (1) | 2024.01.13 |
Flutter 프로그래밍 - 6(만난지 며칠 앱 만들기) (0) | 2024.01.08 |