트리 꾸며서 편지를 보내는 계절성 프로젝트를 함 해보려고 대차게 기획을 시작했다. 대략... 이번주 월요일쯤?
하다보니까 좀 촌시려운 것 같아서 기껏 하나하나 그렸던 트리들 포기하고 무료 아이콘 사이트에서 나무 아이콘을 하나 가져와서 쓰고 있지만.... 여턴간
프로바이더를 급하게 공부해서 쓰고 있는데, 가입이랑 로그인은 어찌저찌 구현했다. 그런데 리액트때도 느꼈지만 이 에디터라는게.... 쉽지않다...ㅠㅠ 근데 넘 만들어보고싶은 아이야 에디터란 녀석
최근 했던 기업프로젝트에서 아바타를 구혔했던 것에서 착안해서 비슷하게 하면 되겠거니 했는데, 비슷하지만 또 다르다.
왜 다른가? 전에 프로젝트에서 했던 아바타 아이템은 고정되어있었다. 좌표가 이미 지정되어서 만들어진 에셋들이었기 때문. 내 플젝에서는 스티커가 되었든 글자가 되었든 뭐가됐든 움직여서 유저가 원하는 좌표에 가져다 놓을 수 있어야한다.
가장 먼저 한 것은 스티커의 타입을 정의하는 것이었음.
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에서 스티커를 관리하도록 했다. 사실 계속 고민중이다. 스티커 상태를 따로 분리해서 관리해야할까 싶기도 하구...
분리해봐야 어차피 포스트카드가 스티커, 테마 등을 다 써야하기때문에 결합성이 너무 높아 그게 그거일 것 같다는 생각도 있고... 하다가 리팩토링 하면 되니까 일단 ㄱ
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,
);
}
... 생략
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');
}
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,
),
),
),
),
],
),
),
],
);
}
Widget