리스트 상단으로 가기: 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도 있으니 참고 하자.