안드로이드 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가 성능이 우수하다.한번에 바꾸기는 다소 무리가 있으며 천천히 준비 해가면서 바꾸거나, 신규 앱개발시 복잡한 리스트인 경우라면 써볼만하다.




리스트 상단으로 가기: Android vs. iOS


안드로이드 개발자로써 iOS 개발에 전혀 관심이 없지만 IOS를 쓰다보면 안드로이드에서 찾아 볼 수 없는 흥미로운 UI들이 있다. 리스트뷰에서 스크롤을 하다가 제일 위로 가고싶은 경우에안드로이드의 경우에는 플랫폼적으로 지원하지 않지만,  iOS는 플랫폼적으로 이런 기능을 제공하고 있다. 

위의 그림처안 iOS는 시간이 표시되는 작업표시줄영역을 터치시 시스템적으로 리스트의 제일 상단으로 자동 스크롤링 되는 것을 볼 수 있다. 안드로이드 플랫폼은 ListView, GridView, ScrollView, WebView, MapView등의 Scroll Containers가 모두 재각각이기 때문에 시스템에서 모든 View들에 대한 Scroll-To-Top기능을 시스템적으로 제공하기가 거의 불가능 했던 걸로 보인다. 

제조사에서도 이런 기능을 제공하기위해 시도했던걸로 보이나 글로벌하게 사용하게끔 만들지는 못했던것 같다. 삼성 갤럭시 시리즈에 Scroll-To-Top기능을 일부 화면에서 제한적으로 제공한다. 물론 iOS와 같이 작업표시줄이 아닌 물리적인 힘으로 폰을 장구치긋이 두번 두들겨 줘야 한다.

안드로이드에서는 이런 문제점을 FastScroll을 약간 만회 할 수 있지만 사용자 측면에서는 대단히 불편 할 뿐만 아니라, 시도조차 해보지 않는 UI적인 요소가 문제점이다. 이런 문제점을 앱내에서 직접 구현 하는 방법을 소개 해보겠다. 

상단으로 스크롤 하기 위한 조건

1) 항상 상단위로 스크롤하기 UI를 표현 할 필요는 없다. 

2) 사용자가 위로 가기위한 액션을 하고 있을때 UI적으로 표현한다.  

상단으로 가기위한 ListView의 Method들

1) setSelection(int position): 지정된 위치로 스크롤 된다. 

2) smoothScrollPosition(int position): API 8 부터 제공되며, smooth하게 스크롤 된다.

3) smoothScrollToPositionFromTop(int position, int offset): API 11부터 제공되며, smooth Scroll시 pixel offset을 지정 할 수 있다.  

API 버전이 높아 질 수록 제공되는 파라미터가 복잡하다. 이런 복잡성을 자동으로 해주는 AutoScrollListView의 requestPositionToScreen(int position, boolean smoothScroll)을 사용하면 된다 .

사용자가 상단으로 스크롤하기위한 액션을 알아내기위한 스크롤시 가속도 측정

a(가속도) = v – v0(이동거리) / t(시간) 아래의 공식과 ListView의 onScrollListener를 통해서 가속도를 계산 할 수 있다.

코드는 아래와 같다. 

public class VelocityListView extends AutoScrollListView {

    /**
     * A callback to be notified the velocity has changed.
     * 
     * @author Cyril Mottier
     */
    public interface OnVelocityListViewListener {
        void onVelocityChanged(int velocity);
    }

    private static final long INVALID_TIME = -1;

    /**
     * This value is really necessary to avoid weird velocity values. Indeed, in
     * fly-wheel mode, onScroll is called twice per-frame which results in
     * having a delta divided by a value close to zero. onScroll is usually
     * being called 60 times per seconds (i.e. every 16ms) so 10ms is a good
     * threshold.
     */
    private static final long MINIMUM_TIME_DELTA = 10L;

    private final ForwardingOnScrollListener mForwardingOnScrollListener = new ForwardingOnScrollListener();

    private OnVelocityListViewListener mOnVelocityListViewListener;

    private long mTime = INVALID_TIME;
    private int mVelocity;

    private int mFirstVisiblePosition;
    private int mFirstVisibleViewTop;
    private int mLastVisiblePosition;
    private int mLastVisibleViewTop;

