Lettering 1차 릴리즈가 된지 2달 정도 지났다.
전시회를 통해 서비스를 소개하기도 하고, 크리스마스 이벤트 등 운영을 이어나가고 있다. 회고를 쓰려고 했지만 이런저런 일들로 인해 이제야 약간의 회고를 남기기로 했다.
프론트엔드는 총 2명으로 각자 페이지별 담당이었지만, 컨벤션 정하기, 컴포넌트 제작, 가이드 페이지 제작 등 각자의 작업 스타일을 맞추는 과정을 거쳤다. 기존에 같이 작업을 했던 팀원이라 크게 어려운 상황도 없었고, 깃허브를 통해서 코드 리뷰를 활발하게 했던 점이 큰 도움이 되었다. 좋은 문화를 적극적으로 제안해준 진주에게 감사할 뿐이다..
백엔트 팀원(규민 오빠)이 추천해준 지라도 초반에는 이용하기 어려웠지만, 마감기한이 다 되어서 QA를 진행할 때에 지라만 보면서 내가 할일을 파악했다. 진행상황 공유도 용이해서 편했다. 현직자가 추천하는 건 다 이유가 있는 거 같다…
직장 다니며, 혼자서 백엔드 업무를 다 하기도 어려웠을텐데 지라에 등록하면 오류 처리도 말끔히 해주어 너무너무 고마웠다.
개발자가 개발에만 집중할 수 있도록 도와준 기획팀(동우, 민지언니, 규리)에게도 감사의 말을 전하고 싶다. 사실 기획팀에서 어영부영 말을 바꾸거나 기한을 애매하게 하면 개발 측이 많이 스트레스 받을 수 있는데, 애초에 진행 방향과 목표도 명확했고 마감 기한도 엄격하게 지키도록 노티를 자주 해주어 따라가는 팀원으로서는 좋았다. (동아리가 아닌 자체적인 프로젝트여서 마감 기한을 계속해서 미루는 상황이 나올까 걱정했었다) 다만 QA 기간이 좀 더 길었다면 어땠을까라는 아쉬움이 있긴하다. 레터링 모두가 내 작업 스타일에는 너무나도 맞는 팀원들이었다.
서비스 특성 상 디자인이 중요하다고 기획 초기에도 많이 이야기가 나왔던 것 같다. 편지를 보내고 받는 아카이빙 서비스인 Lettering은 사실 기능적 요소 보다도 사용자의 감성을 자극하는게 더 크기 때문에 디자인이 1순위가 아닐까….많이 걱정했었다. 중간에 팀원 보충의 문제도 있었지만 천재 디자이너를 섭외했기 때문에…! 아주 아름다운 서비스가 완성되었다. 😊
레터링의 이야기는 인스타그램에서도 확인가능!!!
instagram
가장 많이 받은 질문이자, 구현 당시 제일 걱정했던 부분이었다… 사용자 입장에서는 이런 인터랙션이 있어야 서비스에 대해 더 흥미를 가지리라 생각했기에 꼭! 있어야 할 기능이라고 판단했다. 내가 구현한 방식이 100% 정답은 아니겠지만 기록하는 겸…남겨보기로!!
드래그를 위한 레터링의 홈 화면을 보면 행성(Planet) / 궤도에 있는 편지(Bottom) / 편지(Tag) 크게 3가지 영역으로 나눌 수 있다. 편지(Tag)를 드래그앤 드롭해서 Planet 영역 안에서 Touch End 시 Planet에 편지(Tag)가 추가 되어야 한다.
드래그를 위한 함수들은 모두 Planet Page에 존재한다. 이 함수들을 Props로 통해 Bottom → Letter로 전달한다.
초반에 구현을 할때는 드래그 기능만을 활용해 구현을 했다. 이때 문제가 발생하는데, 웹에서는 잘 작동했지만, 모바일 브라우저에서는 draggable 속성과 drag event가 잘 작동하지 않았다. 모바일로 구현시에는 touch event를 통해 구현해야 했다. 드래그가 작동되는 방식을 보자면, isDragable이 true일 때, 드래그가 시작되고 drag가 시작될때 각 handleDragStart와 drag가 끝나면 handleDragEnd가 실행되게 된다. 그러면 touch Event 또한 TouchStart, TouchEnd를 설정해주면 금방 끝나겠거니 생각했었다. 하지만 Touch가 진행될 때의 이벤트 또한 고려를 해야했기에 작업 시간이 제법 소요되었다.
참고-(https://velog.io/@badahertz52/Drag-and-Drop-구현하기#3-mouse-event--touch-event)
이
요랬는데
<Box
(생략)
draggable={isDragable}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
이래 됐거든요
<Box
$tagType={tagType}
$hasName={!!name}
$hasEditIcon={icon === "edit"}
{...(orbitType ? { $orbitType: orbitType } : {})}
onClick={onHold ? handleHoldEnd : handleBoxClick}
ref={(el) => {
tagRef.current = el;
if (innerRef) innerRef(el);
}}
draggable={isDragable}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onMouseDown={handleHoldStart}
onMouseUp={handleHoldEnd}
onTouchStart={handleTouchStart}
>
웹에서 하는 드래그드롭은 따로 설정해주지 않아도 Tag가 자유롭게 이동하는 실루엣이 보였다. 하지만 터치는 따로 설정을 해줘야했다. 드래그앤드롭하는 중에 해당 Box의 위치를 변경해주는 함수를 작성하고, Bottom에서 빠진 Tag에 대해서도 처리를 해줘야 했다.( Tag들이 밀리도록 )
tag 타입이 Bottom에 있는 letter(orbit)라면 터치 이벤트를 실행한다. 현재 시작위치를 저장하고, 터치 중인 위치를 startPositionRef에 저장한다. 터치가 시작했기에 EventListener를 붙여준다.
초기에 startPosition을 인식하지 못하는 문제 때문에 드래그앤드롭이 잘 안됐었는데(비동기 문제) startPosition이 null이 아닐 시에만, 비동기 시에도 불러올 수 있게 startPositionRef를 따로 만들어 startPosition이 항상 값이 있는 상태를 유지하게 했다.
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
(생략)
if (isDragable && tagType === "orbit") {
e.stopPropagation();
console.log("터치 시작", e.touches?.[0]);
const touch = e.touches?.[0];
if (touch) {
const position = { x: touch.clientX, y: touch.clientY };
startPositionRef.current = position;
}
e.currentTarget.addEventListener("touchmove", handleTouchMove, {
passive: false,
});
e.currentTarget.addEventListener("touchend", handleTouchEnd, {
once: true,
});
}
};
현재 Tag는 drag&drop 중이다. 현재 위치에서 touch.clientX, Y를 비교해서 움직이는 tag 위치를 터치하는 위치로 조정했다. Move 될때마다 translate 가 바뀌어 tag의 style이 변하게 된다.
const handleTouchMove = (e: TouchEvent) => {
const startPosition = startPositionRef.current;
if (startPosition) {
const touch = e.touches[0];
const deltaX = touch.clientX - 60;
const deltaY = touch.clientY - startPosition.y;
setTranslate({
x: touch.clientX - startPosition.x,
y: touch.clientY - startPosition.y,
});
if (tagRef.current) {
tagRef.current.style.zIndex = "999999";
tagRef.current.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
tagRef.current.style.position = "absolute";
tagRef.current.style.touchAction = "none";
}
if (e.cancelable) {
e.preventDefault();
}
}
};
현재 드래그 중인 item을 저장해야 현재 드래그가 완료된 Tag에 대해서 인터랙션이 실행되게 된다. 드래그가 성공한다면 droppedItem을 저장하게 된다. 드래그된 아이템에 대해 tagId와 name을 저장하고, 4초간 깜빡임 인터렉션을 실행한다. (현재 planet에 있는 편지중 tagId가 일치하는 것에 대해서 애니메이션을 실행함)
const [droppedItem, setDroppedItem] = useState<Orbit | null>(null);
useEffect(() => {
if (droppedItem) {
setIsDroppped(true);
setDroppedLetter({
tagId: droppedItem.letterId,
name: droppedItem.senderName,
});
const timer = setTimeout(() => {
setDroppedItem(null);
setDroppedLetter({
tagId: "",
name: "",
});
setIsDroppped(false);
}, 4000);
return () => clearTimeout(timer);
}
}, [droppedItem]);
const handleTagDrag = (item: Orbit) => {
setDroppedItem(item);
};
다음은 드래그가 끝났을 시 실행되는 함수이다. 사용자가 드래그를 멈췄을 때 이벤트가 호출되며, 드롭된 위치가 Planet의 범위 내에 있는지 확인한다. ref.current?.getBoundingClientRect()
를 호출해 Planet의 화면 좌표(planetBounds
)를 가져오고, droppedItem이 존재한다면 새로운 orbitItem을 생성한다. 마지막으로는handleTagTouch(orbitItem)
을 호출해 드롭된 아이템 정보를 Planet에 전달에 깜빡임 인터렉션을 실행하게 된다.
const handleTagDrag = (item: Orbit) => {
setDroppedItem(item);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); //브라우저의 기본 드롭 동작을 방지
if (ref) {
const planetBounds = ref.current?.getBoundingClientRect();
if (planetBounds) {
const { clientX, clientY } = e;
if (
clientX >= planetBounds.left &&
clientX <= planetBounds.right &&
clientY >= planetBounds.top &&
clientY <= planetBounds.bottom
) {
if (droppedItem) {
const orbitItem: Orbit = {
letterId: droppedItem.letterId,
senderName: droppedItem.senderName,
receivedDate: "",
};
handleTagTouch(orbitItem);
}
} else {
console.log("드래그 범위가 아님");
}
}
}
};
그밖에도 페이지 넘김 애니메이션, 카카오 로그인(리프레시 구현), 카카오 메시지 전송 등 새로 했던 기능들도 많지만 쓰다보니 포스팅이 좀 길어져서 나중에 차차 정리해보기로..
새해 복 많이 받으세요~~
인터랙션의 장인, 인터랙션의 천재, 인터랙션의 신 .. 레터링은 정말이지 인터랙션으로 시작하고 인터랙션으로 끝난다고 생각합니다 .. 🥺 마침내 해낸 승효 개발자님 정말 멋지고 존경합니다. 🤍🤍🤍🤍