14장 포토 스티커

송기영·2023년 12월 26일
0

플러터

목록 보기
16/25

다양한 제스처를 이용해 이미지를 수정하는 앱을 구현

14.1 사전지식

14.1.1 GestureDetector와 제스처

제스처 매개변수설명
onTap한 번 탭 했을 때 콜백 함수 실행
onDoubleTap연속으로 두 번 탭했을 때 콜백 함수 실행
onLongPress길게 누를 때 콜백 함수 실행
onScale확대하기를 했을 때 콜백 함수 실행
onVerticalDragStart수직 드래그가 시작됐을 때 콜백 함수 실행
onVerticalDragEnd수직 드래그가 끝났을 때
onHorizontalDragStart수평 드래그가 시작됐을 때
onHorizontalDragEnd수평 드래그가 끝났을 때
onPanStart드래그가 시작됐을 때
onPanEnd드래그가 끝났을 때

14.2 준비하기

14.2.1 pubspect.yaml 설정하기

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/

14.2.1 네이티브 권한 설정

안드로이드

안드로이드 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>

IOS

이미지 관련 권한을 추가해주어야 한다.

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

14.3 구현하기

14.3.1 home_screen

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

14.3.2 main_app_bar

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,
                        )),
                  )),
        ),
      ),
    );
  }
}

14.3.4 emoticon_sticker

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))),
    );
  }
}

14.3.5 sticker_model

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;
}

공부하다가 ..을 사용하는 문법이 있는데 이는 함수를 연달아서 사용하는 경우에 사용한다고 한다.

profile
업무하면서 쌓인 노하우를 정리하는 블로그🚀 풀스택 개발자를 지향하고 있습니다👻

0개의 댓글