이번 단계에서는 사용자가 스티커 위치를 변경하고, 두 손가락으로 크기나 각도(회전)를 변경할 수 있는 기능을 구현해 봅니다.
이 전 단계 Step 1 링크 : https://totheeden.ddnsgeek.com/flutter-example-photo-sticker-step-1/
구현
sticker_model.dart
// sticker_model.dart (새 파일 또는 main.dart 상단에 추가)
import 'package:flutter/material.dart';
class StickerModel {
String path; // 스티커 이미지 경로
Offset position; // 화면상의 위치 (dx, dy)
double scale; // 크기 비율
double rotation; // 회전 각도 (라디안)
StickerModel({
required this.path,
this.position = Offset.zero, // 기본 위치는 (0,0) 또는 중앙으로 설정 가능
this.scale = 1.0,
this.rotation = 0.0,
});
}
main.dart
import 'dart:io';
import 'dart:math' as math; // 회전에 사용
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
// StickerModel 클래스 (위에 정의한 내용)
class StickerModel {
String path;
Offset position;
double scale;
double rotation;
// 스티커의 고유 ID (여러 스티커를 관리할 때 유용)
final String id;
StickerModel({
required this.path,
required this.id,
this.position = Offset.zero,
this.scale = 1.0,
this.rotation = 0.0,
});
}
// ... (MyApp 클래스는 이전과 동일) ...
class StickerEditorPage extends StatefulWidget {
const StickerEditorPage({super.key});
@override
State<StickerEditorPage> createState() => _StickerEditorPageState();
}
class _StickerEditorPageState extends State<StickerEditorPage> {
File? _selectedImage;
final ImagePicker _picker = ImagePicker();
// --- 스티커 관련 상태 변수 수정 ---
// String? _selectedStickerPath; // 이전 방식: 단일 스티커 경로
StickerModel? _currentSticker; // 현재 활성화/편집 중인 스티커 객체
// List<StickerModel> _addedStickers = []; // 여러 스티커를 관리할 경우
final List<String> _stickerPaths = [
'assets/stickers/sticker1.png',
'assets/stickers/sticker2.png',
];
// ------------------------------------
// 이미지 선택 시 초기 스티커 위치 계산을 위한 GlobalKey
final GlobalKey _imageAreaKey = GlobalKey();
Future<void> _pickImageFromGallery() async {
final XFile? pickedFile = await _picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
setState(() {
_selectedImage = File(pickedFile.path);
_currentSticker = null; // 새 이미지 선택 시 스티커 초기화
// _addedStickers.clear(); // 여러 스티커 사용 시
});
}
}
// --- 스티커 선택 및 추가 함수 수정 ---
void _addSticker(String stickerPath) {
// 이미지 영역의 중앙에 스티커를 배치하기 위한 로직 (옵션)
Offset initialPosition = Offset.zero;
final RenderBox? imageBox = _imageAreaKey.currentContext?.findRenderObject() as RenderBox?;
if (imageBox != null && imageBox.hasSize) {
initialPosition = Offset(imageBox.size.width / 2, imageBox.size.height / 2);
}
setState(() {
_currentSticker = StickerModel(
id: DateTime.now().millisecondsSinceEpoch.toString(), // 고유 ID 생성
path: stickerPath,
position: initialPosition, // 이미지 영역 중앙 또는 기본값
scale: 1.0,
rotation: 0.0,
);
// 여러 스티커를 관리한다면:
// _addedStickers.add(_currentSticker!);
});
}
// ---------------------------
// --- GestureDetector 콜백 함수들 ---
Offset _initialFocalPoint = Offset.zero;
double _initialScale = 1.0;
double _initialRotation = 0.0;
void _onScaleStart(ScaleStartDetails details) {
if (_currentSticker == null) return;
_initialFocalPoint = details.focalPoint;
_initialScale = _currentSticker!.scale;
_initialRotation = _currentSticker!.rotation;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
if (_currentSticker == null) return;
setState(() {
// 위치 변경 (드래그)
// details.focalPointDelta는 이전 업데이트 이후의 focalPoint 변화량입니다.
// details.focalPoint는 현재 두 손가락의 중심점입니다.
// 여기서는 간단하게 focalPointDelta를 사용하지만, 좀 더 정교한 이동을 위해서는
// _initialFocalPoint와 현재 details.focalPoint를 비교하여 이동량을 계산할 수 있습니다.
_currentSticker!.position += details.focalPointDelta;
// 크기 변경
_currentSticker!.scale = _initialScale * details.scale;
// 회전 변경
_currentSticker!.rotation = _initialRotation + details.rotation;
});
}
// ---------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('스티커 편집'),
actions: [
// TODO: 편집 완료/저장 버튼 등 추가
],
),
body: Column(
children: <Widget>[
Expanded(
child: Center(
child: _selectedImage == null
? const Text('사진을 선택해주세요.')
: KeyedSubtree( // 이미지 영역의 크기를 얻기 위해 사용
key: _imageAreaKey,
child: Stack(
alignment: Alignment.center,
children: <Widget>[
Image.file(_selectedImage!),
if (_currentSticker != null)
Positioned(
// 스티커의 중심을 기준으로 위치를 잡기 위해 left, top 조정
left: _currentSticker!.position.dx - (_currentSticker!.scale * 50), // 50은 스티커 기본 너비의 절반 (가정)
top: _currentSticker!.position.dy - (_currentSticker!.scale * 50), // 50은 스티커 기본 높이의 절반 (가정)
child: GestureDetector(
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
// onScaleEnd: _onScaleEnd, // 필요시 사용
child: Transform.rotate(
angle: _currentSticker!.rotation,
child: Transform.scale(
scale: _currentSticker!.scale,
child: Image.asset(
_currentSticker!.path,
width: 100, // 스티커의 기본 너비
height: 100, // 스티커의 기본 높이
fit: BoxFit.contain,
),
),
),
),
),
// 여러 스티커를 표시하려면 _addedStickers 리스트를 순회하며 위와 같이 Positioned와 GestureDetector를 사용
],
),
),
),
),
if (_selectedImage != null)
Container(
height: 120,
padding: const EdgeInsets.symmetric(vertical: 8.0),
color: Colors.grey[200],
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _stickerPaths.length,
itemBuilder: (context, index) {
final stickerPath = _stickerPaths[index];
return GestureDetector(
onTap: () => _addSticker(stickerPath), // _selectSticker에서 _addSticker로 변경
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Container(
// 선택된 스티커 강조는 현재 _currentSticker를 기준으로 하거나,
// 여러 스티커를 다룬다면 선택된 스티커 ID를 별도로 관리해야 함.
decoration: BoxDecoration(
border: _currentSticker?.path == stickerPath // 간단한 강조 표시
? Border.all(color: Colors.blue, width: 2)
: null,
borderRadius: BorderRadius.circular(8),
),
child: Image.asset(
stickerPath,
width: 80,
height: 80,
fit: BoxFit.contain,
),
),
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
onPressed: _pickImageFromGallery,
child: Text(_selectedImage == null ? '사진 선택하기' : '다른 사진 선택'),
),
),
],
),
);
}
}
1.StickerModel 클래스
스티커의 path, position (Offset), scale, rotation을 관리합니다.
id 필드를 추가하여 여러 스티커를 관리할 때 각 스티커를 고유하게 식별할 수 있도록 했습니다.
2.상태 변수 변경
_selectedStickerPath (이전의 스티커 경로) 대신 _currentSticker (StickerModel? 타입)를 사용합니다. 이는 현재 활성화되어 조작 중인 스티커 객체를 나타냅니다.
List _addedStickers = []; 주석 처리된 부분은 여러 스티커를 화면에 동시에 표시하고 싶을 때 사용할 수 있는 리스트입니다.
3._imageAreaKey
GlobalKey를 사용하여 Stack 위젯 (이미지가 표시되는 영역)의 BuildContext에 접근합니다. 이를 통해 스티커를 처음 추가할 때 이미지 영역의 중앙 위치를 계산할 수 있습니다.
4._addSticker(String stickerPath) 함수
새로운 StickerModel 객체를 생성합니다.
id는 현재 시간을 사용하여 간단하게 고유 ID를 만듭니다.
initialPosition: _imageAreaKey를 사용하여 이미지 영역의 크기를 가져와 중앙 위치를 계산하려고 시도합니다. RenderBox가 아직 그려지지 않았을 수 있으므로 null 체크 및 hasSize 확인이 필요합니다.
생성된 StickerModel을 _currentSticker에 할당합니다. (여러 스티커를 사용한다면 _addedStickers 리스트에 추가합니다.)
5.GestureDetector 콜백 함수들
_onScaleStart(ScaleStartDetails details):•사용자가 스티커에 두 손가락을 대거나 드래그를 시작할 때 호출됩니다.
_initialFocalPoint: 제스처 시작 시 두 손가락의 중심점입니다. (드래그 시에는 한 손가락의 위치)
_initialScale: 제스처 시작 시 스티커의 현재 크기 비율입니다.
_initialRotation: 제스처 시작 시 스티커의 현재 회전 각도입니다.
_onScaleUpdate(ScaleUpdateDetails details): 사용자가 손가락을 움직여 크기, 회전 또는 위치를 변경할 때 계속 호출됩니다.
위치 변경 (드래그): _currentSticker!.position += details.focalPointDelta;
details.focalPointDelta: 이전 _onScaleUpdate 이벤트 이후 손가락(들)의 중심점 변화량입니다. 이 값을 현재 위치에 더하여 스티커를 이동시킵니다.
크기 변경: _currentSticker!.scale = _initialScale * details.scale;•details.scale: 제스처 시작 시점(_onScaleStart) 대비 현재 손가락 사이 거리의 비율입니다. 이 비율을 초기 스케일(_initialScale)에 곱하여 새로운 스케일을 계산합니다.
회전 변경: _currentSticker!.rotation = _initialRotation + details.rotation;•details.rotation: 제스처 시작 시점(_onScaleStart) 대비 현재 두 손가락이 이루는 각도의 변화량 (라디안)입니다. 이 값을 초기 회전값에 더하여 새로운 회전 각도를 계산합니다.
setState를 호출하여 UI를 업데이트합니다.
6.build() 메서드 내 스티커 표시 부분
KeyedSubtree(key: _imageAreaKey, …): Stack을 KeyedSubtree로 감싸서 _imageAreaKey를 할당합니다. 이렇게 하면 _imageAreaKey.currentContext를 통해 Stack의 RenderBox에 접근하여 크기를 가져올 수 있습니다.
Positioned 위젯:•left와 top 속성을 _currentSticker!.position.dx와 _currentSticker!.position.dy를 사용하여 동적으로 설정합니다.
중요: Positioned는 위젯의 좌상단 모서리를 기준으로 위치를 잡습니다. 스티커의 중심을 _currentSticker.position으로 사용하려면, 스티커 너비와 높이의 절반만큼 빼주어야 합니다. (_currentSticker!.scale * 50). 여기서 50은 Image.asset의 기본 width와 height인 100의 절반입니다. 이 값은 실제 스티커 이미지 크기에 따라 조절해야 합니다.
GestureDetector 위젯:•스티커 Image를 감싸서 터치 제스처를 감지합니다.
onScaleStart와 onScaleUpdate 콜백을 위에서 정의한 함수들로 연결합니다.
Transform.rotate 및 Transform.scale 위젯: GestureDetector의 자식으로, _currentSticker!.rotation과 _currentSticker!.scale 값을 사용하여 스티커를 실제로 회전시키고 크기를 조절합니다. Transform.rotate가 Transform.scale보다 바깥쪽에 있어야 일반적으로 원하는 대로 동작합니다 (크기 조절 후 회전).
Reference
https://heavenly.tistory.com/entry/Flutter-기본-기능-as-show-hide-변수나-함수-클래스명이-같은-경우-해결-방법-example