AdapterView로 Custom List 만들기 – 2. Touch event, Scrolling

앞서서 구현한 리스트 기본 예제에 터치 이벤트 리스너를 달고 스크롤이 되도록 합니다.

이 페이지에서 사용된 예제 소스는 아래에 있습니다.

 

예제 프로젝트 다운로드 : 


AdapterView로 Custom List 만들기 [4] – 애니메이션 효과

AdapterView로 Custom List 만들기 [3] – Velocity, Runnable

AdapterView로 Custom List 만들기 [2] – Touch event, Scrolling <<<<< 현재 위치 

AdapterView로 Custom List 만들기 [1] – AdapterView 기본 구조


 

터치 동작에 따라 스크롤이 되게 하려면

리스트의 전체 길이에서 현재 window(우리가 보는 리스트의 일부분) 영역이 바뀔때마다

화면을 어떻게 갱신할 것인지, 파라미터를 어떻게 구성할 것인지를 결정해야 합니다.

그리고 리스트에는 수 백, 수 천의 아이템이 추가 될 수 있으므로 이 전부를 리스트에 child view로

미리 설정해 두는 것은 비 효율적입니다. (초기 로딩시간 및 메모리 문제 등등)

고로 현재 window의 위치를 계산해서 화면에 필요한 child view만 추가하고 

보이지 않는 child view는 리스트에서 제거할 것입니다.

 

구체적으로 아래와 같은 방법으로 구현합니다.

(물론 여기서 기본적인 동작 순서와 구조만을 참고해서 본인이 직접 작성하는 것이 훨씬 도움이 될 것입니다.)

 

 

0. 최초 어댑터가 설정되면 리스트의 최 상단에 window(우리가 보는 리스트의 일부분 영역)를 위치시키고 

   화면에 필요한 아이템을 child view로 추가.

 

1. Touch-drag 에 의해서 window의 위치가 변경됨.

2. Window의 위치 이동에 따라 화면에서 보이지 않는 아이템들을(child view) 삭제

   (삭제된 아이템은 cache로 저장)

3. Window의 이동에 따라 화면에 새로 보이는 아이템을 child view로 추가

   (어댑터의 getView()를 호출해서 가져옴. cache 된 view 가 있다면 getView()의 convertView 파라미터로 사용)

4. 리스트 내의 child view 들의 위치를 조정

5. 화면을 갱신해서 리스트가 스크롤 된 것 처럼 보이게 함.

 

 

1. 터치 이벤트 구현

 

화면 스크롤의 시작은 터치 이벤트입니다.

여기서는 터치 이벤트 구현을 위해 onTouchEvent 를 상속하고, 추가로 onInterceptTouchEvent를 상속 받습니다.

후자는 child view에서 touch event를 처리하더라도 리스트에서 계속 touch 이벤트를 받을 수 있도록 하기 위해 추가한 메서드 입니다.

 

 

     /**

     * 터치 이벤트 리스너

     */

    @Override

    public boolean onTouchEvent(MotionEvent event) {

        if (getChildCount() == 0) {

            return false;

        }

        

        processTouch(event);            // 터치 동작을 분석 후 드래그 동작 수행

        

        return true;

    }

    

    /**

     * Child view에서 터치 이벤트를 사용하더라도 리스트에서 터치 이벤트를 계속 사용할 수 있도록 함.

     */

    @Override

    public boolean onInterceptTouchEvent(final MotionEvent event) {

         processTouch(event);            // 터치 동작을 분석 후 드래그 동작 수행

        return false;

    }

   

   

    private float mStartX = 0;                        // 터치 이벤트 시작된 기준 좌표 X

    private float mStartY = 0;                        // 터치 이벤트 시작된 기준 좌표 Y

    private float mDistance = 0;                        // 기준 좌표에서 움직인 거리

    private int mDirection = -1;                        // 기준 좌표에서 움직인 방향

    private boolean isProcessed = false; // 특정 event 처리 후 ACTION_UP 까지 다른 이벤트 처리가 필요 없을 때 설정하는 flag 

    private static final int TOUCH_DRAG_THRESHOLD = 0;        //특정 거리 이상을 움직였을 때만 Drag로 인식 

    private static final int TOUCH_CLICK_THRESHOLD = 900;    // 특정 거리 이상을 움직였을 때만 Touch로 인식

    

    /**

     * 터치 이벤트를 분석해서 드래그 동작을 수행

     */

    private void processTouch(MotionEvent event)

    {

        if(event.getAction()==MotionEvent.ACTION_DOWN) 

        {

            mStartX = event.getX();

            mStartY = event.getY();

            isProcessed = false;

        }

        else if(event.getAction()==MotionEvent.ACTION_MOVE && !isProcessed)

        {

            if( !isDrawing ) {

                mScreenTopOffset += (mStartY – event.getY());

                requestLayout();

                mStartX = event.getX();

                mStartY = event.getY();

            }

        }

        else if(event.getAction()==MotionEvent.ACTION_UP && !isProcessed)

        {

        }

    }

 

 