    public VelocityListView(Context context) {
        super(context);
        init();
    }

    public VelocityListView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public VelocityListView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init();
    }

    private void init() {
        super.setOnScrollListener(mForwardingOnScrollListener);
        mForwardingOnScrollListener.selfListener = mOnScrollListener;
    }

    @Override
    public void setOnScrollListener(OnScrollListener l) {
        mForwardingOnScrollListener.clientListener = l;
    }

    public void setOnVelocityListener(OnVelocityListViewListener l) {
        mOnVelocityListViewListener = l;
    }

    /**
     * Return an approximative value of the ListView's current velocity on the
     * Y-axis. A negative value indicates the ListView is currently being
     * scrolled towards the bottom (i.e items are moving from bottom to top)
     * while a positive value indicates it is currently being scrolled towards
     * the top (i.e. items are moving from top to bottom).
     * 
     * @return An approximative value of the ListView's velocity on the Y-axis
     */
    public int getVelocity() {
        return mVelocity;
    }

    private void setVelocity(int velocity) {
        if (mVelocity != velocity) {
            mVelocity = velocity;
            if (mOnVelocityListViewListener != null) {
                mOnVelocityListViewListener.onVelocityChanged(velocity);
            }
        }
    }

    /**
     * @author Cyril Mottier
     */
    private static class ForwardingOnScrollListener implements OnScrollListener {

        private OnScrollListener selfListener;
        private OnScrollListener clientListener;

        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
            if (selfListener != null) {
                selfListener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount);
            }
            if (clientListener != null) {
                clientListener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount);
            }
        }

        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
            if (selfListener != null) {
                selfListener.onScrollStateChanged(view, scrollState);
            }
            if (clientListener != null) {
                clientListener.onScrollStateChanged(view, scrollState);
            }
        }
    }

    private OnScrollListener mOnScrollListener = new OnScrollListener() {
        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
            switch (scrollState) {
                case SCROLL_STATE_IDLE:
                    mTime = INVALID_TIME;
                    setVelocity(0);
                    break;

                default:
                    break;
            }
        }

        @Override
        public void onScroll(AbsListView view, int firstVisiblePosition, int visibleItemCount, int totalItemCount) {

            final long now = AnimationUtils.currentAnimationTimeMillis();
            final int lastVisiblePosition = firstVisiblePosition + visibleItemCount - 1;

            if (mTime != INVALID_TIME) {

                final long delta = now - mTime;
                if (now - mTime > MINIMUM_TIME_DELTA) {
                    int distance = 0;
                    //@formatter:off
                    if (mFirstVisiblePosition >= firstVisiblePosition 
                            && mFirstVisiblePosition <= lastVisiblePosition) {
                        distance = getChildAt(mFirstVisiblePosition - firstVisiblePosition).getTop() - mFirstVisibleViewTop;

                    } else if (mLastVisiblePosition >= firstVisiblePosition 
                            && mLastVisiblePosition <= lastVisiblePosition) {
                        distance = getChildAt(mLastVisiblePosition - firstVisiblePosition).getTop() - mLastVisibleViewTop;
                    //@formatter:on
                    } else {
                        // We're in a case were the item we were previously
                        // referencing has moved out of the visible window.
                        // Let's compute an approximative distance
                        int heightSum = 0;
                        for (int i = 0; i < visibleItemCount; i++) {
                            heightSum += getChildAt(i).getHeight();
                        }

                        distance = heightSum / visibleItemCount * (mFirstVisiblePosition - firstVisiblePosition);
                    }

                    setVelocity((int) (1000d * distance / delta));
                }
            }

            mFirstVisiblePosition = firstVisiblePosition;
            mFirstVisibleViewTop = getChildAt(0).getTop();
            mLastVisiblePosition = lastVisiblePosition;
            mLastVisibleViewTop = getChildAt(visibleItemCount - 1).getTop();

            mTime = now;
        }
    };

}

이렇게 ListView의 setOnVelocityListener를 통해 속도 변화를 감지 할 수 있다. 스크롤시 특정 속도변화가 생기면 버튼을 노출 하도록 레이아웃을 구성 하면 된다.

