The Android Developer Challenge is back! Submit your idea before December 2.

대화형 뷰 만들기

UI 그리기는 맞춤 뷰 만들기 과정의 일부일 뿐입니다. 또한 개발자가 모방하고 있는 실제 행동과 매우 유사한 방식으로 뷰가 사용자 입력에 응답하게 해야 합니다. 개체는 항상 실제 개체와 동일한 행동 방식으로 작동해야 합니다. 예를 들어 이미지가 순식간에 사라졌다가 다른 곳에 다시 나타나서는 안 됩니다. 실제 개체는 그렇지 않기 때문입니다. 대신 이미지는 한 곳에서 다른 곳으로 이동해야 합니다.

또한 사용자는 인터페이스에서 미묘한 행동이나 느낌을 감지하고 실제 세계를 모방한 미묘함에 가장 잘 반응합니다. 예를 들어 사용자가 UI 개체를 빠르게 움직일 때 이 동작을 지연시키는 초기의 마찰을 감지한 후 동작 종료 시 이러한 움직임을 유지하는 운동량을 감지해야 합니다.

이 과정에서는 Android 프레임워크의 기능을 사용하여 이러한 실제 동작을 맞춤 뷰에 추가하는 방법을 보여줍니다.

이 과정 외에도 입력 이벤트속성 애니메이션에서 이와 관련된 추가 정보를 얻으실 수 있습니다.

입력 동작 처리

다른 많은 UI 프레임워크와 마찬가지로 Android에서는 입력 이벤트 모델을 지원합니다. 사용자 작업은 콜백을 트리거하는 이벤트로 전환되며, 콜백을 재정의하여 애플리케이션이 사용자에게 응답하는 방식을 맞춤설정할 수 있습니다. Android 시스템에서 가장 일반적인 입력 이벤트는 onTouchEvent(android.view.MotionEvent)를 트리거하는 터치입니다. 다음과 같이 이 메서드를 재정의하여 이벤트를 처리하세요.

Kotlin

    override fun onTouchEvent(event: MotionEvent): Boolean {
        return super.onTouchEvent(event)
    }
    

자바

    @Override
       public boolean onTouchEvent(MotionEvent event) {
        return super.onTouchEvent(event);
       }
    

터치 이벤트 자체는 그다지 유용하지 않습니다. 최신 터치 UI에서는 탭하기, 당기기, 밀기, 가로젓기, 확대/축소와 같은 동작 측면에서 상호작용을 정의합니다. 원시 터치 이벤트를 동작으로 변환하기 위해 Android에서는 GestureDetector를 제공합니다.

GestureDetector.OnGestureListener를 구현하는 클래스의 인스턴스로 전달하여 GestureDetector를 생성하세요. 몇 가지 동작만 처리하려면 GestureDetector.OnGestureListener 인터페이스를 구현하는 대신 GestureDetector.SimpleOnGestureListener를 확장하면 됩니다. 예를 들어 이 코드에서는 GestureDetector.SimpleOnGestureListener를 확장하고 onDown(MotionEvent)를 재정의하는 클래스를 만듭니다.

Kotlin

    private val myListener =  object : GestureDetector.SimpleOnGestureListener() {
        override fun onDown(e: MotionEvent): Boolean {
            return true
        }
    }

    private val detector: GestureDetector = GestureDetector(context, myListener)
    

자바

    class MyListener extends GestureDetector.SimpleOnGestureListener {
       @Override
       public boolean onDown(MotionEvent e) {
           return true;
       }
    }
    detector = new GestureDetector(PieChart.this.getContext(), new MyListener());
    

GestureDetector.SimpleOnGestureListener를 사용하든 사용하지 않든 true를 반환하는 onDown() 메서드를 항상 구현해야 합니다. 이 단계가 필요한 이유는 모든 동작이 onDown() 메시지로 시작하기 때문입니다. GestureDetector.SimpleOnGestureListener와 마찬가지로 onDown()에서 false를 반환하면 시스템에서는 개발자가 나머지 동작을 무시하려 한다고 가정하므로 GestureDetector.OnGestureListener의 다른 메서드는 호출되지 않습니다. onDown()에서 false를 반환해야 하는 유일한 경우는 전체 동작을 진정으로 무시하기를 원할 때입니다. GestureDetector.OnGestureListener를 구현하고 GestureDetector의 인스턴스를 만들었다면 onTouchEvent()에서 수신하는 터치 이벤트를 GestureDetector를 사용하여 해석할 수 있습니다.

