세로스크롤이 되는 갤러리

Android 2011.07.03 13:23 Posted by 기분째즈
가로스크롤로 그림은 넘기면서 안에 있는 내용물은 세로스크롤이 되는 갤러리입니다. 그림을 넘기는 부분까지 세로스크롤을 원한다면 Gallery를 상속받을 게 아니라 ListView를 상속받아서 사용해야합니다. Gallery는 가로스크롤 밖에 되지 않습니다.

동영상을 보면 이해가 가실 겁니다.
 


간단히 해결될 문제처럼 보이지만 그리 간단한 문제가 아닙니다. 그냥 getView에 이미지를 보여주는 게 아니라 스크롤뷰로 한번싸면 될 거 같지만, 그러면 스크롤뷰가 이벤트를 다 가져가 버려서 가로스크롤이 되지 않습니다. dispatchTouchEvent를 쓰면 둘 다 작동은 되지만 손가락이 일직선으로 좌우로 움직이는 게 아니기 때문에 의도한대로 스크롤 되는 게 아니라 정말 손가락 움직이는대로 움직이게 됩니다. 동영상처럼 세로스크롤만 하려고 했는데 오른쪽 왼쪽에 있는 그림들이 들어갔다 나왔다 거리죠.

onTouchEvent를 상속받아서 손가락이 가로로 움직이면 갤러리에서 처리하고 세로로 움직이면 스크롤뷰에서 처리하면 될 거 같지만, 인간의 손가락은 그렇게 정확하게 움직여주지 않습니다. 가로로 움직이려고 했는데,  첫 터치는 세로로 움직일 수도 있거든요...

한가지 방법은 갤러리에서 터치이벤트를 모두 낚아채서 첫터치 이후 일정거리(예제에서는 1/15인치)를 가로로 움직이면 가로 스크롤, 세로로 움직이면 세로스크롤로 처리하는 방법이 있습니다. 주의사항은 세로스크롤로 보낼 때는 첫 이벤트인 것처럼 위장하기 위해서 마우스이벤트를 DOWN이벤트로 수정해줘야합니다.

# 한장씩 넘어가는 갤러리 
# 어댑터안에서 메모리 관리하기
에서 중요한 소스는 설명했기 때문에 그냥 전체소스만 붙이겠습니다. 말로 하는 것보다 소스를 보시는 게 더 이해가 빠를 수 있으니까요.

OneFlingScrollGallery.java

package com.givenjazz.android;


import android.content.Context;

import android.hardware.SensorManager;

import android.util.AttributeSet;

import android.view.MotionEvent;

import android.view.ViewConfiguration;

import android.widget.Gallery;


public class OneFlingScrollGallery extends Gallery {

    private static final int NOTHING = 0;

    private static final int HORIZONTAL = 1;

    private static final int VERTICAL = 2;


    private float mSensitivity;

    

    private float mDownX;

    private float mDownY;

    private boolean mNeedToPosition;

    private boolean mNeedToJudge;

    private int mDirection;


    private float mDistanceX;

    private float mDeceleration;


    public OneFlingScrollGallery(Context context) {

        this(context, null);

    }


    public OneFlingScrollGallery(Context context, AttributeSet attrs) {

        super(context, attrs);

        float ppi = context.getResources().getDisplayMetrics().density * 160.0f;

        mSensitivity = ppi/15; // 민감도 1/15인치

        mDeceleration = SensorManager.GRAVITY_EARTH // g (m/s^2)

                * 39.37f // inch/meter

                * ppi // pixels per inch

                * ViewConfiguration.getScrollFriction();

    }


    @Override

    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {


        float toMoveDistance = getWidth() - Math.abs(mDistanceX);

        float maxVelocity = (float)Math.sqrt(toMoveDistance * mDeceleration * 2);

        float revisedVelocityX = 0;


        if (velocityX > 0) {

            revisedVelocityX = Math.min(velocityX, maxVelocity);

        } else {

            revisedVelocityX = Math.max(velocityX, -maxVelocity);

        }


        return super.onFling(e1, e2, revisedVelocityX, velocityY);

    }


    @Override

    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {


        if (mNeedToPosition) {

            mDistanceX = 0;

            mNeedToPosition = false;

            distanceX = 0;

        }

        mDistanceX += distanceX;

        return super.onScroll(e1, e2, distanceX, distanceY);

    }


    @Override

