15 포토 스티커 (Gesture Detector)

잠만보·2024년 9월 22일
post-thumbnail

사전 지식

GestureDetector 와 제스쳐

GestureDetector 는 플러터에서 지원하는 모든 제스쳐들을 구현할 수 있는 위젯이다.

흔히 사용하는 제스쳐 정리

사전 준비

pubspec.yaml 설정

다음의 패키지들을 설치해준다.

// 이미지 라이브러리에서 이미지들을 선택하기 위한 패키지
flutter pub add image_picker
// image_picker 로 선택한 이미지들을 갤러리에 저장해주기 위한 패키지
flutter pub add image_gallery_saver
// 간단하고 빠른 RFC4122 및 RFC9562 UUID 생성.
flutter pub add uuid

네이티브 권한 설정하기

iOS 권한 설정

ios/Runner/Info.plist 파일에

사진첩, 카메라, 마이크 권한을 추가한다.

<dict>
    <key>NSPhotoLibraryUsageDescription</key>
    <string>사진첩 권한이 필요해요.</string>
    <key>NSCameraUsageDescription</key>
    <string>카메라 권한이 필요해요.</string>
    <key>NSMicrophoneUsageDescription</key>
    <string>마이크 권한이 필요해요.</string>

    .
    .
    .
</dict>
</plist>

안드로이드 권한 설정하기

안드로이드 11 버전을 기준으로 새로운 파일 저장소가 추가되었다.

만약 사용자 핸드폰이 안드로이드 11 이전 버전이면 기존의 파일 저장소를 사용해야 하기 때문에 requestLegacyExternalStorage 옵션을 true 로 설정해 주어야 한다.
이 값을 true 로 설정하면 모든 버전에서 적절한 파일 저장소를 사용할 수 있다.

<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">   
//================================================================
          
        .
        .
        .

레이아웃 구상하기

홈스크린 위젯

이미지 선택 전에는 화면 중앙에 '이미지 선택하기' 버튼이 보인다.

이미지 선택 후에는 선택한 이미지와 스티커를 고를 수 있는 패널이 생긴다.

이미지 선택 후 화면 위젯 분석

최상단에 AppBar,
중간에 이미지 Body
최하단에 Footer 이렇게 있다.

구현하기

AppBar 구현하기

상태관리는 MainAppBar 위젯을 사용할 HomeScreen 에서 수행하므로 버튼 순서대로 onPressed 매개변수를 각각 onPickImage, onSaveImage, onDeleteItem 이름으로 외부에서 입력받는다.

import 'package:flutter/material.dart';

class MainAppBar extends StatelessWidget {
  final VoidCallback onPickImage; // 이미지 선택 버튼 눌렀을 때 실행할 함수
  final VoidCallback onSaveImage; // 이미지 저장 버튼 눌렀을 때 실행할 함수
  final VoidCallback onDeleteItem; // 이미지 삭제 버튼 눌렀을 때 실행할 함수

  const MainAppBar({
    super.key,
    required this.onPickImage,
    required this.onDeleteItem,
    required this.onSaveImage,
  });

  @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( // 1. 이미지 선택 버튼
            onPressed: onPickImage,
            icon: Icon(
              Icons.image_search_outlined,
              color: Colors.grey[700],
            ),
          ),
          IconButton( // 2. 스티커 삭제 버튼
            onPressed: onDeleteItem,
            icon: Icon(
              Icons.delete_forever_outlined,
              color: Colors.grey[700],
            ),
          ),
          IconButton( // 3. 이미지 저장 버튼
            onPressed: onSaveImage,
            icon: Icon(
              Icons.save,
              color: Colors.grey[700],
            ),
          ),
        ],
      ),
    );
  }
}

AppBar 배치하기

MainAppBar 위젯과 Footer 위젯을 Body 위젯 위에 올려야 하니 Stack 위젯을 이용해서 배치한다.

import 'package:flutter/material.dart';
import 'package:image_editor/component/main_app_bar.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack( // 스크린에 Body, AppBar, Footer 순서로 쌓을 준비
        fit: StackFit.expand, // 1. 자식 위젯들 최대 크기로 펼치기
        children: [
          // MainAppBar 을 좌, 우, 위 끝에 정렬
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            child: MainAppBar(
                onPickImage: onPickImage,
                onDeleteItem: onDeleteItem,
                onSaveImage: onSaveImage),
          ),
        ],
      ),
    );
  }

  void onPickImage() {}

  void onSaveImage() {}

  void onDeleteItem() {}
}

Image 선택하기

