다양한 제스처를 이용해 이미지를 수정하는 앱을 구현
제스처 매개변수 | 설명 |
---|---|
onTap | 한 번 탭 했을 때 콜백 함수 실행 |
onDoubleTap | 연속으로 두 번 탭했을 때 콜백 함수 실행 |
onLongPress | 길게 누를 때 콜백 함수 실행 |
onScale | 확대하기를 했을 때 콜백 함수 실행 |
onVerticalDragStart | 수직 드래그가 시작됐을 때 콜백 함수 실행 |
onVerticalDragEnd | 수직 드래그가 끝났을 때 |
onHorizontalDragStart | 수평 드래그가 시작됐을 때 |
onHorizontalDragEnd | 수평 드래그가 끝났을 때 |
onPanStart | 드래그가 시작됐을 때 |
onPanEnd | 드래그가 끝났을 때 |
dependencies:
cupertino_icons: ^1.0.6
flutter:
sdk: flutter
image_gallery_saver: ^2.0.3
image_picker: ^1.0.5
uuid: ^4.3.1
assets:
- asset/img/
안드로이드 11 이전 버전을 사용하면 requestLegacyExternalStorage 옵션을 true로 변경해주어야 한다.
// android/app/src/main/AndroidManifest.xml
<application
android:label="image_editor"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true" #추가
>
</application>
이미지 관련 권한을 추가해주어야 한다.
<dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>사진첩 권한이 필요해요.</string>
<key>NSCameraUsageDescription</key>
<string>카메라 권한이 필요해요.</string>
<key>NSMicrophoneUsageDescription</key>
<string>마이크 권한이 필요해요.</string>
</dict>
import 'dart:io';
import "dart:ui" as ui;
import "dart:typed_data";
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:image_editor/component/footer.dart';
import 'package:image_editor/component/main_app_bar.dart';
import 'package:image_gallery_saver/image_gallery_saver.dart';
import 'package:image_picker/image_picker.dart';
import "package:image_editor/component/sticker_model.dart";
import "package:image_editor/component/emoticon_sticker.dart";
import "package:uuid/uuid.dart";
import "package:flutter/services.dart";
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
XFile? image;
Set<StickerModel> stickers = {};
String? selectedId;
GlobalKey imgKey = GlobalKey(); // 이미지로 전환할 위젯에 입력해줄 키값
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
renderBody(),
Positioned(
top: 0,
left: 0,
right: 0,
child: MainAppBar(
onPickImage: onPickImage,
onSaveImage: onSaveImage,
onDeleteItem: onDeleteItem,
)),
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);
},
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) {
setState(() {
stickers = {
...stickers,
StickerModel(
id: Uuid().v4(),
imgPath: "asset/img/emoticon_$index.png",
)
};
});
}
void onTransform(String id) {
// 스티커가 변형될 때마다 변형중인 스티커를 현재 스티커로 지정
setState(() {
selectedId = id;
});
}
void onPickImage() async {
final image = await ImagePicker().pickImage(source: ImageSource.gallery);
setState(() {
this.image = image;
stickers = {};
});
}
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);
// 저장 후 Snackbar 보여주기
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text("저장되었습니다.")));
}
void onDeleteItem() {
setState(() {
stickers = stickers.where((sticker) => sticker.id != selectedId).toSet();
});
}
}
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,
super.key});
Widget build(BuildContext context) {
return Container(
height: 80,
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';
typedef OnEmoticonTap = void Function(int id);
class Footer extends StatelessWidget {
final OnEmoticonTap onEmoticonTap;
const Footer({required this.onEmoticonTap, super.key});
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(
"asset/img/emoticon_${index + 1}.png",
height: 100,
)),
)),
),
),
);
}
}
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,
super.key});
State<EmoticonSticker> createState() => _EmoticonStickerState();
}
class _EmoticonStickerState extends State<EmoticonSticker> {
double scale = 1; // 확대/축소 배율
double hTransform = 0; // 가로의 움직임
double vTransform = 0; // 세로의 움직임
double actualScale = 1; // 위젯의 초기 크기 기준 확대/축소 배율
Widget build(BuildContext context) {
return Transform(
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(
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))),
);
}
}
class StickerModel {
final String id;
final String imgPath;
const StickerModel({required this.id, required this.imgPath});
// == 로 하나의 인스턴스가 다른 인스턴스와 같은지 비교할 때 사용되는 로직
bool operator ==(Object other) {
return (other as StickerModel).id == id;
}
// Set에서 중복 여부를 결정하는 속성
int get hashCode => id.hashCode;
}
공부하다가 ..을 사용하는 문법이 있는데 이는 함수를 연달아서 사용하는 경우에 사용한다고 한다.