-
Notifications
You must be signed in to change notification settings - Fork 5
좋은 UX를 위한 기능
Knoticle 서비스에 좋은 UX를 제공하기 위한 방법으로 토스트메세지와 드래그앤드롭, TOC이동을 적용했다.
많은 Api요청에서 에러가 발생했을때와 회원가입, 책 생성등 동작의 성공을 알려주기 위한 방법으로 토스트메세지를 사용하고 있다.
또한, 글 발행과 책 수정, 스크랩 기능에서 좋은 UX를 위해 드래그앤드롭으로 글의 위치를 정할 수 있도록 구현했다.
사용자가 글을 편하게 보고 이동할 수 있도록 TOC 이동을 구현했다.
이러한 과정에서 고민했던 내용을 기록하려고 한다.
팀 내부 회의를 통해 토스트 메세지를 구현하기로 결정하고, 적용하면서 고민했던 부분은 두가지이다.
- 라이브러리를 사용할지, 직접 구현할지?
- 어느 시점에 토스트메세지를 띄워주는게 UX적으로 좋을지?
우선, 토스트 메세지는 라이브러리를 사용하기로 결정했다.
Knoticle팀에서는 react-tostify 라이브러리를 사용했는데 선정 이유는 하기와 같다.
- 우리는 6주라는 짧은 시간동안 기획,설계,구현을 진행해야하며, 완결성있는 프로젝트를 완성해야한다.
- react-toastify는 공식문서가 잘 정리되어 있고, 커스텀해서 사용하기 좋다.
reatct-roastify를 Next에 적용하기 위해서는 _app.tsx에 ToastContainer를 넣어줘야 한다.
_app.tsx는 Next에서 최초로 실행되며 렌더링하는 값은 모든 페이지에 영향을 준다.
따라서, Recoil, Globalstyle, ToastContainer 등 모든 페이지에 영향을 주는 것을 설정해준다.
limit 설정은 최대로 표시할 수 있는 토스트메세지 개수의 제한이다.
import type { AppProps } from 'next/app';
import 'react-toastify/dist/ReactToastify.css';
import { ToastContainer } from 'react-toastify';
import { RecoilRoot } from 'recoil';
import CheckSignInByToken from '@components/CheckSignInByToken';
import GlobalStyle from '@styles/GlobalStyle';
export default function App({ Component, pageProps }: AppProps) {
return (
<RecoilRoot>
<CheckSignInByToken>
<GlobalStyle />
<Component {...pageProps} />
<ToastContainer limit={3} />
</CheckSignInByToken>
</RecoilRoot>
);
}
에러메세지와 성공메세지를 사용하기 위한 함수를 추상화하여 utils에 따로 파일을 분리했다.
import { toast } from 'react-toastify';
export const toastError = (message: string) => {
toast.error(message, {
position: 'top-right',
autoClose: 3000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: 'light',
});
};
export const toastSuccess = (message: string) => {
toast.success(message, {
position: 'top-right',
autoClose: 3000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: 'light',
});
};
세부 설정 옵션
회원가입 성공
// 회원가입 성공 -> useEffet로 함수 실행
useEffect(() => {
if (createUserData === undefined) return;
handleModalClose();
toastSuccess('Knoticle 가입을 축하합니다!');
}, [createUserData]);
2022-11-28.6.38.14.mov
api 에러 핸들링
import { useCallback, useState } from 'react';
import { toastError } from '@utils/toast';
const useFetch = <T>(api: (...args: any[]) => Promise<T>) => {
const [data, setData] = useState<T>();
const execute = useCallback(async (...args: any[]) => {
try {
setData(await api(...args));
} catch (error: any) {
const { message } = error.response.data;
toastError(message);
}
}, []);
return { data, execute };
};
export default useFetch;
2022-11-28.5.44.02.mov
간단하게 react-toastify를 적용해서 토스트메세지를 구현할 수 있었다.
UX 향상을 위한 첫 작업으로 사용자에게 좋은 방법으로 정보와 상태를 전달할 수 있었다.
현재는 에러핸들링과 책 발행,수정 회원가입 성공에 토스트메세지를 적용하고 있다.
무분별한 사용은 오히려 UX를 저하시킬 수 있기 때문에 적절한 사용방법을 더 고민해보고 있다.
https://fkhadra.github.io/react-toastify/introduction
Knoticle 서비스에서 책에서 글의 위치를 결정하는 기능은 매우 중요한 기능이다.
책과 글을 기반으로 만들어진 프로젝트에서 글의 위치를 결정하는 기능은 사용자의 좋은 경험에 중요한 지표가 될 것이다.
초기 기획단계에서 여러가지 방법을 논의했었다.
- 버튼으로 위치를 변경시키는 방법
- 마지막 위치로 고정시키고, 추후 책 수정에서 변경시키는 방법
- 드래그앤드롭
최종적으로는 드래그앤드롭으로 결정되고, 구현을 시작했다.
드래그앤드롭을 구현하기 위해 직접 구현하는 방법, 라이브러리를 사용하는 방법 중 고민을 시작했다.
가장 해보고 싶은 방법은 직접 구현하는 것이지만, 팀프로젝트를 완결성있게 완성시키는 것이 가장 중요했기 때문에 라이브러리를 사용해서 구현하기로 결정했다.
선정에 고민했던 라이브러리는 하기와 같다.
- react-dnd
- react-beautiful-dnd
- react-draggable
상대적으로 가볍고, 커스텀해서 사용하기 편하다.
터치 백엔드 npm으로 터치를 지원한다.
공식문서에 사용예시가 많아서 참고해서 라이브러리를 익히기 쉽다.
react-beautiful-dnd는 UI/UX나 퍼포먼스가 좋은 동작이 predefined 되어있는 것이 특징이다.
신경쓰지않아도 좋은 퍼포먼스 동작이 들어가있어 이쁜 동작을 만들 수 있다.
하지만, 용량이 react-dnd의 두배이상이다.
react-draggable은 드래그로 어떠한 아이템 간의 순서 변경 측면보다, 윈도우즈에서 윈도우를 드래그해서 위치를 바꾸는 부분에서 강점이 있는 라이브러리로 프로젝트와 연관성이 낮아 제외했다.
결론적으로, 가볍고 추후 커스텀해서 사용하기 편리하다는 장점과 터치 지원으로 모바일 구현에서 사용할 수 있는 장점으로 react-dnd를 사용해서 구현했다.
드래그앤드롭을 구현하면서 책 수정모달, 글 발행모달, 스크랩모달 등 많은 곳에서 사용해야 하기 때문에 추상화해서 구현하려고 노력했다.
사용하려는 컴포넌트를 로 감싸고 Props로 backend를 내려준다.
backend는 필수로 HTML5Backend를 내려준다.
HTML5Backend는 React-DnD에서 지원하는 기본 백엔드이다.
터치 지원을 위해서는 TouchBackend를 내려줘서 사용할 수 있다.
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Container } from '@components/common/DragDrop/Container';
export interface EditScrap {
id: number;
order: number;
article: {
id: number;
title: string;
};
}
export interface ContainerState {
data: EditScrap[];
isContentsShown: boolean;
}
export default function DragArticle({ data, isContentsShown }: ContainerState) {
return (
<DndProvider backend={HTML5Backend}>
<Container data={data} isContentsShown={isContentsShown} />
</DndProvider>
);
}
실제로 옮길 요소를 감싸고 있는 Container로 움직이는 요소를 찾고 전역상태로 관리하는 데이터에 update해주는 함수가 들어있다.
findScrap,moveScrap은 하위 컴포넌트로 내려준다.
import { useEffect, memo, useCallback } from 'react';
import { useDrop } from 'react-dnd';
import update from 'immutability-helper';
import { useRecoilState } from 'recoil';
import scrapState from '@atoms/scrap';
import { ListItem } from '../ListItem';
import ContainerWapper from './styled';
const ItemTypes = {
Scrap: 'scrap',
};
export interface EditScrap {
id: number;
order: number;
article: {
id: number;
title: string;
};
}
export interface ContainerState {
data: EditScrap[];
isContentsShown: boolean;
}
export const Container = memo(function Container({ data, isContentsShown }: ContainerState) {
const [scraps, setScraps] = useRecoilState<EditScrap[]>(scrapState);
useEffect(() => {
if (!data) return;
setScraps(data);
}, []);
const findScrap = useCallback(
(id: string) => {
const scrap = scraps.filter((c) => `${c.article.id}` === id)[0] as {
id: number;
order: number;
article: {
id: number;
title: string;
};
};
return {
scrap,
index: scraps.indexOf(scrap),
};
},
[scraps]
);
const moveScrap = useCallback(
(id: string, atIndex: number) => {
const { scrap, index } = findScrap(id);
setScraps(
update(scraps, {
$splice: [
[index, 1],
[atIndex, 0, scrap],
],
})
);
},
[findScrap, scraps, setScraps]
);
const [, drop] = useDrop(() => ({ accept: ItemTypes.Scrap }));
return (
<ContainerWapper ref={drop}>
{scraps.map((scrap, index) => (
<ListItem
key={scrap.article.id}
id={`${scrap.article.id}`}
text={scrap.article.title}
moveScrap={moveScrap}
findScrap={findScrap}
isShown={index < 4}
isContentsShown={isContentsShown}
/>
))}
</ContainerWapper>
);
});
실제로 움직이는 요소들이 있는 컴포넌트이다.
useDrag와 useDrop Hooks를 사용해 드래그앤드롭을 구현한다.
드래그앤드롭을 하면 내가 선택한 요소의 id와 overIndex로 위치를 조정한다.
import { memo } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import Article from './styled';
const ItemTypes = {
Scrap: 'scrap',
};
export interface ScrapProps {
id: string;
text: string;
moveScrap: (id: string, to: number) => void;
findScrap: (id: string) => { index: number };
isShown: boolean;
isContentsShown: boolean;
}
interface Item {
id: string;
originalIndex: number;
}
export const ListItem = memo(function Scrap({
id,
text,
moveScrap,
findScrap,
isShown,
isContentsShown,
}: ScrapProps) {
const originalIndex = findScrap(id).index;
// Drag
const [{ isDragging }, drag] = useDrag(
() => ({
// 타입설정 useDrop의 accept와 일치시켜야함
type: ItemTypes.Scrap,
item: { id, originalIndex },
// Return array의 첫번째 값에 들어갈 객체를 정의한다.
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
// 드래그가 끝났을때 실행한다.
end: (item, monitor) => {
const { id: droppedId } = item;
const didDrop = monitor.didDrop();
if (!didDrop) {
moveScrap(droppedId, originalIndex);
}
},
}),
[id, originalIndex, moveScrap]
);
// Drop
const [, drop] = useDrop(
() => ({
accept: ItemTypes.Scrap,
hover({ id: draggedId }: Item) {
if (draggedId !== id) {
const { index: overIndex } = findScrap(id);
moveScrap(draggedId, overIndex);
}
},
}),
[findScrap, moveScrap]
);
return (
<Article ref={(node) => drag(drop(node))} isShown={isContentsShown ? true : isShown}>
{text}
</Article>
);
});
내가 사용하고 싶은 데이터를 props로 내려줘서 사용할 수 있다.
...
<DragArticleWrapper isContentsShown={isContentsShown}>
<DragArticle data={scraps} isContentsShown={isContentsShown} />
</DragArticleWrapper>
...
[Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/d3dc0522-b5f7-4c75-b561-e09e8d8077ec/Untitled.qt)
처음으로 드래그앤드롭을 구현해봤는데 완성하니 정말 좋은 경험이었다.
버튼으로 변경하는 것보다 좋은 UX로 글 순서를 바꿀 수 있는 것 같다.
남은 고민은 드래그 핸들을 만들거나, 모바일에 대응하고 더 좋은 UX를 줄 수 있도록 커스텀 하는 것이다.
Reference
https://react-dnd.github.io/react-dnd/examples/sortable/cancel-on-drop-outside
https://itchallenger.tistory.com/608
https://channel.io/ko/blog/react-dnd-tips-tricks
Knoticle 서비스는 여러 글을 모아 책으로 엮어주는 서비스이다.
이러한 서비스에서 책과 글의 TOC를 구현해 사용자가 글을 볼때 원하는 곳으로 이동하기 편하도록 개선했다.
2022-12-11.3.39.02.mov
TOC를 구현하는 방법은 문자열 전환과 Link 태그의 href를 통해 구현했다.
목차에 글의 h1,h2,h3 태그의 title을 표현하기 위해 하기와 같이 구현했다.
- DB에 저장하고 있는 article.content값을 articleToc함수로 전달한다.
- TOC에 표현하기 위해 h1,h2,h3 태그의 값만 분류하고 title과 count로 데이터를 전환한다.
- styled-component값의 props로 count값을 내려서 들여쓰기 구분한다.
export const articleToc = (content: string) => {
// 게시물 본문을 줄바꿈 기준으로 나누고, 제목 요소인 것만 저장
const titles = html2markdown(content)
.split(`\n`)
.filter((t) => t.includes('# '));
// 예외처리 - 제목은 문자열 시작부터 #을 써야함
const result = titles
.filter((str) => str[0] === '#')
.map((item) => {
// #의 갯수에 따라 제목의 크기가 달라지므로 갯수를 센다.
let count = item.match(/#/g)?.length;
if (count) {
// 갯수에 따라 목차에 그릴때 들여쓰기 하기위해 *10을 함.
count *= 10;
}
// 제목의 내용물만 꺼내기 위해 '# '을 기준으로 나누고, 백틱과 공백을 없애주고 count와 묶어서 리턴
return { title: item.split('# ')[1].replace(/`/g, '').trim(), count };
});
return result;
};
knoticle은 마크다운 에디터를 사용해서 사용자의 글을 받고, html로 전환해서 DB에 저장한다.
뷰어페이지에서 글을 표현할때는 html을 dangerouslySetInnerHTML을 사용해서 표현해준다.
💡 **innerHTML을 사용하면 DOM노드가 수정되었을때 수정된 것을 알 수 있는 방법이 없다**고 합니다. **따라서 dangerouslySetInnerHTML을 사용하여 가상 DOM과 실제 DOM을 비교하여 변경된 것이 있다면 리렌더링이 될 수 있도록 해야합니다.**TOC 이동을 위해서는 html h1,h2,h3의 태그에 id 값을 추가해야한다.
동일하게 함수로 구현해서 정규식과 문자열 전환을 통해 id값에 title을 추가했다.
export const articleConversion = (content: string) => {
const newArticle = content.split('\n').map((v, idx) => {
if (v.includes('h1') || v.includes('h2') || v.includes('h3')) {
const title = v.replace(/<[^>]*>?/g, '');
const result = v.split('');
result.splice(3, 0, ' ', `id=${title}`);
return result.join('');
}
return v;
});
여기서 넣어준 id값을 toc Link의 href로 설정해준다.
{isArticleShown &&
articleToc.map((article) => (
<TocArticleTitle
href={`#${article.title}`}
key={article.title}
count={article.count}
>
{article.title}
</TocArticleTitle>
))}
이제 TOC의 제목을 클릭하면, 해당하는 태그의 위치로 이동할 수 있다!
처음 구현할때는 id값을 통해 이동해줄 수 있는 방법을 모르고 어떤 방식으로 이동시켜줘야할지 막막했던 것 같다.
하지만 막상 구현해보니 html을 마크다운으로 전환해 title을 분류하고, 정규식을 통해 html 태그에 id를 추가하기도 하면서 재미있게 구현할 수 있었다.
프로젝트 마감까지 이제 시간이 얼마 남지 않아서 이동까지만 구현했지만, 프로젝트가 끝난 후 IntersectionObserver를 활용해 화면 위치에 따라서 TOC에 표시해주는 방식으로 개선할 예정이다.