    public boolean onTouchEvent(MotionEvent e) {

        switch (mDirection) {

            case HORIZONTAL:

                return super.onTouchEvent(e);

            case VERTICAL:

                if (mNeedToJudge == true) {

                    mNeedToJudge = false;

                    //스크롤뷰에서 처음 받을 이벤트이니 다운이벤트로 위장

                    e.setAction(MotionEvent.ACTION_DOWN);

                }


                //스크롤뷰로 이벤트 전달

                getSelectedView().onTouchEvent(e);

                return true;

            case NOTHING:

                float deltaX = Math.abs(e.getX() - mDownX);

                float deltaY = Math.abs(e.getY() - mDownY);

                if (deltaX > deltaY + mSensitivity)

                    mDirection = HORIZONTAL;

                else if (deltaX + mSensitivity < deltaY)

                    mDirection = VERTICAL;


        }

        return true;

    }


    @Override

    public boolean onInterceptTouchEvent(MotionEvent e) {

        mDirection = NOTHING;

        mNeedToPosition = true;

        mNeedToJudge = true;

        mDownX = e.getX();

        mDownY = e.getY();

        return true;

    }


}



ImageAdapter.java (소스의 간결함을 위해 convertView 사용은 생략) 

package com.givenjazz.android;


import java.lang.ref.WeakReference;

import java.util.ArrayList;

import java.util.List;


import android.content.Context;

import android.util.Log;

import android.view.View;

import android.view.ViewGroup;

import android.widget.BaseAdapter;

import android.widget.Gallery;

import android.widget.ImageView;

import android.widget.ScrollView;


public class ImageScrollAdapter extends BaseAdapter {

    private List<Integer> mResources;

    private Context mContext;

    private List<WeakReference<View>> mRecycleList = new ArrayList<WeakReference<View>>();


    public ImageScrollAdapter(Context c, List<Integer> resources) {

mContext = c;

mResources = resources;

    }


    @Override

    public int getCount() {

return mResources.size();

    }


    @Override

    public Object getItem(int position) {

return mResources.get(position);

    }


    @Override

    public long getItemId(int position) {

return position;

    }


    public void recycle() {

RecycleUtils.recursiveRecycle(mRecycleList);

    }


    public void recycleHalf() {

int halfSize = mRecycleList.size() / 2;

List<WeakReference<View>> recycleHalfList = mRecycleList.subList(0,

halfSize);

RecycleUtils.recursiveRecycle(recycleHalfList);

for (int i = 0; i < halfSize; i++)

    mRecycleList.remove(0);

    }


    @Override

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

ScrollView scrollView = new ScrollView(mContext);

ImageView i = new ImageView(mContext);


try {

    i.setImageResource(mResources.get(position));

} catch (OutOfMemoryError e) {

    if (mRecycleList.size() <= parent.getChildCount()) {

Log.e(this + "", "size:" + mRecycleList.size());

throw e;

    }

    Log.w(this + "", e.toString());

    recycleHalf();

    System.gc();

    return getView(position, scrollView, parent);

}

i.setAdjustViewBounds(true);

i.setLayoutParams(new Gallery.LayoutParams(i.getDrawable()

.getIntrinsicWidth(), i.getDrawable().getIntrinsicHeight()));


scrollView.addView(i);


mRecycleList.add(new WeakReference<View>(scrollView));

return scrollView;

    }

}



라이센스는 언제나 그렇듯이 아파치2.0으로 공개합니다.

신고
아래는 첨부된 소스 실행 동영상입니다. 보면 한장씩 자연스럽게 넘어갑니다. 앱 위에 보면 아이폰의 페이지 컨트롤처럼 보여지는 부분도 있는데, 그거까지 설명하면 글이 길어지니 일단 생략하겠습니다.
 


안드로이드 기본 뷰는 한장씩 넘어가는 게 안되서 ViewFlipper나 ViewSwitcher로 구현을 하게 되는데 손으로 드래그해서 스크롤되는 효과를 줄 수가 없게 되죠. 여기서는 Gallery뷰를 상속받아 스크롤이 미끄러질때 미끄러지는 속도를 딱 한장만 넘어갈 정도의 속도로 낮춰서 만들었습니다.

학창시절 물리시간에 배운 기억을 더듬어봐서 속도를 공식으로 표현하면 속도의 제곱은 2 * 마찰계수 * 중력가속도 * 이동거리가 됩니다. 물리에 관한 글이 아니므로 어떻게 유도했는지 자세한 설명은 생략하겠습니다.


일단 상수인 마찰계수와 중력가속도부터 구해야하는데 정보가 없으니 안드로이드 프레임워크 소스를 열어서 분석해봐야합니다. 실제로 저런식으로 구현됐는지도 확인해봐야하고요. 설마했는데 실제로 소스를 열어보니 실제로 지구의 중력가속도에 실제 픽셀을 거리로 변환해서 구현했습니다. (이렇게 구현한 것도 사실 이해가 안가는데 토성, 목성 같은 곳의 중력가속도 정보도 있습니다;;;)