Kotlin

    override fun onTouchEvent(event: MotionEvent): Boolean {
        return detector.onTouchEvent(event).let { result ->
            if (!result) {
                if (event.action == MotionEvent.ACTION_UP) {
                    stopScrolling()
                    true
                } else false
            } else true
        }
    }
    

자바

    @Override
    public boolean onTouchEvent(MotionEvent event) {
       boolean result = detector.onTouchEvent(event);
       if (!result) {
           if (event.getAction() == MotionEvent.ACTION_UP) {
               stopScrolling();
               result = true;
           }
       }
       return result;
    }
    

onTouchEvent()에 이 메서드가 동작의 일부로 인식하지 않는 터치 이벤트를 전달하면 이 메서드에서는 false를 반환합니다. 그러면 개발자는 자신의 맞춤 동작 감지 코드를 실행할 수 있습니다.

물리적으로 그럴듯한 움직임 만들기

동작은 터치스크린 기기를 제어할 수 있는 강력한 방법이지만 물리적으로 그럴듯한 결과를 산출하지 못하면 직관을 거스르고 기억하기 어려울 수 있습니다. 이와 관련된 좋은 예는 사용자가 화면을 가로질러 손가락을 신속하게 이동했다가 화면에서 손을 떼는 가로젓기 동작입니다. 이 동작은 마치 사용자가 플라이휠을 밟아 회전하게 하는 것처럼 가로젓기 방향으로 빠르게 이동한 다음 속도를 줄이는 방법으로 UI가 응답하는 경우 타당합니다.

그러나 플라이휠의 느낌을 시뮬레이션하는 것은 간단한 일이 아닙니다. 플라이휠 모델이 올바르게 작동하려면 많은 물리학 및 수학 지식이 동원되어야 합니다. 다행히도 Android에서는 이 동작과 기타 동작을 시뮬레이션할 수 있도록 도우미 클래스를 제공합니다. Scroller 클래스는 플라이휠 스타일 가로젓기 동작을 처리하는 데 기초가 됩니다.

가로젓기를 시작하려면 가로젓기의 시작 속도와 최소 및 최대 x, y 값으로 fling()을 호출하세요. 속도 값의 경우 개발자를 대신해 GestureDetector에서 계산한 값을 사용할 수 있습니다.

Kotlin

    fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
        scroller.fling(
                currentX,
                currentY,
                (velocityX / SCALE).toInt(),
                (velocityY / SCALE).toInt(),
                minX,
                minY,
                maxX,
                maxY
        )
        postInvalidate()
        return true
    }
    

자바

    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
       scroller.fling(currentX, currentY, velocityX / SCALE, velocityY / SCALE, minX, minY, maxX, maxY);
       postInvalidate();
        return true;
    }
    

참고: GestureDetector에서 계산한 속도가 물리적으로 정확하다 해도 많은 개발자는 이 값을 사용하면 가로젓기 애니메이션이 너무 빨리진다는 느낌을 받습니다. x 및 y 속도를 4~8로 나누는 것이 일반적입니다.

fling()을 호출하면 가로젓기 동작의 물리 모델이 설정됩니다. 나중에 정기적으로 Scroller.computeScrollOffset()을 호출하여 Scroller를 업데이트해야 합니다. computeScrollOffset()에서는 현재 시간을 읽고 물리 모델을 사용해 이 시각의 x 및 y 위치를 계산하여 Scroller 개체의 내부 상태를 업데이트합니다. getCurrX()getCurrY()를 호출하여 이 값을 가져오세요.

대부분의 뷰에서는 Scroller 개체의 x 및 y 위치를 scrollTo()에 직접 전달합니다. PieChart 예시는 약간 다릅니다. 즉 현재 스크롤 y 위치를 사용하여 차트의 회전 각도를 설정합니다.

Kotlin

    scroller.apply {
        if (!isFinished) {
            computeScrollOffset()
            setPieRotation(currY)
        }
    }
    

자바

    if (!scroller.isFinished()) {
        scroller.computeScrollOffset();
        setPieRotation(scroller.getCurrY());
    }
    

