TOP
본문 바로가기
[앱개발]/Flutter, Dart, Figma

[플러터] 포토 스티커 앱

by 기록자_Recordian 2025. 5. 26.
728x90
반응형
이전 내용
 

[플러터] 위치 기반 앱 만들기

이전 내용 [플러터] 비디오 플레이어 앱 만들기이전 내용 [플러터] 주사위 앱 만들기이전 내용 [플러터] D-Day 앱 만들기 (feat. 폰트 반영하기, Cupertino 위젯)이전 내용 [플러터] 이미지 롤링 기능 구

puppy-foot-it.tistory.com

 


포토 스티커 앱

 

갤러리에서 이미지를 선택하고, 선택된 이미지를 수정할 수 있는 기능을 구현해 본다.

먼저 사용할 이미지를 [assets] - [img] 폴더에 넣고, 애뮬레이터에서 Files - Downloads 탭에서 나타나는지 확인한다.

 

 

그 다음에 pubspec.yaml에 사용할 플러그인들을 추가해 준다.

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8
  image_picker: ^1.1.2 # 추가
  image_gallery_saver: ^2.0.3 # 추가
  uuid: ^4.5.1 # 추가

dev_dependencies:
  flutter_test:
    sdk: flutter
 
  flutter_lints: ^5.0.0


flutter:

  uses-material-design: true
  
  assets: # 추가
    - assets/img/

 

다음으로는 AndroidManifest.xml 파일에서 권한 설정을 해준다.

android:requestLegacyExternalStorage="true"

 


프로젝트 구조

 

 

 

 

 

 


코드

 

◆ main.dart

import 'package:flutter/material.dart';
import 'package:image_editor/screen/home_screen.dart';

void main() {
  runApp(
      MaterialApp(
        home: HomeScreen(),
      ),
  );
}

 

◆ screen 디렉터리

- home_screen.dart

import 'package:flutter/material.dart';
import 'package:image_editor/component/main_app_bar.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_editor/component/footer.dart';
import 'package:image_editor/model/sticker_model.dart';
import 'package:image_editor/component/emoticon_sticker.dart';
import 'package:uuid/uuid.dart';
import 'package:flutter/rendering.dart';
import 'dart:ui' as ui;
import 'dart:io';
import 'package:flutter/services.dart';
import 'dart:typed_data';
import 'package:image_gallery_saver/image_gallery_saver.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

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

class _HomeScreenState extends State<HomeScreen> {
  XFile? image; // 선택한 이미지 저장 변수
  Set<StickerModel> stickers = {}; // 화면에 추가된 스티커를 저장할 변수
  String? selectedId; // 현재 선택된 스티커 ID
  GlobalKey imgKey = GlobalKey(); // 이미지로 번환할 위젯에 입력해줄 키값

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack( // 스크린에 Body, AppBar, Footer 순서로 쌓기
        children: [
          renderBody(),
          MainAppBar(
            onPickImage: onPickImage,
            onSaveImage: onSaveImage,
            onDeleteItem: onDeleteItem,
          ),
          // 이미지 선택 시 Footer에 위치
          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);
                            // 스티커의 ID값 함수의 매개변수로 전달
                          },
                          imgPath: sticker.imgPath,
                          isSelected: selectedId == sticker.id,
                        ),
                      ),
                ),
              ],
            ),
          ),
        ),
      );
    } else {
      // 이미지 선택이 안 된 경우 이미지 선택 버튼 표시
      return Center(
        child: TextButton(
          style: TextButton.styleFrom(
            foregroundColor: Colors.grey,
          ),
          onPressed: onPickImage,
          child: Text('이미지 선택하기'),
        ),
      );
    }
  }

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

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

  void onPickImage() async {
    final image = await ImagePicker().pickImage(source: ImageSource.gallery);

    // 갤러리에서 이미지 선택
    setState(() {
      this.image = image; // 선택한 이미지 저장
    });
  }

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

  void onSaveImage() async {
    // 이미지 저장 기능 구현
    RenderRepaintBoundary boundary =
    imgKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
    ui.Image image = await boundary.toImage(); // 바운더리를 이미지로 변경
    // byte data 형태로 형태 변경
    ByteData? byteData =
    await image.toByteData(format: ui.ImageByteFormat.png);
    // Uint8List 형태로 형태 변경
    Uint8List pngBytes = byteData!.buffer.asUint8List();

    // 이미지 저장하기
    await ImageGallerySaver.saveImage(pngBytes, quality: 100);

    // 저장 후 SnackBar 보여주기
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text('저장되었습니다.'),
      ),
    );
  }
}

 

 

◆ model 디렉터리

- sticker_model.dart

class StickerModel {
  final String id;
  final String imgPath;

  StickerModel({
    required this.id,
    required this.imgPath,
});

  @override
  bool operator == (Object other) {
    // ID 값이 같은 인스턴스 끼리는 같은 스티컬고 인식
    return (other as StickerModel).id == id;
  }

  // Set에서 중복 여부 결정 속성
  // ID값 같으면 Set 안에서 같은 인스턴스로 인식
  @override
  int get hashCode => id.hashCode;
}

 

◆ component 디렉터리

- emotion_sticker.dart

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,
    Key? key,
  }) : super(key: key);

  @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 Transform( // child 위젯 변형 위젯
      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(
            // 테두리는 투명이나 너비는 1로 설정해서 스티커가 선택 및 취소될 때 깜빡이는 현상 제거
            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,
          ),
        ),
      ),
    );
  }
}

 

- footer.dart

import 'package:flutter/material.dart';

// 스티커 선택 시마다 실행할 함수의 시그니처
typedef OnEmoticonTap = void Function(int id);
class Footer extends StatelessWidget {
  final OnEmoticonTap onEmoticonTap;

  const Footer({
    required this.onEmoticonTap,
    Key? key
}) : super(key: key);

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

 

- main_app_bar.dart

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,

    Key? key,
}) : super(key: key);

  @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( // 이미지 선택 버튼
            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],
            ),
          ),
        ],
      ),
    );
  }
}

 

 

★ A problem occurred configuring project ':image_gallery_saver'. 에러 해결 필요

참고 요망 블로그

https://velog.io/@gogogi313/%EC%B9%98%ED%84%B03.-gallerysaver-namespace-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0

https://karina-winter.tistory.com/entry/Flutter-imagegallerysaver-%ED%8C%A8%ED%82%A4%EC%A7%80-%EB%B9%8C%EB%93%9C-%EC%97%90%EB%9F%AC-%ED%95%B4%EA%B2%B0%EB%B0%A9%EB%B2%95

 

 

 


[참고]

코드팩토리의 플러터 프로그래밍

 


다음 내용

 

728x90
반응형