안드로이드 ListView에서 RecyclerView로 마이그레이션 하기


안드로이드 5.0(롤리팝)에서 발표된 RecyclerView는 ListView의 확장성과 유연성을 높이기위해 만들어진 고급위젯이다. 이런 유연성때문에 기존의 ListView의 기능들이 RecyclerView에서는 직접 구현해야되는 경우가 많아 졌다. 그렇다면 ListView에서 RecyclerView로 마이그레이션 하기위해 우리는 무엇을 고민해야 하는지 하나씩 설명해보겠다.

1. OnItemClickListener, OnItemLongClickListener

ListView에서 아이템을 클릭시 콜백 받을수 있는 리스너는 RecyclerView에서는 존재하지 않으며 그대신 OnItemTouchListner가 새롭게 생겼다. 터치 이벤트를 통해 사용자가 아이템을 클릭했는지, 롱클릭 했는지등을 직접 처리해야 한다. 아니면 ViewHolder에서 아이템별로 OnClickListener를 달아준다.

전자의 방법은 아래 코드와 같이 GestureDector를 사용해서 onLongPress(), onSingleTapConfirmed()의 이벤트를 분기하는 방법이다.

GestureDetectorCompat mGestureDetector = new GestureDetectorCompat(getActivity().getApplicationContext(), new GestureDetector.SimpleOnGestureListener() {

    @Override
    public boolean onSingleTapConfirmed(MotionEvent e) {
        
        View childView = getRecyclerView().findChildViewUnder(e.getX(), e.getY());
        int position = getRecyclerView().getChildPosition(childView);
        
        //ItemClick
        
        return super.onSingleTapConfirmed(e);
    }
    
    @Override
    public void onLongPress(MotionEvent e) {
    
        View childView = getRecyclerView().findChildViewUnder(e.getX(), e.getY());
        int position = getRecyclerView().getChildPosition(childView);
        
        //LongClick
        
        super.onLongPress(e);
    }

});


@Override
public boolean onInterceptTouchEvent(RecyclerView recyclerView, MotionEvent event) {
    mGestureDetector.onTouchEvent(event);

    return false;
}

하지만 실제로 사용시 문제 점이 발견되어 쉽게 처리가 불가능할것 같다. 이유는 스크롤시 사용자가 스크롤을 멈추기 위해 화면을 터치 하는 순간 onSingleTapConfirmed 이벤트가 발생하는 문제점이다. 이렇게 되면 사용자는 스크롤을 멈추는게 아닌 아이템클릭으로 인식 되는 오작동이 생기기 때문에 이런 문제를 해결하기위해 좀 더 추가적인 작업이 필요하다.

findChildViewUnder(e.getX(), e.getY()) : 터시이벤트로 해당아이템 뷰 가져오기

getChildPosition(childView) : 해당뷰의 아이템 포지션

 

후자의 방법은 Adapter에 OnClick이벤트를 념겨 ViewHolder의 클릭이벤트로 달아 주는 것이다.

class SimpleViewHolder extends ViewHolder {

    public ImageView mImage;
    public TextView mText;
    public OnClickListener mOnClickListener;

    public SimpleViewHolder(View itemView) {
        super(itemView);

        mImage = (ImageView) itemView.findViewById(R.id.img);
        mText = (TextView) itemView.findViewById(R.id.txt);

        itemView.setOnClickListener(mOnClickListener);

    }
}

 

2. setOnScrollListener

ListView의 setOnScrollListener는 RecyclerView에서도 있으나 콜백되는 데이터가 다르다.

  • ListView
    public void onScrollStateChanged(AbsListView view, int scrollState)
    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)
  • RecyclerView
    public void onScrollStateChanged(RecyclerView recyclerView, int newState)
    public void onScrolled(RecyclerView recyclerView, int dx, int dy)

ListView의 onScroll을 통해 리스트의 제일 하단 일 경우 더보기 기능을 구현해서 썼다면 RecyclerView onScrolled()로는 알 수 없으며 LayoutManager에서 별도의 메서드로 위치를 찾아야 한다.

public void onScrolled(RecyclerView recyclerView, int dx, int dy){
    super.onScrolled(recyclerView, dx, dy);

    int visibleItemCount = recyclerView.getChildCount();
    int totalItemCount = recyclerView.getLayoutManager().getItemCount();
    int firstVisibleItem = ((LinearLayoutManager) recyclerView.getLayoutManager()).findFirstVisibleItemPosition();
}

 

