리사이클러뷰의 각 아이템을 스와이프해서 추가적인 동작을 구현하고자 한다.
코딩의 미덕은 구글링이듯이, 사실 아래 블로그 보고 그대로 따라했다.
https://codeburst.io/android-swipe-menu-with-recyclerview-8f28a235ff28
ItemTouchHelper를 활용하여 스와이프 시 해당하는 동작들을 정의해주는 것이 핵심이다.
SwipeController를 통한 기본 설정
ItemTouchHelpler의 Callback을 구현하는 SwipeController 클래스를 통해 관련된 모든 동작들을 정의할 것이다.
일단, getMovementFlags
함수를 통해서 각각의 아이템을 스와이프 했을 때 날려버릴 수 있고,
// SwipeController.java
public class SwipeController extends Callback {
@Override
public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
return makeMovementFlags(0, LEFT | RIGHT);
}
@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
return false;
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
}
}
convertToAbsoluteDirection
함수를 통해 스와이프를 종료하면 (손을 떼면) 아이템이 다시 돌아오도록 할 수 있다.
이때 swipeBack, 즉 손을 뗐는지 확인하기 위해서 setTouchListener라는 함수를 정의하여 리사이클러뷰에서 터치 이벤트를 받아오도록 하였다.
// SwipeController.java
@Override
public int convertToAbsoluteDirection(int flags, int layoutDirection) {
if (swipeBack) {
swipeBack = buttonShowedState != ButtonsState.GONE;
return 0;
}
return super.convertToAbsoluteDirection(flags, layoutDirection);
}
public void onChildDraw(Canvas c,
RecyclerView recyclerView,
RecyclerView.ViewHolder viewHolder,
float dX, float dY,
int actionState, boolean isCurrentlyActive) {
if (actionState == ACTION_STATE_SWIPE) {
setTouchListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
private void setTouchListener(final Canvas c, final RecyclerView recyclerView, final RecyclerView.ViewHolder viewHolder, final float dX, final float dY, final int actionState, final boolean isCurrentlyActive) {
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
swipeBack = event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_UP;
return false;
}
});
}
왼쪽 오른쪽 swipe 동작 구분
일단 우리는 ① 버튼 없음(기본), ② 왼쪽 스와이프와 버튼 생성 ③ 오른쪽 스와이프와 버튼 생성 3가지 동작을 구현하고자 함으로, 각각의 상태를 구분해놓는다.
enum ButtonsState {
GONE,
LEFT_VISIBLE,
RIGHT_VISIBLE
}
public class SwipeController extends Callback {
private boolean swipeBack = false;
//현재 상태 (스와이프되어 버튼이 그려졌는지)
private ButtonsState buttonShowedState = ButtonsState.GONE;
private RectF buttonInstance = null;
private RecyclerView.ViewHolder currentItemViewHolder = null;
private SwipeControllerActions buttonsActions = null;
private final float buttonWidth;
}
그리고 얼마나 왼쪽/오른쪽으로 스와이프 되었는지 확인하여 일정 범위 이상이면 버튼을 그리도록 세팅한다.
// SwipeController.java
private void setTouchListener(final Canvas c,
final RecyclerView recyclerView,
final RecyclerView.ViewHolder viewHolder,
final float dX, final float dY,
final int actionState, final boolean isCurrentlyActive) {
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
swipeBack = event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_UP;
if (swipeBack) {
if (dX < -buttonWidth) buttonShowedState = ButtonsState.RIGHT_VISIBLE;
else if (dX > buttonWidth) buttonShowedState = ButtonsState.LEFT_VISIBLE;
if (buttonShowedState != ButtonsState.GONE) {
setTouchDownListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
setItemsClickable(recyclerView, false);
}
}
return false;
}
});
}
buttonShowedState
가 GONE, 즉 스와이프가 된 상태이면 앞으로 버튼이 그려질 텐데
이미 기존에 스와이프 동작을 위해 리사이클러뷰의 아이템에 터치리스너가 설정되어있을 것이다.
따라서 버튼의 터치이벤트를 따로 활성화하려면 oncTouchListener를 재정의해서 덮어씌워주어야 한다.
// SwipeController.java
private void setTouchDownListener(final Canvas c,
final RecyclerView recyclerView,
final RecyclerView.ViewHolder viewHolder,
final float dX, final float dY,
final int actionState, final boolean isCurrentlyActive) {
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
setTouchUpListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
return false;
}
});
}
private void setTouchUpListener(final Canvas c,
final RecyclerView recyclerView,
final RecyclerView.ViewHolder viewHolder,
final float dX, final float dY,
final int actionState, final boolean isCurrentlyActive) {
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_UP) {
SwipeController.super.onChildDraw(c, recyclerView, viewHolder, 0F, dY, actionState, isCurrentlyActive);
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
return false;
}
});
setItemsClickable(recyclerView, true);
swipeBack = false;
buttonShowedState = ButtonsState.GONE;
}
return false;
}
});
}
private void setItemsClickable(RecyclerView recyclerView,
boolean isClickable) {
for (int i = 0; i < recyclerView.getChildCount(); ++i) {
recyclerView.getChildAt(i).setClickable(isClickable);
}
}
버튼 그리기
이제 버튼을 그려주면 된다. 버튼을 그려주는 drawButtons
함수를 정의하고,
스와이프중 손을 떼면 동작하는 onChildDraw
함수에서 이를 호출하도록 하면 된다.
// SwipeController.java
Resources rs;
private final float buttonWidth;
public SwipeController(Resources rs) {
this.rs = rs;
buttonWidth = toPx(80);
}
public float toPx(float dp) {
return dp * (rs.getDisplayMetrics().densityDpi/160f);
}
@Override
public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
//...
drawButtons(c, viewHolder);
}
//버튼 그려주기
private void drawButtons(Canvas c, RecyclerView.ViewHolder viewHolder) {
float buttonWidthWithoutPadding = buttonWidth - 20;
float corners = toPx(12);
View itemView = viewHolder.itemView;
Paint p = new Paint();
RectF leftButton = new RectF(itemView.getLeft(), itemView.getTop(), itemView.getLeft() + buttonWidthWithoutPadding, itemView.getBottom());
p.setColor(rs.getColor(R.color.main_400));
c.drawRoundRect(leftButton, corners, corners, p);
drawText("이동", c, leftButton, p);
RectF rightButton = new RectF(itemView.getRight() - buttonWidthWithoutPadding, itemView.getTop(), itemView.getRight(), itemView.getBottom());
p.setColor(rs.getColor(R.color.red_cancel));
c.drawRoundRect(rightButton, corners, corners, p);
drawText("삭제", c, rightButton, p);
buttonInstance = null;
if (buttonShowedState == ButtonsState.LEFT_VISIBLE) {
buttonInstance = leftButton;
}
else if (buttonShowedState == ButtonsState.RIGHT_VISIBLE) {
buttonInstance = rightButton;
}
}
//버튼 안에 글씨
private void drawText(String text, Canvas c, RectF button, Paint p) {
float textSize = toPx(16);
p.setColor(Color.WHITE);
p.setAntiAlias(true);
p.setTextSize(textSize);
float textWidth = p.measureText(text);
c.drawText(text, button.centerX()-(textWidth/2), button.centerY()+(textSize/2), p);
}
이때, 스크롤하게 되면 리사이클러뷰가 각각의 아이템들을 지웠다가 - 다시 그리기 때문에, 이전 스와이프 상태에서onChildDraw
를 호출하여 생성된 버튼이 초기화된 상태에서 다시 아이템이 생긴다.
따라서 리사이클러뷰에 ItemDecoration
을 추가하여 버튼이 재깍재깍 그려지도록 해야 한다.
또한, onDraw를 직접 호출하는 방식보다, onChildDraw에 currentItemViewHolder 속성을 통해 할당한다.
//MysosoReviewFragment.java (리사이클러뷰 띄우는 화면)
private RecyclerView.ViewHolder currentItemViewHolder = null;
//...
private void setupRecyclerView() {
binding.recyclerViewReview.setLayoutManager(
new LinearLayoutManager(getContext(), RecyclerView.VERTICAL, false)
);
binding.recyclerViewReview.setAdapter(mysosoReviewAdapter);
ItemTouchHelper itemTouchhelper = new ItemTouchHelper(swipeController);
itemTouchhelper.attachToRecyclerView(binding.recyclerViewReview);
binding.recyclerViewReview.addItemDecoration(new RecyclerView.ItemDecoration() {
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
swipeController.onDraw(c);
}
});
}
// SwipeController.java
private RecyclerView.ViewHolder currentItemViewHolder = null;
@Override
public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
if (actionState == ACTION_STATE_SWIPE) {
if (buttonShowedState != ButtonsState.GONE) {
if (buttonShowedState == ButtonsState.LEFT_VISIBLE) dX = Math.max(dX, buttonWidth);
if (buttonShowedState == ButtonsState.RIGHT_VISIBLE) dX = Math.min(dX, -buttonWidth);
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
else {
setTouchListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
}
if (buttonShowedState == ButtonsState.GONE) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
currentItemViewHolder = viewHolder;
}
버튼 동작 설정
이제 각각의 버튼의 동작들을 설정해주면 된다. 우선은 왼쪽 버튼 클릭 / 오른쪽 버튼 클릭 / 리셋에 해당하는 동작을 호출하기 위한 SwipeControllerActions 인터페이스를 선언해준다.
public interface SwipeControllerActions {
void onLeftClicked(int pos);
void onRightClicked(int pos);
void onReset(int pos);
}
// SwipeController.java
public SwipeController(SwipeControllerActions buttonsActions, Resources rs) {
this.buttonsActions = buttonsActions;
this.rs = rs;
buttonWidth = toPx(80);
}
그리고 touchUpListener에 각각 버튼 클릭시 작동할 동작을 선언해준다.
private void setTouchUpListener(final Canvas c, final RecyclerView recyclerView, final RecyclerView.ViewHolder viewHolder, final float dX, final float dY, final int actionState, final boolean isCurrentlyActive) {
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_UP) {
SwipeController.super.onChildDraw(c, recyclerView, viewHolder, 0F, dY, actionState, isCurrentlyActive);
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
return false;
}
});
setItemsClickable(recyclerView, true);
swipeBack = false;
//터치 시 동작 추가
if (buttonsActions != null && buttonInstance != null && buttonInstance.contains(event.getX(), event.getY())) {
if (buttonShowedState == ButtonsState.LEFT_VISIBLE) {
buttonsActions.onLeftClicked(viewHolder.getAdapterPosition());
}
else if (buttonShowedState == ButtonsState.RIGHT_VISIBLE) {
buttonsActions.onRightClicked(viewHolder.getAdapterPosition());
}
}
buttonShowedState = ButtonsState.GONE;
currentItemViewHolder = null;
}
return false;
}
});
}
버튼 동작 설정
이제 리사이클러뷰를 호출하는 메인 액티비티 (해당 프로젝트에선 프레그먼트)에서 SwipeControllerAction에 해당하는 각각의 클릭 시 대응되는 함수를 정의해주면 된다.
나는 리사이클러뷰로 각 리뷰를 띄우고, 리뷰에 해당하는 가게 이동과, 가게 삭제를 구현하였다.
//MysosoReviewFragment.java (리사이클러뷰 띄우는 화면)
//...
private void setupRecyclerView() {
binding.recyclerViewReview.setLayoutManager(
new LinearLayoutManager(getContext(), RecyclerView.VERTICAL, false)
);
binding.recyclerViewReview.setAdapter(mysosoReviewAdapter);
SwipeController swipeController = new SwipeController(new SwipeControllerActions() {
//이동
@Override
public void onLeftClicked(int pos) {
navConroller.navigate(MysosoReviewFragmentDirections.actionMysosoReviewFragmentToShopGraph(
mysosoReviewAdapter.getReviewModels().get(pos).getStoreId()
));
}
//삭제
@Override
public void onRightClicked(int pos) {
//안전을 위해서 다이얼로그 추가
new MaterialAlertDialogBuilder(getContext())
.setTitle("정말 삭제하시겠습니까?")
.setNeutralButton("네", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
myReviewViewModel.deleteMyReview(
((HomeActivity) getActivity()).getLoginToken(),
mysosoReviewAdapter.getReviewModels().get(pos).getStoreId(),
pos,
MysosoReviewFragment.this::onSuccess,
MysosoReviewFragment.this::onFailedDelete,
MysosoReviewFragment.this::onNetworkErrorDelete);
}
})
.setPositiveButton("아니오", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
}
})
.show();
}
//제자리로
@Override
public void onReset(int pos) {
mysosoReviewAdapter.notifyItemChanged(pos);
}
}, getResources());
ItemTouchHelper itemTouchhelper = new ItemTouchHelper(swipeController);
itemTouchhelper.attachToRecyclerView(binding.recyclerViewReview);
binding.recyclerViewReview.addItemDecoration(new RecyclerView.ItemDecoration() {
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
swipeController.onDraw(c);
}
});
//...
}
결과
설명을 하긴 했는데, 그냥 저 블로그 가서 보는게 편할 듯 싶다...
SwipeController의 전문 코드는 아래와 같다
//SwipeController
import static androidx.recyclerview.widget.ItemTouchHelper.*;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.view.MotionEvent;
import android.view.View;
import androidx.recyclerview.widget.RecyclerView;
enum ButtonsState {
GONE,
LEFT_VISIBLE,
RIGHT_VISIBLE
}
public class SwipeController extends Callback {
private boolean swipeBack = false;
private ButtonsState buttonShowedState = ButtonsState.GONE;
private RectF buttonInstance = null;
private RecyclerView.ViewHolder currentItemViewHolder = null;
private SwipeControllerActions buttonsActions = null;
private final float buttonWidth;
Resources rs;
public SwipeController(SwipeControllerActions buttonsActions, Resources rs) {
this.buttonsActions = buttonsActions;
this.rs = rs;
buttonWidth = toPx(80);
}
public float toPx(float dp) {
return dp * (rs.getDisplayMetrics().densityDpi/160f);
}
@Override
public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
return makeMovementFlags(0, LEFT | RIGHT);
}
@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
return false;
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
buttonsActions.onReset(viewHolder.getAdapterPosition());
}
@Override
public int convertToAbsoluteDirection(int flags, int layoutDirection) {
if (swipeBack) {
swipeBack = buttonShowedState != ButtonsState.GONE;
return 0;
}
return super.convertToAbsoluteDirection(flags, layoutDirection);
}
@Override
public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
if (actionState == ACTION_STATE_SWIPE) {
if (buttonShowedState != ButtonsState.GONE) {
if (buttonShowedState == ButtonsState.LEFT_VISIBLE) dX = Math.max(dX, buttonWidth);
if (buttonShowedState == ButtonsState.RIGHT_VISIBLE) dX = Math.min(dX, -buttonWidth);
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
else {
setTouchListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
}
if (buttonShowedState == ButtonsState.GONE) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
currentItemViewHolder = viewHolder;
}
private void setTouchListener(final Canvas c, final RecyclerView recyclerView, final RecyclerView.ViewHolder viewHolder, final float dX, final float dY, final int actionState, final boolean isCurrentlyActive) {
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
swipeBack = event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_UP;
if (swipeBack) {
if (dX < -buttonWidth) buttonShowedState = ButtonsState.RIGHT_VISIBLE;
else if (dX > buttonWidth) buttonShowedState = ButtonsState.LEFT_VISIBLE;
if (buttonShowedState != ButtonsState.GONE) {
setTouchDownListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
setItemsClickable(recyclerView, false);
}
}
return false;
}
});
}
private void setTouchDownListener(final Canvas c, final RecyclerView recyclerView, final RecyclerView.ViewHolder viewHolder, final float dX, final float dY, final int actionState, final boolean isCurrentlyActive) {
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
setTouchUpListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
return false;
}
});
}
private void setTouchUpListener(final Canvas c, final RecyclerView recyclerView, final RecyclerView.ViewHolder viewHolder, final float dX, final float dY, final int actionState, final boolean isCurrentlyActive) {
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_UP) {
SwipeController.super.onChildDraw(c, recyclerView, viewHolder, 0F, dY, actionState, isCurrentlyActive);
recyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
return false;
}
});
setItemsClickable(recyclerView, true);
swipeBack = false;
if (buttonsActions != null && buttonInstance != null && buttonInstance.contains(event.getX(), event.getY())) {
if (buttonShowedState == ButtonsState.LEFT_VISIBLE) {
buttonsActions.onLeftClicked(viewHolder.getAdapterPosition());
}
else if (buttonShowedState == ButtonsState.RIGHT_VISIBLE) {
buttonsActions.onRightClicked(viewHolder.getAdapterPosition());
}
}
buttonShowedState = ButtonsState.GONE;
currentItemViewHolder = null;
}
return false;
}
});
}
private void setItemsClickable(RecyclerView recyclerView, boolean isClickable) {
for (int i = 0; i < recyclerView.getChildCount(); ++i) {
recyclerView.getChildAt(i).setClickable(isClickable);
}
}
private void drawButtons(Canvas c, RecyclerView.ViewHolder viewHolder) {
float buttonWidthWithoutPadding = buttonWidth - 20;
float corners = toPx(12);
View itemView = viewHolder.itemView;
Paint p = new Paint();
RectF leftButton = new RectF(itemView.getLeft(), itemView.getTop(), itemView.getLeft() + buttonWidthWithoutPadding, itemView.getBottom());
p.setColor(rs.getColor(R.color.main_400));
c.drawRoundRect(leftButton, corners, corners, p);
drawText("이동", c, leftButton, p);
RectF rightButton = new RectF(itemView.getRight() - buttonWidthWithoutPadding, itemView.getTop(), itemView.getRight(), itemView.getBottom());
p.setColor(rs.getColor(R.color.red_cancel));
c.drawRoundRect(rightButton, corners, corners, p);
drawText("삭제", c, rightButton, p);
buttonInstance = null;
if (buttonShowedState == ButtonsState.LEFT_VISIBLE) {
buttonInstance = leftButton;
}
else if (buttonShowedState == ButtonsState.RIGHT_VISIBLE) {
buttonInstance = rightButton;
}
}
private void drawText(String text, Canvas c, RectF button, Paint p) {
float textSize = toPx(16);
p.setColor(Color.WHITE);
p.setAntiAlias(true);
p.setTextSize(textSize);
float textWidth = p.measureText(text);
c.drawText(text, button.centerX()-(textWidth/2), button.centerY()+(textSize/2), p);
}
public void onDraw(Canvas c) {
if (currentItemViewHolder != null) {
drawButtons(c, currentItemViewHolder);
}
}
}
해당 부분은 아래 레포에서 다음 부분들을 확인하면 된다.
sososhopping_customer/app/src/main/java/com/sososhopping/customer/mysoso/view
MysosoReviewFragment (리뷰 화면 프레그먼트)
adapter> MysosoReviewAdapter (리뷰 리사이클러뷰 어댑터)
SwipeController
SwipeControllerActions
전문가가 아니라 정확하지 않은 지식이 담겨있을 수 있습니다.
언제든지 댓글로 의견을 남겨주세요!
'코딩 > 안드로이드' 카테고리의 다른 글
안드로이드 이메일, 비밀번호 입력 폼 (머테리얼 디자인) [Android, JAVA] (1) | 2022.03.12 |
---|---|
안드로이드 트리 구조 리사이클러뷰 구현 (접었다 폈다) [Android, JAVA] (2) | 2022.02.19 |
안드로이드 Daum 주소 검색 API 활용하기 [Android] (6) | 2022.02.18 |
JetPack 직전 DialogFragment 값 받아오기 (BackStackEntry, SavedStateHandle) [Android (0) | 2022.02.11 |
RecyclerView 아이템 클릭 이벤트 구현 [Android, JAVA] (4) | 2022.01.29 |
Comment