터치 이벤트를 분석해서 Drag 동작일 경우 변화시키는 값은 mScreenTopOffset 입니다.

(전체 리스트에서 Window가 시작되는 Y 좌표)

아이템들이 모두 이어진 가상의 리스트가 있다고 할 때 우리가 화면으로 보는 스크린이 어느 지점에 있는지를 나타냅니다.

이후에 requestLayout() 을 호출함으로써 onLayout()에서 화면을 다시 그릴 수 있도록 합니다.

그리고 화면 갱신속도보다 터치 이벤트가 훨씬 빠르게 업데이트 되므로 화면 갱신중에는 requestLayout()이 계속 호출되지 않도록 isDrawing flag를 확인합니다.

 

 

1-1. 화면의 재구성 – onLayout

 

화면을 다시 그리기 위한 모든 작업은 onLayout()에서 시작합니다.

아이템 삭제, 추가, layout 조정의 작업이 순서대로 호출됩니다.

아이템의 위치, 추가, 삭제 계산을 위해 추가한 파라미터를 유심히 봐야 합니다.

mScreenTopOffset 과 Drag 의 방향을 기본으로 아이템이 추가 삭제 될 때마다

mItemTopOffsetmFirstItemIndexmLastItemIndex 를 갱신합니다.

onLayout 수행중에는 isDrawing 을 true로 설정해서 touch event listener에서 onLayout을 호출하지 않도록 합니다.

 

 

 

    private boolean isDrawing = false;    // 화면 drawing 중에 touch 업데이트로 onLayout() 중복 호출을 방지

    private int mScreenTopOffset = 0;    // 전체 화면 길이에서 현재 화면이 시작되는 시작점

    private int mPrevScreenTopOffset = 0;// 이전 화면의 시작점을 기억

    private int mItemTopOffset = 0;// 전체 화면 길이에서 화면에 보이는 첫 번째 아이템이 시작되는 시작점

    private int mFirstItemIndex = 0;    // 화면에 보이는 첫 아이템의 index

    private int mLastItemIndex = 0;    // 화면에 보이는 마지막 아이템의 index

    private final LinkedList<View> mViewCache = new LinkedList<View>();    // 삭제한 뷰를 저장하는 캐시

    

    /**

     * 화면에 표시될 item을 추가/삭제, item의 위치를 설정

     * 사용자 touch, adapter data 변화에 따라 layout이 변경되는 시작점. 

     */

    @Override

    protected void onLayout(boolean changed, int l, int t, int r, int b) {

        super.onLayout(changed, l, t, r, b);

 

        if (mAdapter == null)

            return;

        isDrawing = true;                // drawing flag 설정, drawing 동안 touch 이벤트가 수행되지 않도록

 

        if (getChildCount() == 0)        // [0] 아이템이 없는 경우 변수 초기화 

        {

            mScreenTopOffset = 0;

            mItemTopOffset = 0;

            mFirstItemIndex = 0;

            mLastItemIndex = 0;

        }

        else {                    // [2] 이동한 화면에서 보이지 않는 아이템 삭제 

            removeNonVisibleViews();

        }

 

        insertListItems();        // [3] 이동한 화면에 필요한 아이템을 채움

        layoutItems();            // [4] 자식 뷰들의 화면 내 위치를 재설정

        invalidate();                // [5] 화면 업데이트

        

        mPrevScreenTopOffset = mScreenTopOffset;

        isDrawing = false;            // drawing flag 해제

    }

 

 

 

 

2. 화면의 재구성 – 보이지 않는 아이템 삭제

 

removeNonVisibleViews() 에서 화면 이동 후 보이지 않는 아이템을 삭제합니다.

먼저 스크롤 방향을 알아내서 상단의 아이템을 삭제할건지, 하단의 아이템을 삭제할건지 결정합니다.

 

상단의 아이템을 삭제할 때엔 아래의 파라미터를 사용하고,

mScreenTopOffset – 전체 리스트에서 현재 Window의 위치

mItemTopOffset – 현재 보여지는 아이템 중 최 상단 아이템의 시작 위치. 전체 리스트에 상대적인 위치

child.getMeasuredHeight() – 최 상단 아이템의 Height

 

하단의 아이템을 삭제할 때엔 아래의 파라미터로 계산합니다.

child.getTop() – 현재 보여지는 아이템 중 최 하단 아이템의 시작 위치. 현재 Window에 상대적인 위치

getHeight() – 현재 Window의 height

movedDistance – Drag로 움직인 거리

 