ImagePicker 패키지를 사용해서 이미지를 선택하는 창을 사용자에게 보여준다.

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({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  XFile? image; // 선택한 이미지를 저장할 변수
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        // 스크린에 Body, AppBar, Footer 순서로 쌓을 준비
        fit: StackFit.expand, // 1. 자식 위젯들 최대 크기로 펼치기
        children: [
          // MainAppBar 을 좌, 우, 위 끝에 정렬
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            child: MainAppBar(
                onPickImage: onPickImage,
                onDeleteItem: onDeleteItem,
                onSaveImage: onSaveImage),
          ),
        ],
      ),
    );
  }

  void onPickImage() async {
    final image = await ImagePicker().pickImage(source: ImageSource.gallery); // 갤러리에서 이미지 선택하기
    setState(() {
      this.image = image; // 선택한 이미지 저장하기
    });
  }

  void onSaveImage() {}

  void onDeleteItem() {}
}

ImageSource.gallery 를 선택하면 갤러리에서 직접 이미지를 고를 수 있다.
ImageSource.camera 를 선택하면 사용할 이미지를 직접 카메라로 찍을 수 있다.

Body 구현하기

  1. 이미지가 선택되지 않은 상태에서는 '이미지 선택하기' 라고 적힌 버튼을 화면 중앙에 보여줘야 한다.

  2. 이미지가 선택된 상태일때는 선택된 이미지를 화면에 보여줘야 한다.
    추가로 위젯을 확대하고 좌우이동할 수 있는 InteractiveViewer 로 Image 위젯을 감싸서 자동으로 제스처 기능이 적용되도록 하겠다.

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({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  XFile? image; // 선택한 이미지를 저장할 변수
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        // 스크린에 Body, AppBar, Footer 순서로 쌓을 준비
        fit: StackFit.expand, // 1. 자식 위젯들 최대 크기로 펼치기
        children: [
          renderBody(),
          // MainAppBar 을 좌, 우, 위 끝에 정렬
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            child: MainAppBar(
                onPickImage: onPickImage,
                onDeleteItem: onDeleteItem,
                onSaveImage: onSaveImage),
          ),
        ],
      ),
    );
  }

  Widget renderBody() {
    if (image != null) { // 1. 이미지 선택한 경우
      return Positioned.fill( // Stack 크기의 최대만큼 차지하기
        child: InteractiveViewer( // 위젯 확대 및 좌우 이동을 가능하게 하는 위젯
          child: Image.file(
            File(image!.path),
            fit: BoxFit.cover, // 이미지가 부모 위젯 크기 최대를 차지하도록
          ),
        ),
      );
    } else { // 2. 이미지 선택이 안된 경우 이미지 선택 버튼 표시
      return Center(
        child: TextButton(
          style: TextButton.styleFrom(
            foregroundColor: Colors.grey,
          ),
          onPressed: onPickImage,
          child: Text('이미지 선택하기'),
        ),
      );
    }
  }

  void onPickImage() async {
    final image = await ImagePicker()
        .pickImage(source: ImageSource.gallery); // 갤러리에서 이미지 선택하기
    setState(() {
      this.image = image; // 선택한 이미지 저장하기
    });
  }

  void onSaveImage() {}

  void onDeleteItem() {}
}

import 'package:flutter/material.dart';

// 스티커를 선택할 때 마다 실행할 함수의 시그니처
typedef OnEmotionTap = void Function(int id);

class Footer extends StatelessWidget {
  final OnEmotionTap onEmotionTap;

  const Footer({
    super.key,
    required this.onEmotionTap,
  });

  @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: () {
                  onEmotionTap(index + 1); // 스티커 선택할 때 실행할 함수
                },
                child: Image.asset(
                  'asset/img/emoticon_${index + 1}.png',
                  height: 100,
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

EmoticonSticker 위젯 구현하기

이모티콘 스티커를 이미지에 붙이는 기능을 구현해보자

1. 스티커를 붙이는 기능 구현하기

import 'package:flutter/material.dart';

// 스티커를 그리는 위젯
class EmoticonSticker extends StatefulWidget {
  final VoidCallback onTransform;
  final String imPath; // 이미지 경로

  const EmoticonSticker(
      {super.key, required this.onTransform, required this.imPath});

  @override
  State<EmoticonSticker> createState() => _EmoticonStickerState();
}

class _EmoticonStickerState extends State<EmoticonSticker> {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () { // 스티커를 눌렀을 때 실행할 함수
        widget.onTransform(); // 스티커의 상태가 변경될 때마다 실행, 스티커의 상태가 변경이 된다는 걸 부모 위젯에 알리는 역할
      },
      onScaleUpdate: (ScaleUpdateDetails details) { // 스티커의 확대 비율이 변경되었을 때 실행
        widget.onTransform();
      },
      onScaleEnd: (ScaleEndDetails details) {}, // 스티커의 확대 비율 변경이 완료되었을 때 실행
      child: Image.asset(widget.imPath,),
    );
  }
}