setOnVelocityListener를 통해 속도변화가 생기면 버튼을 보이고, 아니면 숨기도록 작업을 하면 된다.

public class MainActivity extends Activity {

    private static final int VELOCITY_ABSOLUTE_THRESHOLD = 5500;

    private static final int BIT_VISIBILITY = 0x01;
    private static final int BIT_ANIMATION = 0x02;

    private static final int SCROLL_TO_TOP_HIDDEN = 0;
    private static final int SCROLL_TO_TOP_HIDING = BIT_ANIMATION;
    private static final int SCROLL_TO_TOP_SHOWN = BIT_VISIBILITY;
    private static final int SCROLL_TO_TOP_SHOWING = BIT_ANIMATION | BIT_VISIBILITY;

    private VelocityListView mListView;
    private Button mScrollToTopButton;

    private ViewPropertyAnimator mAnimator;

    private int mVelocityAbsoluteThreshold;
    private int mScrollToTopState = SCROLL_TO_TOP_HIDDEN;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        mVelocityAbsoluteThreshold = (int) (VELOCITY_ABSOLUTE_THRESHOLD * getResources().getDisplayMetrics().density + 0.5f);

        setContentView(R.layout.main_activity);
        
        mScrollToTopButton = (Button) findViewById(R.id.btn_scroll_to_top);
        mScrollToTopButton.setOnClickListener(mOnClickListener);

        mAnimator = mScrollToTopButton.animate();

        mListView = (VelocityListView) findViewById(android.R.id.list);
        mListView.setAdapter(new CheesesAdapter());
        mListView.setOnVelocityListener(mOnVelocityListener);
    }

    private OnClickListener mOnClickListener = new OnClickListener() {
        @Override
        public void onClick(View v) {
            mListView.requestPositionToScreen(0, true);
        }
    };

    private OnVelocityListViewListener mOnVelocityListener = new OnVelocityListViewListener() {
        @Override
        public void onVelocityChanged(int velocity) {
            if (velocity > 0) {
                if (Math.abs(velocity) > mVelocityAbsoluteThreshold) {
                    if ((mScrollToTopState & BIT_VISIBILITY) == 0) {
                        mAnimator.translationY(0).setListener(mOnShownListener);
                        mScrollToTopState = SCROLL_TO_TOP_SHOWING;
                    }
                }
            } else {
                if ((mScrollToTopState & BIT_VISIBILITY) == BIT_VISIBILITY) {
                    mAnimator.translationY(-mScrollToTopButton.getHeight()).setListener(mOnHiddenListener);
                    mScrollToTopState = SCROLL_TO_TOP_HIDING;
                }
            }
        }
    };

    private final AnimatorListener mOnHiddenListener = new AnimatorListenerAdapter() {
        public void onAnimationEnd(Animator animation) {
            mScrollToTopState = SCROLL_TO_TOP_HIDDEN;
        };
    };

    private final AnimatorListener mOnShownListener = new AnimatorListenerAdapter() {
        public void onAnimationEnd(Animator animation) {
            mScrollToTopState = SCROLL_TO_TOP_SHOWN;
        };
    };
    
}

소스코드:

cfile7.uf.27189145524E532F347739.zip

이런식의 구현 방식을 오픈소스화 해서 공개한 StickyScrollViewItems도 있으니 참고 하자.

Android 유연성 있는 ViewHolder Pattern

얼마전 ListView 포퍼먼스 팁에 관한 블로그 포스팅을 한적이 있다.  Adapter에서 View의 재활용과 함께 ViewHolder Pattern으로 findViewById를 View생성 시점에 setTag()를 하여 재활용에 대해 언급 했다. 

이 방법은 각 ListView의 ViewItem별로 각각의 ViewHolder를 가지고 있어야 한다.  

ListView의 아이템별로 서로 다른 디자인이 필요하기에 View의 종류가 달라 질 수 밖에 없기때문에 ViewHolder도 각각 존재 할 수 밖에 없다. 이렇게 static하게 ViewHolder을 가지고 있는것 보다 유연하게 ViewHolder를 생성 할 수 있는 코드를 생성하는 방법에 대해서 알아보자.