3. HeaderView, FooterView

ListView에서 제공되는 addHeaderView와 addFooterView는 더이상 제공되지 않으며 Adapter에서 ViewType를 통해 Header와 Footer 뷰를 제어 해야 한다. 아래와 같이 getItemCount와 getItemViewType을 통해 onCreateViewHolder와 onBindViewHolder에서 Header와 Footer뷰의를 생성하게끔한다. 이렇게 ListView에서 HeaderView와 FooterView를 지원하지 않게 된이유는 재활용되지 않는 문제점을 해결하고자 애초에 문제점가 발생 되지 않기 위해 삭제 해버린것 같다. 어쨌든 아래 코드를 통해 Header와 Footer뷰를 Adapter에서 구성할 수 있다.

public abstract class HeaderRecyclerViewAdapter extends RecyclerView.Adapter {
    private static final int TYPE_HEADER = Integer.MIN_VALUE;
    private static final int TYPE_FOOTER = Integer.MIN_VALUE + 1;
    private static final int TYPE_ADAPTEE_OFFSET = 2;

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        if (viewType == TYPE_HEADER) {
            return onCreateHeaderViewHolder(parent, viewType);
        } else if (viewType == TYPE_FOOTER) {
            return onCreateFooterViewHolder(parent, viewType);
        }
        return onCreateBasicItemViewHolder(parent, viewType - TYPE_ADAPTEE_OFFSET);
    }

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
        if (position == 0 && holder.getItemViewType() == TYPE_HEADER) {
            onBindHeaderView(holder, position);
        } else if (position == getBasicItemCount() && holder.getItemViewType() == TYPE_FOOTER) {
            onBindFooterView(holder, position);
        } else {
            onBindBasicItemView(holder, position - (useHeader() ? 1 : 0));
        }
    }

    @Override
    public int getItemCount() {
        int itemCount = getBasicItemCount();
        if (useHeader()) {
            itemCount += 1;
        }
        if (useFooter()) {
            itemCount += 1;
        }
        return itemCount;
    }

    @Override
    public int getItemViewType(int position) {
        if (position == 0 && useHeader()) {
            return TYPE_HEADER;
        }

        if (position == getBasicItemCount() && useFooter()) {
            return TYPE_FOOTER;
        }

        if (getBasicItemType(position) != Integer.MAX_VALUE - TYPE_ADAPTEE_OFFSET) {
            new IllegalStateException("HeaderRecyclerViewAdapter offsets your BasicItemType by " + TYPE_ADAPTEE_OFFSET + ".");
        }

        return getBasicItemType(position) + TYPE_ADAPTEE_OFFSET;
    }

    public abstract boolean useHeader();

    public abstract RecyclerView.ViewHolder onCreateHeaderViewHolder(ViewGroup parent, int viewType);

    public abstract void onBindHeaderView(RecyclerView.ViewHolder holder, int position);

    public abstract boolean useFooter();

    public abstract RecyclerView.ViewHolder onCreateFooterViewHolder(ViewGroup parent, int viewType);

    public abstract void onBindFooterView(RecyclerView.ViewHolder holder, int position);

    public abstract RecyclerView.ViewHolder onCreateBasicItemViewHolder(ViewGroup parent, int viewType);

    public abstract void onBindBasicItemView(RecyclerView.ViewHolder holder, int position);

    public abstract int getBasicItemCount();

    public abstract int getBasicItemType(int position);
}

참고: https://gist.github.com/sebnapi/fde648c17616d9d3bcde

 

4. ItemSelect

ListView에서 기본적으로 지원되던 아이템 선택모드는 RecyclerView에서는 지원되지 않는다. 솔찍히 ListView에서 제공되는 기본 선택 기능은 좀 복잡한 경우 커스텀으로 직접 구현해야되는 문제점을 갖고있고 하위 OS의 경우 기능이 구현되어 있지도 않다. 이렇게 좀더 확장해서 써라는 의미로 애초에 지원하지 않은것 같다. 직접 구현하는 방법은 Adapter에서 선택 모드시 선택된 해당 아이템을 List로 직접 관리 하면된다. 구현 한다면 다음과 같다. 참고로 SparseBooleanArray는 순서에 상관없는 Array이며 빠르기 때문에 사용하였다.

