Flutter Example : Photo Sticker – Step 2

이번 단계에서는 사용자가 스티커 위치를 변경하고, 두 손가락으로 크기나 각도(회전)를 변경할 수 있는 기능을 구현해 봅니다.

이 전 단계 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

댓글 남기기