아이템이 삭제 될 때마다 아래 소스에서 파란색으로 보이는 전역 변수가 함께 업데이트 되어야 합니다.

 

 

     /**

     * 새롭게 설정된 리스트 영역에서 보이지 않는 아이템 삭제 [2]

     */

    private void removeNonVisibleViews() {

        int movedDistance = mScreenTopOffset – mPrevScreenTopOffset;

        int childCount = getChildCount();

        

        if(movedDistance > 0 && childCount > 1) {    // 위로 드래그, 화면 위에서 부터 가려지는 아이템을 삭제 

            View child = getChildAt(0);

            

            while( child != null && mItemTopOffset + child.getMeasuredHeight() < mScreenTopOffset ) {

                removeViewInLayout(child);

                childCount–;

                mViewCache.addLast(child);    // 삭제한 아이템은 캐시에 저장

                mItemTopOffset += child.getMeasuredHeight();

                mFirstItemIndex++;

                

                if(childCount > 1)

                    child = getChildAt(0);

                else

                    child = null;

            }

        }

        else if(movedDistance < 0 && childCount > 1) {    // 아래로 드래그, 화면 아래에 가려지는 아이템을 삭제

            View child = getChildAt(childCount – 1);

            

            while( child != null && child.getTop() > getHeight() + movedDistance ) {

                removeViewInLayout(child);

                childCount–;

                mViewCache.addLast(child);            // 삭제한 아이템은 캐시에 저장

                mLastItemIndex–;

                

                if(childCount > 1)

                    child = getChildAt(childCount – 1);

                else

                    child = null;

            }

        }

    }

 

 

 

3. 화면의 재구성 – 새로운 아이템 추가

 

새로운 아이템 추가도 삭제와 유사한 형태입니다. (산수는 약간 다르지만)

마찬가지로 아이템이 추가될 때마다 파란색 전역 변수를 업데이트 해야 합니다.

새로운 아이템을 어댑터에서 getView() 로 가져올 때 convertView 파라미터로 사용할 뷰를 캐시에서 꺼내옵니다.

주의할 점은 새로 생성한 child view의 width, height 값을 가져오는 child.getMeasuredWidth(), child.getMeasuredHeight() 메서드는 반드시 measuring 과정이 끝난 다음에 사용해야 합니다.

 

 

     /**

     * 새롭게 설정된 리스트 영역에 필요한 아이템을 채움 [3]

     */

    private void insertListItems() {

        int movedDistance = mScreenTopOffset – mPrevScreenTopOffset;

        int childCount = getChildCount();

        

        if( movedDistance > 0 && childCount > 0 ) {    // 위로 드래그, 화면이 아래로 내려옴. 아래쪽에 아이템을 채움

            View child = getChildAt(getChildCount()-1);

            int bottom = child.getBottom();

            while( getHeight() + movedDistance >  bottom

                    && mLastItemIndex < mAdapter.getCount() – 1) {

                mLastItemIndex++;

                child = mAdapter.getView(mLastItemIndex, getCachedView(), this);

                addViewAndMeasure(child, ADD_VIEW_AT_BOTTOM);

                bottom += child.getMeasuredHeight();

            }

        }

        else if( movedDistance < 0 && childCount > 0 ) {// 아래로 드래그, 화면이 위로 올라감. 위에 아이템을 채움

            View child = null;

            while( mScreenTopOffset <  mItemTopOffset

                    && mFirstItemIndex > 0 ) {

                mFirstItemIndex–;

                child = mAdapter.getView(mFirstItemIndex, getCachedView(), this);

                addViewAndMeasure(child, ADD_VIEW_AT_TOP);

                mItemTopOffset -= child.getMeasuredHeight();

            }

        }

        else {

 

            if(childCount < 1) {        // 리스트 초기화, 가장 상위의 화면을 구성

                View child = null;

                int bottom = 0;

                int index = 0;

                while( getHeight() >  bottom

                        && index < mAdapter.getCount() ) {

                    child = mAdapter.getView(index, getCachedView(), this);

                    addViewAndMeasure(child, ADD_VIEW_AT_BOTTOM);

                    bottom += child.getMeasuredHeight();

                    mLastItemIndex = index;

                    index++;

                }

            }

 

        }

    }

    

    private View getCachedView() {    // getView()를 호출할 때 convertView 파라미터로 사용.

        if (mViewCache.size() != 0) {

            mViewCache.remove();

        }

        return null;

    }

    

    private static final int ADD_VIEW_AT_TOP = 1;            // view를 현재 리스트 화면 최상위에 추가

    private static final int ADD_VIEW_AT_BOTTOM = 2;        // view를 현재 리스트 화면 최하단에 추가

    /**

     * child view 설정, measuring (부모와 자식 view 간의 size 협상)

     *

     * @param child             리스트에 추가할 child view

     * @param direction     Child view가 붙을 방향

     */

    private void addViewAndMeasure(View child, int direction) {    // child view를 리스트에 추가하고 Measuring 수행 

        Log.d("Card","+ addViewAndMeasure()");

        LayoutParams params = child.getLayoutParams();

        if (params == null) {

            params = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);

        }

        int index = 0;

        if(direction == ADD_VIEW_AT_TOP) {

            index = 0;

        } else {

            index = -1;

        }

        child.setDrawingCacheEnabled(true);            // drawing cache 사용. drawChild() 참고.

        addViewInLayout(child, index, params, true);

        int itemWidth = getWidth();

        child.measure(MeasureSpec.EXACTLY | itemWidth, MeasureSpec.UNSPECIFIED);

    }

 

 

 