Scroller 클래스에서는 스크롤 위치를 계산하기는 하지만 이 위치를 뷰에 자동으로 적용하지는 않습니다. 스크롤 애니메이션을 부드럽게 표현할 수 있을 만큼 자주 새 좌표를 가져와 적용하는 것은 개발자의 책임입니다. 이를 위해 다음 두 가지 방법을 사용할 수 있습니다.

  • 다시 그리기를 강제하기 위해 fling()을 호출한 후 postInvalidate()를 호출합니다. 이 기술을 사용하려면 onDraw()에서 스크롤 오프셋을 계산하고 이 스크롤 오프셋이 변경될 때마다 postInvalidate()를 호출해야 합니다.
  • 가로젓기 중에 애니메이션이 적용되도록 ValueAnimator를 설정하고 addUpdateListener()를 호출하여 애니메이션 업데이트를 처리할 리스너를 추가합니다.

PieChart 예시에서는 두 번째 방법을 사용합니다. 이 기술은 설정이 약간 더 복잡하지만 애니메이션 시스템과 더 긴밀하게 연동되므로 불필요할 가능성이 있는 뷰 무효화 기능이 굳이 없어도 됩니다. 단점은 ValueAnimator가 API 레벨 11 이전에는 제공되지 않았기 때문에 3.0 미만의 Android 버전을 실행하는 기기에서는 이 기술을 사용할 수 없다는 것입니다.

참고: 더 낮은 API 레벨을 대상으로 하는 애플리케이션에서 ValueAnimator를 사용할 수 있습니다. 런타임 시 현재 API 레벨을 확인하고 현재 레벨이 11 미만인 경우 뷰 애니메이션 시스템 호출을 생략하기만 하면 됩니다.

Kotlin

    private val scroller = Scroller(context, null, true)
    private val scrollAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
        addUpdateListener {
            if (scroller.isFinished) {
                scroller.computeScrollOffset()
                setPieRotation(scroller.currY)
            } else {
                cancel()
                onScrollFinished()
            }
        }
    }
    

자바

    scroller = new Scroller(getContext(), null, true);
    scrollAnimator = ValueAnimator.ofFloat(0,1);
    scrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            if (!scroller.isFinished()) {
                scroller.computeScrollOffset();
                setPieRotation(scroller.getCurrY());
            } else {
                scrollAnimator.cancel();
                onScrollFinished();
            }
        }
    });
    

원활한 전환

사용자는 최신 UI가 상태 간에 부드럽게 전환되기를 기대합니다. UI 요소는 갑자기 나타났다가 갑자기 사라지는 대신 서서히 나타나고 서서히 사라집니다. 동작은 갑자기 시작하고 멈추는 대신 부드럽게 시작하고 끝납니다. Android 3.0에 도입된 Android 속성 애니메이션 프레임워크 덕분에 원활하게 전환할 수 있습니다.

애니메이션 시스템을 사용하려면 뷰 모양에 영향을 주는 속성이 변경될 때마다 속성을 직접 변경해서는 안 됩니다. 대신에 ValueAnimator를 사용해 변경하세요. 다음 예시의 경우 PieChart에서 현재 선택된 파이 조각을 수정하면 선택한 조각의 중앙에 선택 포인터가 오도록 전체 차트가 회전합니다. ValueAnimator에서는 새 회전 값을 즉시 설정하지 않고 수백 밀리초 동안 회전을 변경합니다.

Kotlin

    autoCenterAnimator = ObjectAnimator.ofInt(this, "PieRotation", 0).apply {
        setIntValues(targetAngle)
        duration = AUTOCENTER_ANIM_DURATION
        start()
    }
    

자바

    autoCenterAnimator = ObjectAnimator.ofInt(PieChart.this, "PieRotation", 0);
    autoCenterAnimator.setIntValues(targetAngle);
    autoCenterAnimator.setDuration(AUTOCENTER_ANIM_DURATION);
    autoCenterAnimator.start();
    

변경하려는 값이 기본 View 속성 중 하나인 경우 뷰에는 여러 속성의 동시 애니메이션에 최적화된 내장 ViewPropertyAnimator가 있으므로 애니메이션 작업을 훨씬 더 쉽게 할 수 있습니다. 예를 들면 다음과 같습니다.

Kotlin

    animate()
        .rotation(targetAngle)
        .duration = ANIM_DURATION
        .start()
    

자바

    animate().rotation(targetAngle).setDuration(ANIM_DURATION).start();