기본 원리 및 핵심 컴포넌트
RecyclerView 내에서 사용자가 항목을 길게 누르고, 그 상태에서 손가락을 움직여 여러 항목을 선택하는 기능은 OnItemTouchListener를 활용하여 구현할 수 있습니다. 이 방식은 시스템의 기본 스크롤 동작과 충돌하지 않도록 주의 깊게 처리해야 하며, 터치 이벤트의 순서와 제어 흐름을 정확히 이해해야 합니다.
핵심 클래스 및 메서드 설명
LinearLayoutManager.canScrollHorizontally()및canScrollVertically(): 스크롤 가능 여부를 조절하여, 다중 선택 모드 중에는 리사이클러 뷰의 스크롤을 일시적으로 비활성화합니다.RecyclerView.OnItemTouchListener: 터치 이벤트를 직접 가로채고,onInterceptTouchEvent와onTouchEvent를 통해 길게 누르기와 드래그 동작을 감지합니다.
구현 코드 (변형된 구조)
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;
}
});
이 구현은 길게 누르기 후 드래그로 다중 항목을 선택하는 경험을 제공하며, 스크롤 간섭을 최소화하고 사용자 인터페이스 반응성을 유지합니다.