4. 화면의 재구성 – Child view의 위치 조정

 

지울거 지우고 넣을거 넣었으니

Child view 들의 위치를 정렬시킵니다.

 

 

     /**

     * 자식 뷰가 표시될 화면 내 위치 설정 [4]

     * 자식 뷰 크기가 확정되어야 하므로 measuring 과정이 선행 되어야 함.

     */

    private void layoutItems() {

        Log.d("Card","+ layoutItems()");

        int top = mItemTopOffset – mScreenTopOffset;

     

        for (int index = 0; index < getChildCount(); index++) {

            View child = getChildAt(index);

 

            int width = child.getMeasuredWidth();

            int height = child.getMeasuredHeight();

            int left = (getWidth() – width) / 2;

     

            child.layout(left, top, left + width, top + height);

            top += height;

        }

    }

 

 

여기까지 수정해서 실행해보면 리스트가 터치에 반응해서 스크롤이 됩니다.

그런데.. 지금은 화면 상, 하 제한이 없어서 위, 아래로 무한정 스크롤이 됩니다.

일단 이 부분부터 수정을 해보죠.

 

 

 

     /**

     * 사용자 touch, adapter data 변화에 따라 layout이 변경되는 시작점. 

     */

    @Override

    protected void onLayout(boolean changed, int l, int t, int r, int b) {

        super.onLayout(changed, l, t, r, b);

 

        if (mAdapter == null)

            return;

        isDrawing = true;                    // drawing flag 설정

 

        if(mScreenTopOffset < 0) {             // 스크린이 최 상단을 벗어나지 못하게 [1]

            mScreenTopOffset = 0;

        }

        

        redrawLayout();  [2]

 

        // 스크린이 최 하단을 벗어나지 못하게 [3]

        if( mPrevScreenTopOffset < mScreenTopOffset &&

                mLastItemIndex == mAdapter.getCount() – 1 && 

                getChildAt(getChildCount()-1).getBottom() < getHeight() ) {

            

            mPrevScreenTopOffset = mScreenTopOffset;

            mScreenTopOffset -= ( getHeight() – getChildAt(getChildCount()-1).getBottom() );

            

            if(mScreenTopOffset < 0) {             // 다시 스크린이 최 상단을 벗어나지 못하게

                mScreenTopOffset = 0;

            }

            

            redrawLayout();

        }

 

        invalidate();                                // 화면 업데이트

        

        mPrevScreenTopOffset = mScreenTopOffset;

        isDrawing = false;                    // drawing flag 해제

    }

 

 

onLayout 에서 화면 재설정을 하기 전에 먼저 스크린이 상단 제한을 벗어났는지 체크합니다. [1]

 

화면에 아이템 첨삭과 measuring을 별도의 메서드로 분리 하였습니다. (redrawLayout[2]

 

아이템 첨삭 작업이 끝나면 다시 스크린의 하단 제한을 체크합니다. [3]

하단 제한선을 체크하기 위해서는 아이템의 높이 값들이 정해져야 하기 때문에

redrawLayout을 먼저 수행해야 합니다.

만약 하단 제한선을 벗어나면 Window의 위치를 수정해서 redrawLayout 과정을 다시 실행합니다.

 

예제 소스에 이 부분을 추가하면 이제 상, 하단 스크롤 제한이 걸리게 됩니다.

 

 

 

5. 하지만 아직…

 

여기까지 구현 된 스크롤은 우리가 일반적으로 사용하던 리스트의 스크롤과 같지는 않습니다.

지금은 단순히 드래그로 움직인 거리만큼만 움직이다보니 스크롤이 굼뜨다는 느낌이 듭니다.

다음 글에서 이 부분을 수정해 보겠습니다.

 

 

 

Post Author: TORTUGA

TORTUGA
궁금하신 점은 새로 개편한 홈페이지의 QnA 게시판을 이용해주세요!!!!!!! http://www.hardcopyworld.com/gnuboard5/bbs/board.php?bo_table=qna

댓글 남기기

이메일은 공개되지 않습니다.