Notice
Recent Posts
Recent Comments
Link
관리 메뉴

설.현.아빠

[휴님강좌] [번역] 안드로이드에서 멀티터치 활용하기 (Making Sense of Multitouch) 본문

안드로이드/Multi Touch

[휴님강좌] [번역] 안드로이드에서 멀티터치 활용하기 (Making Sense of Multitouch)

설.현.아빠 2011. 2. 11. 11:00

Making Sense of Multitouch

어플리케이션을 개발하는데 있어서 사용자 인터페이스 부분은 누구나 귀찮아 하지만, 결코 피해갈 수 없는, 그리고 막상 하려고 하면 할 일이 많은 부분이라고 생각합니다. 더군다나 요즘에는 기존의 전통적인 키보드/마우스 대신, 이른바 멀티 터치를 이용한 조작이 하나의 대세가 된 만큼 GUI 어플리케이션을 구현하는 일은 더더욱 골치아픈 일이 되어버렸습니다. 안드로이드에서 터치 이벤트, 특히나 멀티터치 이벤트를 어떻게 처리할 수 있는가에 관한 좋은 내용이 안드로이드 개발자 블로그에 올라와서 번역해 보았습니다.

[이 포스트는 Adam Powell 에 의해 작성되었습니다. 그는 우리의 Touch-Feely (주> 솔직한 이라는 뜻이지만, Multitouch 에 관한 포스트인 만큼 하나의 말장난으로 생각되네요.)한 안드로이드 개발자 중에 한명입니다. — Tim Bray]

 '멀티터치' 라는 단어는 널리 사용되고 있습니다만, 사람들은 이 단어를 조금 혼재되어 사용하고 있습니다. 어떤 사람들은 하드웨어의 기능에 대해서 이야기 하는 반면에 다른 누군가는 소프트웨어 상에서 제스처 인식 기능에 대해서 이야기 합니다. 하여간에 오늘은 우리가 어떻게 하나 이상의 손가락을 이용해 멋지게 작동하는 어플리케이션을 만들 수 있는지 살펴보고자 합니다.


 이 포스트는 많은 코드를 포함하고 있습니다. 예제를 통해 사용자의 터치 이벤트에 반응해서 사용자가 원하는 형태로 조작할 수 있는 Custom View 를 만드는 방법에 관해 이야기 하고자 합니다. 앞으로 이어질 예제들을 이해하기 위해서는, 여러분은  Activity 를 설정하는 방법 그리고 Android UI system 에 관해 어느정도 익숙하셔야 될 것입니다. 전체 프로젝트 소스는 포스트 마지막에 링크해 두었습니다.

 우선 새로운 View 클래스를 하나 만들어 봅시다. 이 View 는 주어진 특정 위치에어플리케이션의 아이콘 이미지를 그립니다.

public class TouchExampleView extends View {
    private Drawable mIcon;
    private float mPosX;
    private float mPosY;
    
    private float mLastTouchX;
    private float mLastTouchY;
    
    public TouchExampleView(Context context) {
        this(context, null, 0);
    }
    
    public TouchExampleView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    
    public TouchExampleView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        mIcon = context.getResources().getDrawable(R.drawable.icon);
        mIcon.setBounds(0, 0, mIcon.getIntrinsicWidth(), mIcon.getIntrinsicHeight());
    }

    @Override
    public void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        
        canvas.save();
        canvas.translate(mPosX, mPosY);
        mIcon.draw(canvas);
        canvas.restore();
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        // More to come here later...
        return true;
    }

}

 

MotionEvent


 안드로이드에서 터치 이벤트 데이타를 접근하기 위한 첫번째는 MotionEvent 클래스를 사용하는 것 입니다. View 의 onTouchEvent 와 onInterceptTouchEvent 를 통해 전달되는 MotionEvent 객체안에는 화면상에 존재하는 '포인터' 들의 정보를 비롯하여, 현재 터치된 위치에 관한 정보등이 들어 있습니다. MotionEvent 를 이용하면, 개발자들은 개별 '포인터' 들의 X/Y 좌표 값 뿐만 아니라, 크기와 어느정도의 압력으로 눌렸는지에 관한 정보를 알 수 있습니다. MotionEvent.getAction()  메서드는 어떤 형태의 이벤트가 발생했는지에 관해 알려줍니다. 

 그럼 우선, 가장 일반적인 드래그 기능을 구현해 봅시다. View 의 onTouchEvent 를 아래와 같이 구현해서 드래그 기능을 구현할 수 있습니다.

