[flutter] 트리 꾸미기 프로젝트1: 스티커 위치 조정하기

KoEunseo·2023년 12월 7일
0

project

목록 보기
36/37
post-thumbnail

이런거 개발함

tmi

트리 꾸며서 편지를 보내는 계절성 프로젝트를 함 해보려고 대차게 기획을 시작했다. 대략... 이번주 월요일쯤?

하다보니까 좀 촌시려운 것 같아서 기껏 하나하나 그렸던 트리들 포기하고 무료 아이콘 사이트에서 나무 아이콘을 하나 가져와서 쓰고 있지만.... 여턴간

프로바이더를 급하게 공부해서 쓰고 있는데, 가입이랑 로그인은 어찌저찌 구현했다. 그런데 리액트때도 느꼈지만 이 에디터라는게.... 쉽지않다...ㅠㅠ 근데 넘 만들어보고싶은 아이야 에디터란 녀석
최근 했던 기업프로젝트에서 아바타를 구혔했던 것에서 착안해서 비슷하게 하면 되겠거니 했는데, 비슷하지만 또 다르다.

why?

왜 다른가? 전에 프로젝트에서 했던 아바타 아이템은 고정되어있었다. 좌표가 이미 지정되어서 만들어진 에셋들이었기 때문. 내 플젝에서는 스티커가 되었든 글자가 되었든 뭐가됐든 움직여서 유저가 원하는 좌표에 가져다 놓을 수 있어야한다.

Model: 스티커 타입 정의

가장 먼저 한 것은 스티커의 타입을 정의하는 것이었음.
1. 똑같은 name을 가진 스티커가 여러 번 사용될 수 있기 때문에 생성할때 uuid를 통해 id를 생성해 부여한다.
2. top, left 속성은 유저가 지정하는 위치를 가지고 있을 수 있도록 추가했다.
3. width, height은 초기값으로 50씩 적당한 크기를 가지고 생성될 수 있도록 했다. 나중에 유저가 핀치를 통해 조절할 수 있어야 한다.
4. angle도 조정할 수 있도록 속성을 추가해두었다.

class StickerModel {
  final String id;
  final String name;
  double top;
  double left;
  double width;
  double height;
  double angle;
  
  StickerModel({
    required this.id,
    required this.name,
    this.top = 0.0,
    this.left = 0.0,
    this.width = 50.0,
    this.height = 50.0,
    this.angle = 0.0,
  });

postcard_state.dart

트리 하나가 엽서와 같은 기능을 하게 되기 때문에, postcard state에서 스티커를 관리하도록 했다. 사실 계속 고민중이다. 스티커 상태를 따로 분리해서 관리해야할까 싶기도 하구...
분리해봐야 어차피 포스트카드가 스티커, 테마 등을 다 써야하기때문에 결합성이 너무 높아 그게 그거일 것 같다는 생각도 있고... 하다가 리팩토링 하면 되니까 일단 ㄱ

enum PostcardStatus {
  init,
  fetching,
  editting,
  submitting,
  success,
  error,
}
class PostcardState {
  final PostcardStatus postcardStatus;
  final List<StickerModel> stickers;
  final String? themeColor;

  PostcardState({
    required this.postcardStatus,
    required this.stickers,
    required this.themeColor,
  });

  factory PostcardState.init() {
    return PostcardState(
      postcardStatus: PostcardStatus.init,
      stickers: [],
      themeColor: null,
    );
  }
  ... 생략
  1. init
    포스트카드가 생성되면 상태는 init, 스티커는 빈 배열, 테마(테마 컬러)는 null값을 가진 채로 초기화된다.
  2. fetching
    서버로부터 포스트카드를 가져와야 하는 때가 있기 때문에(편지쓸때) 추가함.
  3. editting
    포스트카드를 편집하는 상태
  4. submitting
    편집한 포스트카드를 서버로 전송하는 상태.
  5. success, error
    서버와 통신 성공 혹은 실패하여 에러가 난 상태

postcard_provider.dart

사용한 패키지들

  1. 'package:state_notifier/state_notifier.dart';
    StateNotifier extends하기 위해 사용.
    주로 리버팟이랑 함께 사용하는 것 같다. 프로바이더 만드신 개발자분이 만들었다고 함. 아, 그리고 이거 쓸라면 플러터버전도 함께 받아야한다.
  2. 'package:uuid/uuid.dart';
    위에서 언급했다시피 스티커에 id 주려고 사용함.

extends, with

class PostcardProvider extends StateNotifier<PostcardState> with LocatorMixin {
  PostcardProvider() : super(PostcardState.init());

StateNotifier<PostcardState>: state의 변동사항을 관리하고 알려준다. 구독한다고 보면 됨.
LocatorMixin: 의존성 주입을 위한 믹스인. 외부의 다른 프로바이더를 read할 수 있게 한다. 여러 프로바이더를 가져와서 사용할 수 있게 됨.

스티커 추가하기 기능