public class RecyclerViewDemoAdapter
        extends RecyclerView.Adapter
                 {

   // ...
   private SparseBooleanArray selectedItems;
   
   // ...

   public void toggleSelection(int pos) {
      if (selectedItems.get(pos, false)) {
         selectedItems.delete(pos);
      }
      else {
         selectedItems.put(pos, true);
      }
      notifyItemChanged(pos);
   }
   
   public void clearSelections() {
      selectedItems.clear();
      notifyDataSetChanged();
   }
   
   public int getSelectedItemCount() {
      return selectedItems.size();
   }
   
   public List getSelectedItems() {
      List items = 
            new ArrayList(selectedItems.size());
      for (int i = 0; i < selectedItems.size(); i++) {
         items.add(selectedItems.keyAt(i));
      }
      return items;
   }

   // ...

}

참고: http://www.grokkingandroid.com/statelistdrawables-for-recyclerview-selection/

 

5. Divider

ListView에서 지원되는 아이템간의 Divider는 RecyclerView에는 없다. 아이템별로 하단에 View를 넣어 선을 긋는 방법도 있겠지만 RecyclerView에서 지원하는 ItemDecoration을 이용하면 간단하게 처리 가능하다.

 

public class DividerItemDecoration extends RecyclerView.ItemDecoration {
 
    private static final int[] ATTRS = new int[]{
            android.R.attr.listDivider
    };
 
    public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL;
 
    public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL;
 
    private Drawable mDivider;
 
    private int mOrientation;
 
    public DividerItemDecoration(Context context, int orientation) {
        final TypedArray a = context.obtainStyledAttributes(ATTRS);
        mDivider = a.getDrawable(0);
        a.recycle();
        setOrientation(orientation);
    }
 
    public void setOrientation(int orientation) {
        if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) {
            throw new IllegalArgumentException("invalid orientation");
        }
        mOrientation = orientation;
    }
 
    @Override
    public void onDraw(Canvas c, RecyclerView parent) {
        if (mOrientation == VERTICAL_LIST) {
            drawVertical(c, parent);
        } else {
            drawHorizontal(c, parent);
        }
    }
 
    public void drawVertical(Canvas c, RecyclerView parent) {
        final int left = parent.getPaddingLeft();
        final int right = parent.getWidth() - parent.getPaddingRight();
 
        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);
            final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
                    .getLayoutParams();
            final int top = child.getBottom() + params.bottomMargin;
            final int bottom = top + mDivider.getIntrinsicHeight();
            mDivider.setBounds(left, top, right, bottom);
            mDivider.draw(c);
        }
    }
 
    public void drawHorizontal(Canvas c, RecyclerView parent) {
        final int top = parent.getPaddingTop();
        final int bottom = parent.getHeight() - parent.getPaddingBottom();
 
        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);
            final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
                    .getLayoutParams();
            final int left = child.getRight() + params.rightMargin;
            final int right = left + mDivider.getIntrinsicHeight();
            mDivider.setBounds(left, top, right, bottom);
            mDivider.draw(c);
        }
    }
 
    @Override
    public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
        if (mOrientation == VERTICAL_LIST) {
            outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
        } else {
            outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
        }
    }
}

참고: https://gist.github.com/alexfu/0f464fc3742f134ccd1e

 

6. 그외

이외 아이템 레이아웃 배경에 selector에 android:state_pressed=”true”를 한다고 해도 pressed가 호출 되지 않아 아이템을 누르는 경우 배경이 변하지 않는다. RecyclerView가 이벤트를 childView로 넘기지 않아 발생되며 1번의 GestureDetector에서 onShowPress()를 통해 직접 해당뷰에 setPressed(true)를 호출하면된다.

ListView의 scrollToPosition()도 RecyclerView에서 관리 되는것이 아닌 recyclerView.getLayoutManager().scrollToPosition() 에서 관리된다.

 

이처럼 ListView에서 RecyclerView로 마이그레이션 하기에는 많은 기능을 직접 구현해야 되는 문제점이 있다. 하지만 복잡한 아이템이 많은 경우 ListView에 비해 RecyclerView가 성능이 우수하다.한번에 바꾸기는 다소 무리가 있으며 천천히 준비 해가면서 바꾸거나, 신규 앱개발시 복잡한 리스트인 경우라면 써볼만하다.