public OneFlingGallery(Context context, AttributeSet attrs) {

super(context, attrs);

float ppi = context.getResources().getDisplayMetrics().density * 160.0f;

mDeceleration = SensorManager.GRAVITY_EARTH // g (m/s^2)

* 39.37f // inch/meter

* ppi // pixels per inch

* ViewConfiguration.getScrollFriction();

    }


중력가속도는 SensorManager.GRAVITY_EARTH * 39.37f * ppi
마찰계수는 ViewConfiguration.getScrollFriction() 입니다.
소스를 열어보면 마찰계수가 0.015로 되어있습니다. 저 마찰계수는 빙판과 아이스하키 공(퍽)의 마찰계수 수준입니다. 스크롤을 한번하면 멈추지 않고 쭉쭉 미끄러지는 게 이해가 가는군요. (기껏 물리학적으로 자연스럽게 구현해서 저런식으로 부자연스럽게 낮은 마찰계수를 적용했는지는 이해가 안갑니다만)

참고로 진저나 프로요 이하에서는 마찰계수가 프레임워크에 상수로 고정되어 있으나 API 11(3.0 허니콤) 부터는 setFriction메소드로 마찰계수의 변경이 가능합니다. 그때부터는 속도가 아니라 마찰계수를 바꾸면 되니 더 자연스럽게 구현을 할 수 있을 겁니다.

이제 이동거리를 구해봅시다. 갤러리같은 어댑터뷰는 사실 스크롤이 되는 게 아니라 자식뷰들을 반대쪽으로 이동시키는 구조로 되어 있습니다. getScrollX()로 구하면 항상 0이 나오기 때문에 onScroll을 오버로드해서 자식뷰들을 이동시킨 거리를 합해서 구해야합니다.

    @Override

    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX,

    float distanceY) {

mDistanceX += distanceX;

return super.onScroll(e1, e2, distanceX, distanceY);

    }


    @Override

    public boolean onTouchEvent(MotionEvent event) {

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

    mDistanceX = 0;

}

return super.onTouchEvent(event);

    }

 
이렇게 하면 mDistanceX에 스크롤된 거리가 합산이 됩니다. onScroll을 오버로드 하지말고, onTouchEvent에서 이벤트거리만 합산해도 되지 않겠냐고 생각할지 모르지만 안됩니다. 마지막에 onScroll이벤트보다 fling이 먼저 발생하는데 그 때 1,2 픽셀정도 오차가 나게되고 한장씩 넘어가는 건 맞지만 딱 맞아떨어져서 멈추지 않기 때문에 묘하게 거슬립니다.

갤러리 크기에서 스크롤된 거리를 빼면 이동해야할 거리가 나옵니다.

    @Override

    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,

    float velocityY) {


float toMoveDistance = getWidth() - Math.abs(mDistanceX);

float maxVelocity = (float) Math.sqrt(toMoveDistance * mDeceleration

* 2);

float revisedVelocityX = 0;


if (velocityX > 0) {

    revisedVelocityX = Math.min(velocityX, maxVelocity);

} else {

    revisedVelocityX = Math.max(velocityX, -maxVelocity);

}


return super.onFling(e1, e2, revisedVelocityX, velocityY);

    } 


이렇게 구한 이동해야할 거리로 속도를 구합니다. 속도를 자연스럽게 구현할 때 속도를 낮추는 건 괜찮지만 빠르게 하는 건 이상하겠죠? 자연스럽게 하기위해 속도는 원래 속도와 이동해야할 속도 중 낮은 속도로 수정해줍니다. 이렇게 해서 가다가 멈춰도 알아서 적당한 위치로 붙기 때문에 속도를 낮춰도 괜찮습니다. 그리고 그렇게 구한 속도를 fling에 넣어주면 됩니다.

아래의 전체소스이용해서 갤러리를 만드시면 되고 사용법은 기존의 Gallery 뷰와 동일합니다.

전체소스 보기


첨부된 파일은 동영상처럼 아이폰의 페이지 컨트롤도 포함시켰고, 바로 전에 썼던 메모리관리 기법도 적용한 예제소스입니다. (이 부분도 설명할 부분이 있는데, 너무 방대해져서 그냥 소스만 첨부합니다. 나중에 기회되면 어댑터 내에서 메모리 관리하는 것도 설명할게요)

화면에 꽉차는 이미지 등장할 때부터 부터는 이미지가 좀 늦게 등장하는 현상이 있는데 그림파일 I/O 때문에 발생합니다. 다음에 I/O시간 때문에 UI가 멈추는 시간을 제거하는 기법을 설명하거나 갤러리 안에서 자연스럽게 세로스크롤 되는 기법을 설명하겠습니다.(첨부된 소스 안에 OneFlingScrollGallery는 세로스크롤 하는 갤러리 소스입니다)


소스가 첨부된 GitHub 링크
 
신고


 

티스토리 툴바