2. 제스쳐를 입력받아보는 기능 구현하기

onScaleUpdate 제스쳐ScaleUpdateDetails 값을 첫번째 매개변수로 입력받는다.

ScaleUpdateDetails는 많은 정보를 제공하는데

  1. details.scale확대 배율double 값으로 제공하며, 확대/축소 제스처가 시작된 순간을 기준으로 몇 배율의 변화가 있는지 알려준다.
    이때 배율은 위젯의 초기 크기 기준이 아니기 때문에 확대/축소 제스쳐가 끝나는 순간을 알려주는 onScaleEnd 매개변수가 실행될 때 현재 배율을 꼭 기억해 두어서 배율 계산에 사용하여야 한다.

  2. details.focalPointDelta 는 dx, dy 값을 가지고 있으며 각각 x축 y축으로 이동한 수치를 반환받을 수 있다.

class _EmoticonStickerState extends State<EmoticonSticker> {
  double scale = 1; // 확대, 축소 배율
  double hTransform = 0; // 가로의 움직임
  double vTransform = 0; // 세로의 움직임
  double actualScale = 1; // 확대/축소 제스처가 끝나는 순간의 배율
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () { 
        widget.onTransform(); 
      },
      onScaleUpdate: (ScaleUpdateDetails details) { 
        widget.onTransform();
        setState(() {
          // 현재 제스쳐가 시작된 순간 기준으로 '변경된 배율' '마지막 제스처의 배율' 을 곱해서 '실제 배율' 계산
          scale = details.scale * actualScale; // 위젯의 실제 초기 크기 대비 배울 계산
                 
          // x 와 y 축을 기준으로 이동된 만큼 각각  hTransform vTransform 에 더해서 좌표를 이동
          vTransform += details.focalPointDelta.dy; // 세로 이동 거리 계산
          hTransform += details.focalPointDelta.dx; // 가로 이동 거리 계산
        });
      },
      onScaleEnd: (ScaleEndDetails details) {
        actualScale = scale; // 확대/축소 제스처가 끝나는 순간의 배율 저장
      }, 
      child: Image.asset(widget.imPath,),
    );
  }
}

3. 이모티콘 스티커 여러개를 이미지 하나에 추가하기

이모티콘이 선택된 상태인지 지정하는 isSelected 변수를 선언하고 외부에서 입력받아서 이 값이 true 면 파란색 테두리를 만든다.

import 'package:flutter/material.dart';

// 스티커를 그리는 위젯
class EmoticonSticker extends StatefulWidget {
  final VoidCallback onTransform;
  final String imPath; 
  final bool isSelected; // 스티커가 선택된 상태인지 확인

  const EmoticonSticker({
    super.key,
    required this.onTransform,
    required this.imPath,
    required this.isSelected,
  });

  @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 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(
       
        .
        .
        .
        
    );
  }
}

4. 위젯의 확대/축소 변화 반영하기

Transform 위젯transform 매개변수에는 매트릭스 값을 넣어서 자식 위젯의 변화를 정의한다.

단위 행렬을 하나 선언하고
좌우 상하 움직임을 translate() 함수로 정의하고
확대/축소를 scale() 함수로 정의한다.

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(
        
        .
        .
        .
        
    );
  }
}

스티커 붙이기

1. StickerModel 클래스 구현

StickerModel 클래스를 구현해서 각각 스티커에 필요한 정보(스티커 ID, 스티커 이미지 경로 값)를 저장하겠다.

Map을 사용해서도 구현할 수 있지만 데이터 구조가 강제되지 않아서 상태 관리를 할 때 에는 꼭 클래스를 사용해서 데이터를 구조화하는게 좋다.

class StickerModel {
  final String id; // 스티커 고유 id
  final String imgPath; // 스티커 이미지 경로값

  // 생성자 함수
  StickerModel({
    required this.id,
    required this.imgPath,
});
  @override
  bool operator ==(Object other) { // == 로 같은지 비교할 때 사용되는 로직
    // 하나의 인스턴스가 다른 인스턴스와 같은지 비교할 때 사용
    // ID 값이 같은 인스턴스끼리는 같은 스티커로 인식
    return (other as StickerModel).id == id;
  }

  // Set 등 해시값을 사용하는 데이터 구조에서 사용하는 Getter
  // ID 값이 같으면 Set 안에서 같은 인스턴스로 인식
  @override
  int get hashCode => id.hashCode;
}

2. 화면에 스티커 렌더링하기


.
.
.

class _HomeScreenState extends State<HomeScreen> {
  XFile? image;
  Set<StickerModel> stickers = {}; // 화면에 추가된 스티커를 저장할 변수
  String? selectedId; // 현재 선택된 스티커의 ID

  
  .
  .
  .
  