@Override
public boolean onTouchEvent(MotionEvent ev) {
    final int action = ev.getAction();
    switch (action) {
    case MotionEvent.ACTION_DOWN: {
        final float x = ev.getX();
        final float y = ev.getY();
        
        // Remember where we started
        mLastTouchX = x;
        mLastTouchY = y;
        break;
    }
        
    case MotionEvent.ACTION_MOVE: {
        final float x = ev.getX();
        final float y = ev.getY();
        
        // Calculate the distance moved
        final float dx = x - mLastTouchX;
        final float dy = y - mLastTouchY;
        
        // Move the object
        mPosX += dx;
        mPosY += dy;
        
        // Remember this touch position for the next move event
        mLastTouchX = x;
        mLastTouchY = y;
        
        // Invalidate to request a redraw
        invalidate();
        break;
    }
    }
    
    return true;

}


 위의 코드는 버그가 있습니다. 하나 이상의 포인터를 지원하는 디바이스 (주> 멀티 터치를 지원해서 두 손가락으로 화면을 동시에 누르면 두 개의 포인터가 생성된다고 할 수 있습니다.) 에서는 올바르게 작동하지 않습니다. 이미지를 화면 여기저기로  드래깅 하는 동안, 두 번째 손가락을 터치 스크린에 올리고, 첫 번째 손가락을 때어보세요. 이런! 이미지의 위치가 점프 해버립니다. 무슨 일이 일어난 걸까요? 우리는 이미지가 움직일 거리를, 기본 포인터의 저장된 마지막 위치를 기반해서 계산합니다. 첫 번째 손가락이 때어지는 순간, 두 번째 손거락이 기본 포인터가 되고, 따라서 기존에 저장해둔 값과 새롭게 터치된 값은 크게 차이가 납니다. 때문에 이미지의 위치가 점프하게 됩니다.

 만일 우리가 하나의 포인터 만을 지원하고자 한다면,  MotionEvent.getX() 와 MotionEvent.getY() 만 사용하면 됩니다. MotionEvent 는 안드로이드 2.0 이후 부터, 하나 이상의 포인터가 있는 상황을 지원하기 위해서 확장되었고, 멀티터치 이벤트를 지원하는 새로운 액션이 추가되었습니다. MotionEvent.getPointerCount() 는 현재 화면상에 존재하는 포인터 갯수를 반환 합니다. getX 와 getY 는 어떤 포인터의 값을 가져올지, 인덱스 값을 인자로 넘겨 받습니다.

Index vs. ID

 큰 관점에서 보자면,  특정 시점에 관한 터치 데이터는 바로 사용되지 않습니다. 왜냐하면 일정 시간에 걸친 여러 MotionEvent 들이 모여서 하나의 제스처로 의미를 갖기 때문입니다. 따라서 인덱스 값만으로는 제스처 이벤트를 처리하기에 충분하지 않습니다. 인덱스 값은 현재 발생한 MotionEvent 내에서 특정 포인터의 위치를 나타낼 뿐입니다. 그러나 걱정하지 않으셔도 됩니다. 여러분은 각각의 포인터 마다 유일한 ID 값을 사용할 수 있습니다. MotionEvent.getPointerId(index) 를 이용해서 ID 값을 알아 낼 수 있으며, MotionEvent.findPointerIndex(id) 함수를 이용해서 특정 ID 에 해당하는 포인터의 인덱스 값을 알아낼 수 있습니다. 

Feeling Better? - 좀 나아 지셨나요?

포인터의 ID 값을 활용하여 위의 예제를 수정해 봅시다.

private static final int INVALID_POINTER_ID = -1;

// The ‘active pointer’ is the one currently moving our object.
private int mActivePointerId = INVALID_POINTER_ID;

// Existing code ...