아래처럼 Adapter에 static class로 만들어 ViewHolder를 사용하는것이 일반적이다.  ViewHolder는 같은 기능을 하는데 단순히 View의 종류가 달라져 각각 생성 할수 밖에 없는 상황이다. 

private static class ViewHolder {

    public final ImageView bananaView;
    public final TextView phoneView;

    public ViewHolder(ImageView bananaView, TextView phoneView) {
        this.bananaView = bananaView;
        this.phoneView = phoneView;
    }
}

@Override
public View getView(int position, View convertView, ViewGroup parent) {

    ImageView bananaView;
    TextView phoneView;

    if (convertView == null) {
        convertView = LayoutInflater.from(context).inflate(R.layout.banana_phone, parent, false);
        bananaView = (ImageView) convertView.findViewById(R.id.banana);
        phoneView = (TextView) convertView.findViewById(R.id.phone);
        convertView.setTag(new ViewHolder(bananaView, phoneView));
    } else {
        ViewHolder viewHolder = (ViewHolder) convertView.getTag();
        bananaView = viewHolder.bananaView;
        phoneView = viewHolder.phoneView;
    }

    BananaPhone bananaPhone = getItem(position);
    phoneView.setText(bananaPhone.getPhone());
    bananaView.setImageResource(bananaPhone.getBanana());

    return convertView;
}

해결책으로 아래와 같이 ViewGroup의 View들에 대해 동적으로 생성하는 코드를 만들 수 있다.

public class ViewHolder {
    @SuppressWarnings("unchecked")
    public static <T extends View> T get(View view, int id) {
        SparseArray<View> viewHolder = (SparseArray<View>) view.getTag();
        if (viewHolder == null) {
            viewHolder = new SparseArray<View>();
            view.setTag(viewHolder);
        }
        View childView = viewHolder.get(id);
        if (childView == null) {
            childView = view.findViewById(id);
            viewHolder.put(id, childView);
        }
        return (T) childView;
    }
}

모든 View들은 View를 상속 받고 있기 때문에  SparseArray를 통해서  ChildView의 Id가 없으면 findViewById()를 통해 put해주고 있으면 get으로 가져오는 방식이다. 

사용하는 간단한 예를 알아보면:

@Override
public View getView(int position, View convertView, ViewGroup parent) {

    if (convertView == null) {
        convertView = LayoutInflater.from(context).inflate(R.layout.banana_phone, parent, false);
    }

    ImageView bananaView = ViewHolder.get(convertView, R.id.banana);
    TextView phoneView = ViewHolder.get(convertView, R.id.phone);

    BananaPhone bananaPhone = getItem(position);
    phoneView.setText(bananaPhone.getPhone());
    bananaView.setImageResource(bananaPhone.getBanana());

    return convertView;
}

기존의 방법보다 더 간결화되고 심플해진 것을 볼 수 있다.

리스트뷰 퍼포먼스 팁

안드로이드의 ListView는 확장성및 성능을 위해 설계된 뷰이다. 일반 View를 기반으로 스크롤처리를 하면 뷰를 inflate할때 상당한 시간과 메모리부족등의 문제가 발생될 것이다. 이러한 문제점을 해결하기위해 ListView를 제공하고 있다.

요즘도 흔하지만 안드로이드 초기 시절 ListView를 스크롤 할때 상당히 끊김이 심한 경우를 볼 수 있었다. (국내 포털 N사앱에서 스크롤시 ANR발생되는 등) 이는 ListView의 구조를 정확하게 이해하지 않고 개발을 했기 때문이다.

그래서 ListView가 어떤 원리로 작동되는지와 어떻게 하면 성능을 최적화해서 좀 더 자연스러운 스크롤을 할 수 있는지에대해 써볼까 한다.

 

ListView의 작동 원리

ListView는 View를 재활용한다. 화면에 보여질 뷰를 inflate 한다음 스크롤시 재활용 하는 방식이다.  View inflate는 많은 비용이 드는 작업이며, 화면에 보이지도 않은 View를 미리그려 메모리의 문제가 생길 수 있기 때문에 한번 생성된 뷰를 재활용해서 데이터만 바꾸는 구조이다.

ListView내부 코드를 보면 ScrapView라는 화면에 보여질 View 배열이 존재 한다. ListView의 포지션에 따라 이 ScrapView의 위치가 바뀌게되는 구조로 재활용 하게 된다.

 

