Android RecyclerView에서 길게 누른 후 손가락으로 다중 선택 구현

기본 원리 및 핵심 컴포넌트

RecyclerView 내에서 사용자가 항목을 길게 누르고, 그 상태에서 손가락을 움직여 여러 항목을 선택하는 기능은 OnItemTouchListener를 활용하여 구현할 수 있습니다. 이 방식은 시스템의 기본 스크롤 동작과 충돌하지 않도록 주의 깊게 처리해야 하며, 터치 이벤트의 순서와 제어 흐름을 정확히 이해해야 합니다.

핵심 클래스 및 메서드 설명

  • LinearLayoutManager.canScrollHorizontally()canScrollVertically(): 스크롤 가능 여부를 조절하여, 다중 선택 모드 중에는 리사이클러 뷰의 스크롤을 일시적으로 비활성화합니다.
  • RecyclerView.OnItemTouchListener: 터치 이벤트를 직접 가로채고, onInterceptTouchEventonTouchEvent를 통해 길게 누르기와 드래그 동작을 감지합니다.

구현 코드 (변형된 구조)

public class MultiSelectGestureListener implements RecyclerView.OnItemTouchListener {
    private static final String TAG = "MultiSelectGesture";
    private boolean isTouchStarted = false;
    private boolean isSelectionCancelled = false;
    private int initialPosition = -1;
    private int lastVisiblePosition = -1;
    private float initialX = 0f;
    private float initialY = 0f;
    private long touchStartTime = 0L;

    private final Handler mainHandler = new Handler(Looper.getMainLooper());

    @Override
    public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
        if (e.getActionIndex() > 0) return false; // 멀티터치는 무시

        switch (e.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                View childUnder = rv.findChildViewUnder(e.getX(), e.getY());
                if (childUnder == null) return false;

                RecyclerView.ViewHolder holder = rv.findContainingViewHolder(childUnder);
                if (holder == null || holder.getAdapterPosition() == RecyclerView.NO_POSITION) return false;

                Rect rect = new Rect();
                if (!holder.itemView.getGlobalVisibleRect(rect) || !rect.contains((int)e.getRawX(), (int)e.getRawY())) return false;

                isTouchStarted = true;
                isSelectionCancelled = false;
                initialPosition = holder.getAdapterPosition();
                lastVisiblePosition = initialPosition;
                initialX = e.getRawX();
                initialY = e.getRawY();
                touchStartTime = e.getDownTime();

                // 1초 후에 선택 모드 진입
                mainHandler.postDelayed(() -> {
                    if (!isTouchStarted || isSelectionCancelled || touchStartTime != this.touchStartTime) return;
                    disableScrolling(rv);
                    onItemSelected(rv, initialPosition);
                }, 1000);

                return false;

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                if (!isSelectionCancelled) {
                    onSelectionCompleted();
                }
                enableScrolling(rv);
                isTouchStarted = false;
                return false;

            case MotionEvent.ACTION_MOVE:
                float deltaX = Math.abs(initialX - e.getRawX());
                float deltaY = Math.abs(initialY - e.getRawY());
                if (e.getEventTime() - e.getDownTime() < 900 && (deltaX > 10 || deltaY > 10)) {
                    isSelectionCancelled = true;
                    enableScrolling(rv);
                    mainHandler.removeCallbacksAndMessages(null);
                }
                onTouchEvent(rv, e);
                return false;

            default:
                return false;
        }
    }

    @Override
    public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
        if (isSelectionCancelled || e.getActionMasked() != MotionEvent.ACTION_MOVE) return;

        View childUnder = rv.findChildViewUnder(e.getX(), e.getY());
        if (childUnder == null) return;

        RecyclerView.ViewHolder holder = rv.findContainingViewHolder(childUnder);
        if (holder == null) return;

        Rect rect = new Rect();
        if (!holder.itemView.getGlobalVisibleRect(rect) || !rect.contains((int)e.getRawX(), (int)e.getRawY())) return;

        int currentPosition = holder.getAdapterPosition();
        if (currentPosition == lastVisiblePosition) return;

        // 이전 위치로 되돌아가는 경우
        if (Math.abs(initialPosition - currentPosition) < Math.abs(initialPosition - lastVisiblePosition)) {
            onItemDeselected(rv, lastVisiblePosition);
        } else {
            onItemSelected(rv, currentPosition);
        }

        lastVisiblePosition = currentPosition;

        // 스크롤 자동 보정
        LinearLayoutManager layoutManager = (LinearLayoutManager) rv.getLayoutManager();
        if (layoutManager != null) {
            int firstVisible = layoutManager.findFirstVisibleItemPosition();
            int lastVisible = layoutManager.findLastVisibleItemPosition();
            int itemCount = rv.getAdapter().getItemCount();

            if (currentPosition == firstVisible && firstVisible > 0) {
                rv.smoothScrollToPosition(firstVisible - 1);
            } else if (currentPosition == lastVisible && lastVisible < itemCount - 1) {
                rv.smoothScrollToPosition(lastVisible + 1);
            }
        }
    }

    @Override
    public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {}

    protected void onItemSelected(@NonNull RecyclerView recyclerView, int position) {
        RecyclerView.ViewHolder vh = recyclerView.findViewHolderForAdapterPosition(position);
        if (vh != null) {
            vh.itemView.setBackgroundColor(Color.LTGRAY);
        }
    }

    protected void onItemDeselected(@NonNull RecyclerView recyclerView, int position) {
        RecyclerView.ViewHolder vh = recyclerView.findViewHolderForAdapterPosition(position);
        if (vh != null) {
            vh.itemView.setBackgroundColor(Color.WHITE);
        }
    }

    protected void onSelectionCompleted() {}

    private void disableScrolling(RecyclerView rv) {
        if (rv.getLayoutManager() instanceof LinearLayoutManager) {
            ((LinearLayoutManager) rv.getLayoutManager()).setCanScroll(false);
        }
    }

    private void enableScrolling(RecyclerView rv) {
        if (rv.getLayoutManager() instanceof LinearLayoutManager) {
            ((LinearLayoutManager) rv.getLayoutManager()).setCanScroll(true);
        }
    }
}

사용 예시: MainActivity 연결

recyclerView.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) {
    @Override
    public boolean canScrollVertically() {
        return scrollEnabled;
    }

    @Override
    public boolean canScrollHorizontally() {
        return scrollEnabled;
    }
});

// 다중 선택 리스너 등록
recyclerView.addOnItemTouchListener(new MultiSelectGestureListener() {
    @Override
    public void onItemSelected(@NonNull RecyclerView rv, int pos) {
        if (pos < 0 || pos >= itemList.size()) return;
        ItemData item = itemList.get(pos);
        item.isSelected = true;
        rv.getAdapter().notifyItemChanged(pos);
    }

    @Override
    public void onItemDeselected(@NonNull RecyclerView rv, int pos) {
        if (pos < 0 || pos >= itemList.size()) return;
        ItemData item = itemList.get(pos);
        item.isSelected = false;
        rv.getAdapter().notifyItemChanged(pos);
    }

    @Override
    public void onSelectionCompleted() {
        // 선택 완료 후 로직 추가 가능
    }

    @Override
    protected void onScrollEnabledChange(boolean enabled) {
        scrollEnabled = enabled;
    }
});

이 구현은 길게 누르기 후 드래그로 다중 항목을 선택하는 경험을 제공하며, 스크롤 간섭을 최소화하고 사용자 인터페이스 반응성을 유지합니다.

태그: RecyclerView Android multi-select OnItemTouchListener LinearLayoutManager

7월 13일 18:21에 게시됨