@Override
public boolean onTouchEvent(MotionEvent ev) {
    final int action = ev.getAction();
    switch (action & MotionEvent.ACTION_MASK) {
    case MotionEvent.ACTION_DOWN: {
        final float x = ev.getX();
        final float y = ev.getY();
        
        mLastTouchX = x;
        mLastTouchY = y;

        // Save the ID of this pointer
        mActivePointerId = ev.getPointerId(0);
        break;
    }
        
    case MotionEvent.ACTION_MOVE: {
        // Find the index of the active pointer and fetch its position
        final int pointerIndex = ev.findPointerIndex(mActivePointerId);
        final float x = ev.getX(pointerIndex);
        final float y = ev.getY(pointerIndex);
        
        final float dx = x - mLastTouchX;
        final float dy = y - mLastTouchY;
        
        mPosX += dx;
        mPosY += dy;
        
        mLastTouchX = x;
        mLastTouchY = y;
        
        invalidate();
        break;
    }
        
    case MotionEvent.ACTION_UP: {
        mActivePointerId = INVALID_POINTER_ID;
        break;
    }
        
    case MotionEvent.ACTION_CANCEL: {
        mActivePointerId = INVALID_POINTER_ID;
        break;
    }
    
    case MotionEvent.ACTION_POINTER_UP: {
        // Extract the index of the pointer that left the touch sensor
        final int pointerIndex = (action & MotionEvent.ACTION_POINTER_INDEX_MASK) 
                >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
        final int pointerId = ev.getPointerId(pointerIndex);
        if (pointerId == mActivePointerId) {
            // This was our active pointer going up. Choose a new
            // active pointer and adjust accordingly.
            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
            mLastTouchX = ev.getX(newPointerIndex);
            mLastTouchY = ev.getY(newPointerIndex);
            mActivePointerId = ev.getPointerId(newPointerIndex);
        }
        break;
    }
    }
    
    return true;


 몇 가지 새로운 요소들이 있습니다. 위 예제에서는 'action' 변수를 그대로 사용하는 대신, 'action & MotionEvent.ACTION_MASK' 를 사용하였습니다. 또 새로운 상수 값 MotionEvent.ACTION_POINTER_UP 을 사용하였습니다. 이 이벤트는 첫번째 포인터가 아닌 다른 포인터가 눌리거나 때어지는 경우 발생합니다. 따라서 만일 화면 상에 이미 하나의 포인터가 존재하는 상황에서, 새로운 포인터가 눌러지게 되는 순간에 ACTION_DOWN 대신에 ACTION_POINTER_DOWN 이벤트가 발생합니다. 만일 포인터가 때어지는 순간 화면 상에 또 다른 포인터가 존재한다면, ACTION_UP 대신 ACTION_POINTER_UP 이벤트가 발생하게 됩니다.

 ACTION_POINTER_DOWN 과 ACTION_POINTER_UP 이벤트는 액션 값에 추가적인 정보가 인코딩되어 있습니다. 액션 값과 MotionEvent.ACTION_MASK 을 '&' 연산을 하게 되면, 눌리거나 때어진 포인터의 인덱스 값을 알 수 있습니다. 위의 예제에서는, ACTION_POINTER_UP 이벤트가 발생하는 경우, 액션 값에서 인덱스 값을 추출한 후, 현재 우리가 추적하고 있는 포인터값이 아닌 것을 확인 합니다. 만일 우리가 추적하고 있는 포인터가 화면 상에서 사라졌다면, 우리는 다른 포인터를 하나 선택하고, 해당 포인터의 현재 위치를 저장합니다. 새롭게 저장된 위치 값이 ACTION_MOVE 이벤트 시에 화면상의 오브젝트의 위치를 움직일 때 사용되기 때문에, 항상 올바른 값을 이용해서 오브젝트가 움직일 거리를 계산하게 됩니다.

 이 것들 여러분이 다양한 종류의 제스처를 처리할 때 알아야할 모든 것입니다만, 이렇게 로우레벨의 함수를 이용해서 제스처를 다루는 것은 굉장히 귀찮은 일 입니다. GestureDetectors 를 사용해 봅시다.

GestureDetectors

 어플리케이션 마다 서로 굉장히 다른 요구사항이 갖고 있기 때문에, 안드로이드는 개발자가 명시적으로 요청하지 않는 이상, 화면 터치시 발생하는 데이터를 가공해서 보다 상위의 제스처 이벤트로 생성하는 작업을 수행하는데 시간을 낭비하지 않습니다. GestureDetector 는 작은 필터 오브젝트입니다. MotionEvent 를 우선 처리한 후, 특별한 상위의 제스처 이벤트가 있는 경우, 설정된 리스너에게 해당 이벤트를 전달해 줍니다. 안드로이드 프레임워크는 두 종류의 GestureDetector 를 제공해 줍니다. 하지만 원하는 경우 이를 참조해서 여러분만의 GestureDetector 를 자유롭게 구현 하실 수 있습니다. GestureDetector 는 하나의 패턴이지 딱 정해진 솔루션이 아닙니다. GestureDetector 들은 별을 그리는 등의 복잡한 제스처를 처리하는 것 뿐만아니라, 플링(주>책장을 넘기듯이 손가락으로 화면을 휙~ 하고 드래그 하는 것) 이나 더블탭과 같은 간단한 제스처를 처리하는데도 유용하게 사용될 수 있습니다. 

 android.view.GestureDetector 는 안드로이드에서 사용되는 스크롤링, 플링, 롱프레스와 같은 몇 가지 일반적인 싱글 터치 기반의 제스처 이벤트들을 알려 줍니다.안드로이드 2.2 (Froyo) 에서 android.view.ScaleGestureDetector 가 추가되었는데, 가장 일반적인 핀치 줌 (주> 아이폰에서 사진을 확대 축소하는 바로 그 방식) 제스처 이벤트를 처리해 줍니다.

 GestureDetector 는 onTouchEvent(MotionEvent) 메서드를 제공하며, 자신이 처리할 적절한 제스처 이벤트가 있는 경우 True 값을 반환합니다. GestureDetector 와 ScaleGestureDetector 는 특정 View 가 여러 가지 제스처 이벤트를 처리해야 되는 경우에 함께 사용될 수 있습니다. 

 감지된 제스처 이벤트를 리포트 하기 위해서, GestureDetector 를 생성할 때 리스너를 등록하게 됩니다. ScaleGestureDetector 는 OnScaleGestureListener 를 사용합니다. ScaleGestureDetector.SimpleOnScaleGestureListener 는 여러분이 리포트 되는 이벤트 중 몇몇 이벤트에만 관심이 있는 경우 간편하게 확장해서 사용할 수 있는 헬퍼 클래스입니다.

 이미 이전 예제에서 드래깅 기능을 지원하고 있으니까 이번에는 확대축소 기능을 추가해 봅시다. 새로운 예제 코드는 아래와 같습니다.

private ScaleGestureDetector mScaleDetector;
private float mScaleFactor = 1.f;

// Existing code ...

public TouchExampleView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    mIcon = context.getResources().getDrawable(R.drawable.icon);
    mIcon.setBounds(0, 0, mIcon.getIntrinsicWidth(), mIcon.getIntrinsicHeight());
    
    // Create our ScaleGestureDetector
    mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());
}

