[React] 단일 클릭, ctrl 클릭과 함께 shift 클릭 로직 구현하기

Jin·2022년 3월 10일
1

React

목록 보기
11/18
post-custom-banner

직전 프로젝트에서 핵심 기능은 클릭, ctrl 클릭, shift 클릭으로 아이템을 취사 선택할 수 있게 하는 것이었습니다.

저는 shift 클릭 기능 구현을 담당하였고 로직이 복잡하여 구현하는 데에 애를 먹었습니다.

이 게시글은 제가 단일 클릭, ctrl 클릭을 고려한 shift 클릭 로직 구현을 어떻게 했는지에 관한 것입니다.

우리가 흔히 생각하는 클릭은 아래와 같이 동작합니다.

사용할 때는 아무 생각없이 클릭했지만 막상 구현하려고 하니 경우의 수가 굉장했습니다.

경우의 수

  • 단일 클릭
  • ctrl 클릭
  • 아무것도 클릭되지 않았을 때 shift 클릭
  • 하나 이상의 아이템 클릭 후 shift 클릭
    • shift 클릭한 아이템이 직전 클릭한 아이템보다 아래에 있는 경우
      • 직전에 shift 클릭하여 영역이 지정되어 있는 경우
      • 직전에 단일 클릭 or ctrl 클릭을 한 경우
    • shift 클릭한 아이템이 직전 클릭한 아이템보다 위에 있는 경우
      • 직전에 shift 클릭하여 영역이 지정되어 있는 경우
      • 직전에 단일 클릭 or ctrl 클릭을 한 경우
  • shift 클릭하여 영역을 활성화한 후 영역 안의 아이템을 shift 클릭한 경우
    • 직전에 shift 클릭하여 생긴 영역의 방향성이 위인 경우
    • 직전에 shift 클릭하여 생긴 영역의 방향성이 아래인 경우
  • shift 클릭시 영역 안에 이미 클릭했던 아이템이 포함되어 있을 경우
  • shift 클릭 후 ctrl 클릭 후 다시 shift 클릭을 하는 경우

언뜻 생각해봐도 이 정도의 경우의 수가 나옵니다.

코드 구현

이것을 코드로 구현하는 것은 아래와 같습니다.

checkedId는 활성해야 하는 아이템의 id가 담긴 배열이고
onChangeCheckedId 함수는 checkedId를 변경할 수 있습니다.

const onClick = (
    e: React.MouseEvent<HTMLDivElement>,
    id: number,
    index: number
  ) => {
    if (e.shiftKey) {
      let newCheckId: number[]

      if (startEnd?.start === null || startEnd?.start === undefined) {
          
          // 처음 눌렀을 때
          
        newCheckId = dataList.slice(0, index + 1).map((data) => data.id)
        onChangeCheckedId(newCheckId)

        setStartEnd({
          start: 0,
          end: index,
          direction: null,
        })
      } else {
        
        // 시프트로 영역 지정 후 안에 shift 클릭시

        if (checkedId.includes(id)) {
          if (startEnd.direction && startEnd.end !== null) {
            if (startEnd.direction === 'up') {
              
              // 방향성이 위인 경우
              
              newCheckId = dataList
                .slice(index, startEnd.end + 1)
                .map((data) => data.id)

              const removeCheckId = dataList
                .slice(startEnd.start, startEnd.end + 1)
                .map((data) => data.id)

              onChangeCheckedId([
                ...checkedId.filter(
                  (id: number) => !removeCheckId.includes(id)
                ),
                ...newCheckId,
              ])
            } else {
              
              // 방향성이 아래인 경우
              
              newCheckId = dataList
                .slice(startEnd.start, index + 1)
                .map((data) => data.id)

              const removeCheckId = dataList
                .slice(startEnd.start, startEnd.end + 1)
                .map((data) => data.id)

              onChangeCheckedId([
                ...checkedId.filter(
                  (id: number) => !removeCheckId.includes(id)
                ),
                ...newCheckId,
              ])
            }
          }
        } else if (index < startEnd.start) {
          
          // shift 클릭한 아이템이 직전 클릭한 아이템보다 위에 있는 경우
          
          newCheckId = dataList
            .slice(index, startEnd.start + 1)
            .map((data) => data.id)
          
          if (startEnd.end !== null) {
            
            // 직전에 shift 클릭하여 영역이 지정되어 있는 경우
            
            const shiftedCheckId = dataList
              .slice(startEnd.start, startEnd.end + 1)
              .map((data) => data.id)

            onChangeCheckedId([
              ...checkedId.filter((id) => !shiftedCheckId.includes(id)),
              ...newCheckId,
            ])
          } else {
            
            // 직전에 단일 클릭 or ctrl 클릭한 경우
            
            onChangeCheckedId([
              ...checkedId,
              ...newCheckId.slice(0, newCheckId.length - 1),
            ])
          }
          setStartEnd({
            start: index,
            end: startEnd.start,
            direction: 'up',
          })
        } else {
          
          // shift 클릭한 아이템이 직전 클릭한 아이템보다 아래에 있는 경우
          
          if (startEnd.end !== null) {
            
            // 직전에 shift 클릭하여 영역이 지정되어 있는 경우
            
            newCheckId = dataList
              .slice(startEnd.end, index + 1)
              .map((data) => data.id)

            const shiftedCheckId = dataList
              .slice(startEnd.start, startEnd.end + 1)
              .map((data) => data.id)

            onChangeCheckedId([
              ...checkedId.filter((id) => !shiftedCheckId.includes(id)),
              ...newCheckId,
            ])
            setStartEnd({
              start: startEnd.end,
              end: index,
              direction: 'down',
            })
          } else {
            
            // 직전에 단일 클릭 or ctrl 클릭한 경우
            
            newCheckId = dataList
              .slice(startEnd.start + 1, index + 1)
              .map((data) => data.id)

            onChangeCheckedId([...checkedId, ...newCheckId])
            setStartEnd({
              ...startEnd,
              end: index,
              direction: 'down',
            })
          }
        }
      }
    } else {
      
      // shift 클릭을 하지 않은 경우
      
      setStartEnd({
        start: index,
        end: null,
        direction: null,
      })
      if (e.ctrlKey || e.metaKey) {
        
        // ctrl 클릭한 경우
        
        if (checkedId.includes(id)) {
          onChangeCheckedId(checkedId.filter((v) => v !== id))
        } else {
          onChangeCheckedId([...checkedId, id])
        }
      } else {
        
        // 단일 클릭한 경우
        
        if (checkedId.length === 1 && checkedId[0] === id) {
          onChangeCheckedId([])
        } else {
          onChangeCheckedId([id])
        }
      }
    }
  }

잘 작동하는 것을 볼 수 있습니다.

느낀 점

더 쉬운 로직으로 구현할 수 있었겠다는 생각도 듭니다. 여러 팀원들과 협업하면서 기존에 선언된 상태와 구조를 최대한 변경하지 않으면서 기능을 구현하려고 하였습니다. 그러다보니 로직이 좀 더 복잡해진 측면이 있었던 것 같습니다.

리덕스 같이 전역으로 사용되는 상태는 데이터 구조를 미리 약속하였습니다. 팀원들과 같이 사용되는 내부 상태 또한 그 구조와 데이터 타입을 미리 약속하고 각자 기능을 구현하는 방향으로 순서를 정하는 것이 맞다는 생각도 하게 되었습니다.

profile
배워서 공유하기
post-custom-banner

0개의 댓글