ConvertView 재활용

ListView의 Adapter에는 getView()라는 메소드가존재 한다. 해당 포지션이 보이면 이 getView()가 호출된다. 다들 아는것 처럼 인수로 View convertView, int position, ViewGroup parent 가 들어 온다.

convertView는 위에서 언급한 재활용 되는 ScrapView[]이다.재활용 하기때문에 최초 한번만 convertView는 NULL값이 들어 오며 그후에 호출 되면 이미 생성된 View가 들어 오게 된다.

그렇기 때문에 아래 처럼 convertView가  NULL일 때만 inflate해주면 된다.

public View getView(int position, View convertView, ViewGroup parent) {
    if (convertView == null) {
        convertView = mInflater.inflate(R.layout.your_layout, null);
    }

    TextView text = (TextView) convertView.findViewById(R.id.text);
    text.setText("Position " + position);

    return convertView;
}

 

ViewHolder Pattern

inflate된 View를 findViewById()메소드를 통해 찾는 것이 일반적이다. 레이아웃 구조가 복잡하고, 많은 데이터를 View에 설정 할때 이또한 많은 비용이 발생하게 된다. 이렇게 많은 비용이 발생하게 된다면 스크롤시 자연스러움을 보장 할 수 없게된다.

이미 찾은 View를 ViewHolder Pattern을이용해서 convertView의 Tag값이 저장해두는 방식을 쓰면 매번 findViewById()할 필요가 없다. convertView가 최초로 호출 될때 findViewById()로 찾은 View를 Tag에 저장하며, 그 이후로 호출되면 Tag에 저장된 View를 불러오는 방식이다.

public View getView(int position, View convertView, ViewGroup parent) {
    ViewHolder holder;

    if (convertView == null) {
        convertView = mInflater.inflate(R.layout.your_layout, null);

        holder = new ViewHolder();
        holder.text = (TextView) convertView.findViewById(R.id.text);

        convertView.setTag(holder);
    } else {
        holder = convertView.getTag();
    }

    holder.text.setText("Position " + position);

    return convertView;
}

private static class ViewHolder {
    public TextView text;
}

비동기 처리

getView()는 UI Thread에서 작동 되기때문에 인터넷이나 로컬에서 사진을 불러오는등 동적인 작업시 전혀다른 Thread를 통해서 비동기 처리해야 된다. 그렇지 않을경우 스크롤시 동기화처리되어 아주 많이 끊기게 된다. 안드로이드에서 비동기 처리방법으로 AsyncTask를 사용하면된다.

public View getView(int position, View convertView,
        ViewGroup parent) {
    ViewHolder holder;

    ...

    holder.position = position;

    new ThumbnailTask(position, holder)
            .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, null);

    return convertView;
}

private static class ThumbnailTask extends AsyncTask {
    private int mPosition;
    private ViewHolder mHolder;

    public ThumbnailTask(int position, ViewHolder holder) {
        mPosition = position;
        mHolder = holder;
    }

    @Override
    protected Cursor doInBackground(Void... arg0) {
        // Download bitmap here
    }

    @Override
    protected void onPostExecute(Bitmap bitmap) {
        if (mHolder.position == mPosition) {
            mHolder.thumbnail.setImageBitmap(bitmap);
        }
    }
}

private static class ViewHolder {
    public ImageView thumbnail;
    public int position;
}

그외 사항

스크롤시 빠르게 지나가는 경우 삭제될 데이터들에 대한 자원을 낭비 하게 된다. 이런 경우를 대비해서 스크롤 중일때는 기본데이터만 가져오고 스크롤이 멈추면 모든데이터를 가져오는 방식도 추천할만하다. ListView의 onScrollChangeListener을 통해 스롤되는중이거나 스크롤이 끝났음을 Callback 받을수 있으며 Adapter에서 flag를 만들어 getView에서 모든데이터를 가져올때와 기본데이터만 보여줄것을 구현 하면된다.

이처럼 위의 사항을 잘 지킨다면 아무리 많고 복잡한 데이터일지라도 끊김 없는 아주 자연스러운  ListView를 구현 할 수 있을 것이다.