@Override
public boolean onTouchEvent(MotionEvent ev) {
    // Let the ScaleGestureDetector inspect all events.
    mScaleDetector.onTouchEvent(ev);
    
    final int action = ev.getAction();
    switch (action & MotionEvent.ACTION_MASK) {
    case MotionEvent.ACTION_DOWN: {
        final float x = ev.getX();
        final float y = ev.getY();
        
        mLastTouchX = x;
        mLastTouchY = y;
        mActivePointerId = ev.getPointerId(0);
        break;
    }
        
    case MotionEvent.ACTION_MOVE: {
        final int pointerIndex = ev.findPointerIndex(mActivePointerId);
        final float x = ev.getX(pointerIndex);
        final float y = ev.getY(pointerIndex);

        // Only move if the ScaleGestureDetector isn't processing a gesture.
        if (!mScaleDetector.isInProgress()) {
            final float dx = x - mLastTouchX;
            final float dy = y - mLastTouchY;

            mPosX += dx;
            mPosY += dy;

            invalidate();
        }

        mLastTouchX = x;
        mLastTouchY = y;

        break;
    }
        
    case MotionEvent.ACTION_UP: {
        mActivePointerId = INVALID_POINTER_ID;
        break;
    }
        
    case MotionEvent.ACTION_CANCEL: {
        mActivePointerId = INVALID_POINTER_ID;
        break;
    }
    
    case MotionEvent.ACTION_POINTER_UP: {
        final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) 
                >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
        final int pointerId = ev.getPointerId(pointerIndex);
        if (pointerId == mActivePointerId) {
            // This was our active pointer going up. Choose a new
            // active pointer and adjust accordingly.
            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
            mLastTouchX = ev.getX(newPointerIndex);
            mLastTouchY = ev.getY(newPointerIndex);
            mActivePointerId = ev.getPointerId(newPointerIndex);
        }
        break;
    }
    }
    
    return true;
}