 // context를 받는 이유는 스티커가 처음 추가될 때 한가운데 나오게 하기 위해서임. name은 asset 경로를 줌
 void addSticker(BuildContext context, String name) async {
    // 일단 editting 이라고 상태를 변경함
    state = state.copyWith(postcardStatus: PostcardStatus.editting);
    try {
      String id = const Uuid().v1();
      //스티커 생성
      StickerModel newSticker = StickerModel(
        id: id,
        name: name,
        top: MediaQuery.of(context).size.width * 0.3,
      );
	  // 이 부분은 현재 선택된 아이템에 border를 주기 위해 추가한 부분인데 지금은 중요치 않음
      updateSelectedSticker(newSticker);

      // 기존 스티커 리스트에 새로운 스티커 추가.
      // 새 스티커가 기존 스티커의 뒤에 추가되는 이유는 화면상에서 stack이 쌓일 때 맨 아래에 있는 위젯이 맨 위에 올라오기 때문임.
      state = state.copyWith(
        stickers: [
          ...state.stickers, // 기존 스티커
          StickerModel( // 새 스티커
            id: newSticker.id,
            name: newSticker.name,
            top: newSticker.top,
            left: newSticker.left,
            width: newSticker.width,
            height: newSticker.height,
          ),
        ],
      );
    } catch (_) {
      rethrow;
    }
  }

}

스티커 움직이기 기능

처음에는 left, top에 값을 줄 때 clamp로 범위를 고정했는데 그랬더니 그 범위만큼만 스티커가 움직여서 fail. 삭제하니 의도대로 움직인다.

  void updateStickerPosition(
    BuildContext context, // 혹시 몰라 추가했는데 없어도 될듯
    StickerModel sticker, // 타겟 스티커
    double deltaX, // ui상에서 GestureDetector를 통해 전달받는다.
    double deltaY, // 위와 마찬가지
  ) {
    state = state.copyWith(postcardStatus: PostcardStatus.editting);
    // 매개변수로 받은 스티커의 위치가 계속 변화하기 때문에 기존 스티커 리스트에 상태를 업데이트 하기 위해 전체 리스트를 가져온다.
    final updatedStickers = List<StickerModel>.from(state.stickers);
    // 업데이트해야하는 스티커의 위치를 타게팅하기 위해 인덱스를 찾는다.
    final index = updatedStickers.indexOf(sticker);
    if (index != -1) { // -1이 아니라면 = 리스트에 있다면
      updatedStickers[index] = sticker.copyWith( // 타겟 위치에 업데이트
        //@tip: clamp 쓰면 범위가 고정됨. 안쓰는 게 낫다.
        left: (sticker.left + deltaX),
        top: (sticker.top + deltaY),
      );
      //업데이트된 스티커 리스트 상태에 추가
      state = state.copyWith(stickers: updatedStickers);
    }
    print('location mode');
  }

UI

edit_tree.dart

  
  Widget build(BuildContext context) {
  // 전체 스티커 가져오기
    List<StickerModel> stickers = context.watch<PostcardState>().stickers;
  // 선택된 스티커(edit할) 가져오기
    StickerModel? selectedSticker =
        context.watch<PostcardProvider>().selectedSticker;
  // PostcardProvider 가져오기
    PostcardProvider postcardProvider =
        Provider.of<PostcardProvider>(context, listen: false);
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Container(
          decoration: const BoxDecoration(
            shape: BoxShape.circle,
          ),
          clipBehavior: Clip.hardEdge, //다른 assets가 튀어나오지 못하도록 함
          child: Stack(
            children: [
              CircleAvatar(
                radius: MediaQuery.of(context).size.width * 0.45, //화면 너비의 45%정도의 반지름.
                backgroundColor: widget.themeColor ?? AppColors.blueColor, // 기본 theme 컬러 세팅
              ),
              Positioned(
                left: 0, //@tip: 좌우에 0을 주면 가운데 위치하게 된다.
                right: 0,
                bottom: 0,
                child: Image.asset( // 눈내린 땅 이미지
                  'assets/etc/snow_ground.png',
                  fit: BoxFit.cover,
                ),
              ),
              Positioned(
                left: 0, //@tip: 상하좌우에 0을 주어서 한가운데 오도록 함
                right: 0,
                top: 0,
                bottom: 0,
                child: Image.asset(
                  'assets/trees/tree.png',
                ),
              ),
              for (var sticker in stickers)
                Positioned(
                  top: sticker.top,
                  left: sticker.left,
                  child: GestureDetector(
                  //@onPanUpdate가 delta.dx, delta.dy 값을 실시간으로 측정해준다.
                    onPanUpdate: (details) {
                      if (sticker.id == selectedSticker?.id) {
                        postcardProvider.updateStickerPosition(
                          context,
                          sticker,
                          details.delta.dx * 2.0, // @tip: 민감도를 높이기 위해 2를 곱함.
                          details.delta.dy * 2.0,
                        );
                        selectedSticker = sticker;
                      }
                    },
                    onTap: () { //현재 선택되어 가공하고있는 스티커 세팅
                      if (sticker.id != selectedSticker?.id) {
                         postcardProvider.updateSelectedSticker(sticker);
                      }
                    },
                    child: Container( //현재 선택한 스티커에 border를 표시
                      decoration: BoxDecoration(
                        border: Border.all(
                          color: sticker.id == selectedSticker?.id
                              ? AppColors.redColor
                              : Colors.transparent,
                          width: 4,
                        ),
                        borderRadius: BorderRadius.circular(5),
                      ),
                      child: Image.asset(
                        sticker.name,
                        width: sticker.width,
                        height: sticker.height,
                      ),
                    ),
                  ),
                ),
            ],
          ),
        ),
      ],
    );
  }
profile
주니어 플러터 개발자의 고군분투기

0개의 댓글