  Widget renderBody() {
    if (image != null) {
      // 이미지 선택한 경우
      return Positioned.fill(
        // Stack 크기의 최대만큼 차지하기
        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), // 위젯을 구분하는 key 값
                    onTransform: onTransform,
                    imPath: sticker.imgPath,
                    isSelected: selectedId == sticker.id,
                  ),
                ),
              )
            ],
          ),
        ),
      );
    } else {
      // 이미지 선택이 안된 경우 이미지 선택 버튼 표시
      return Center(
        child: TextButton(
          style: TextButton.styleFrom(
            foregroundColor: Colors.grey,
          ),
          onPressed: onPickImage,
          child: Text('이미지 선택하기'),
        ),
      );
    }
  }
  void onTransform() {}

  
.
.
.

}

스티커를 누를 때 마다 새로운 StickerModel 을 stickers 변수에 입력하겠다.

UUID 패키지의 Uuid().v4() 함수를 사용하면 절대로 겹치지 않는 String 값을 생성해낼 수 있다.

 void onEmoticonTap(int index)async {
    setState(() {
      stickers = {
        ...stickers,
        StickerModel(
          id: Uuid().v4(), // 스티커의 고유 ID
          imgPath: 'asset/img/emoticon_$index.png',
        ),
      };
    });
  }

4. selectedId 변수 사용하기

selectedId 변수는 특정 스티커를 탭하거나, 위치가 변경되거나, 확대/축소가 변경되었을 때 현재 조작중인 스티커의 ID로 지정돼야 한다.

Widget renderBody() {
    if (image != null) {
      // 이미지 선택한 경우
      return Positioned.fill(
        // Stack 크기의 최대만큼 차지하기
        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 값을 함수의 매개변수로 전달
                // =============================================
                    },
                    imPath: sticker.imgPath,
                    isSelected: selectedId == sticker.id,
                  ),
                ),
              )
            ],
          ),
        ),
      );
    } else {
      
      .
      .
      .
      
  }

  // 스티커가 변형될 때 마다 변형 중인 스티커를 현재 선택한 스티커로 지정
  void onTransform(String id) {
    setState(() {
      selectedId = id;
    });
  }

스티커 삭제하기

Set 형태인 stickers 변수에 모든 스티커 정보를 다 저장해놨으니 스티커 삭제 버튼이 눌릴 때 마다 stickers 변수에서 각 StickerModel을 순회하며 id 를 비교해서 selectedId 변수에 해당하는 값을 지워주면 된다.

void onDeleteItem() async{
    setState(() {
      // 현재 선택된 스티커 삭제 후 Set 으로 변환
      stickers = stickers.where((sticker)=>sticker.id != selectedId).toSet();
    });
  }

이미지 저장하기

1. RepaintBoundary 위젯 을 사용

RepaintBoundary 위젯을 사용해 위젯을 이미지로 추출한 후 갤러리에 저장하겠다.

RepaintBoundary 위젯
자식 위젯을 이미지로 추출하는 기능이 있다.
이 기능을 사용하려면 RepaintBoundary에 key 매개변수를 입력해주고 이미지를 추출할 때 이 key 값을 사용해야 한다.

GlobalKey imgKey = GlobalKey(); // 이미지로 전환할 위젯에 입력해줄 키 값

Widget renderBody() {
    if (image != null) {
      // 이미지 선택한 경우
      return RepaintBoundary( // 위젯을 이미지로 저장
        key: imgKey,
        child: Positioned.fill(
          
        .
        .
        .
        
      );
    }
  }

2. onSaveImage() 함수에 이미지 저장 기능 구현

GlobalKey인 imgKey 를 선언해두고 RepaintBoundary 위젯의 key 매개변수에 입력했기 때문에 이 imgKey 값을 이용해서 RepaintBoundary의 기능에 접근할 수 있다.

imgKey.currentContext!.findRenderObject() 함수를 실행하면 실제 화면에 렌더링된 RepaintBoundary 위젯을 찾을 수 있다.

void onSaveImage() {
    RenderRepaintBoundary boundary =
        imgKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
  }

3. toImage() 함수를 실행해서 RepaintBoundary의 영역을 이미지로 변환하기

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); // ➋ byte data 형태로 형태 변경
    Uint8List pngBytes = byteData!.buffer.asUint8List(); // ➌ Unit8List 형태로 형태 변경

    await ImageGallerySaver.saveImage(pngBytes, quality: 100);

    ScaffoldMessenger.of(context).showSnackBar(  // ➋ 저장 후 Snackbar 보여주기
      SnackBar(
        content: Text('저장되었습니다!'),
      ),
    );
  }
profile
아프지 말자 - (잘못된 정보, 수정 사항 있으면 언제든지 알려주시면 감사하겠습니다!)

0개의 댓글