@Override
public void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    
    canvas.save();
    canvas.translate(mPosX, mPosY);
    canvas.scale(mScaleFactor, mScaleFactor);
    mIcon.draw(canvas);
    canvas.restore();
}

private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
    @Override
    public boolean onScale(ScaleGestureDetector detector) {
        mScaleFactor *= detector.getScaleFactor();
        
        // Don't let the object get too small or too large.
        mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));

        invalidate();
        return true;
    }
}
 
 이 예제는 단지 ScaleGestureDetector 가 제공하는 기능을 수박 겉 핧기 식으로 살펴보았을 뿐입니다. Listener 메서드는 GestureDetector 객체 스스로의  참조 값을 인자로 넘겨 받게 되는데, 해당 객체를 통해 현재 처리된 제스처에 관한 추가적인 정보를 알 수 있습니다. 보다 자세한 내용에 관해서는  ScaleGestureDetector API 문서를 한 번 살펴 보시면 좋습니다.

 이제 우리의 예제 어플리케이션 사용자는 한 손가락으로 그림을 드래그 하고, 두 손가락으로 그림을 확대하거나 축소할 수 있습니다. 그리고, 두 손가락이 번갈아 화면에서 떨어졌다가 붙어다가 하는 상황에서도 현재 활성화 되어 있는 포인터를 올바르게 처리해 줍니다. 여러분은 이 최종 예제 프로젝트를 다음의 주소에서 다운로드 받으실 수 있습니다. http://code.google.com/p/android-touchexample/ 이 프로젝트는 안드로이드 2.2 SDK (API Level 8) 과 2.2(Froyo) 가 설치된 디바이스를 필요로 합니다.

From Example to Application - 예제에서 실재 어플리케이션으로

 실제 어플리케이션에서는 줌인 줌아웃이 어떤식으로 적용되어야 하는지 구체적으로 설정할 필요가 있습니다. 줌인의 경우, 사용자들은 컨텐츠가 두 손가락의 가운데 지점을 기준으로 확대 될 것을 기대합니다. 이 값은 ScaleGestureDetector 클래스의 getFocusX() 와 getFocusY() 를 통해 알 수 있습니다. 보다 자세한 설정들은 여러분의 어플리케이션이 어떻게 컨텐츠를 그리고 표현하는지에 달려있습니다.

 서로 다른 터치스크린 하드웨어는 서로 다른 기능을 갖을 수 있습니다. 어떤 패널은 오직 싱글 터치만을 지원하고, 어떤 것들은 두 개의 터치를 지원하지만, 복잡한 제스처의 경우 위치 값이 불안정할 수 있습니다. 또 다른 기기들은 두 개 이상의 터치를 정확하게 지원해 줄 수도 있습니다. 여러분은 어플리케이션이 작동하는 시점에 PackageManager.hasSystemFeature() 함수를 이용해서, 현재 디바이스가 어떤 종류의 터치스크린을 갖고 있는지 확인 할 수 있습니다.

 여러분이 사용자 인터페이스를 디자인할 때, 사용자들은 매우 다양한 방법으로 모바일 디바이스를 사용하고, 모든 안드로이드 디바이스들이 똑같이 만들어진 것은 아니라는 사실을 염두에 두어야 합니다. 어떤 어플리케이션은 한 손으로 사용되며, 멀티 터치 제스처가 어색할 수도 있습니다. 어떤 사용자들은 방향키나 트랙볼을 사용하는 것을 더 좋아할 수도 있습니다. 잘 설계된 제스처는 복잡한 기능을 사용자가 간단하게 손가락으로 사용할 수 있도록 해주지만, 동시에 제스처로 접근 가능한 기능을 다른 방식으로도 접근 가능할 수 있도록 설계하는 것을 고려해 보아야 합니다.

'안드로이드 > Multi Touch' 카테고리의 다른 글

멀티 터치 지원 여부 확인.  (0) 2011.02.11
Multi Touch  (0) 2011.02.11
Comments