Path 2.0을 통하여 재조명하는 안드로이드 애니메이션 :: 안드로이드 설치 및 개발[SSISO Community]
 
SSISO 카페 SSISO Source SSISO 구직 SSISO 쇼핑몰 SSISO 맛집
추천검색어 : JUnit   Log4j   ajax   spring   struts   struts-config.xml   Synchronized   책정보   Ajax 마스터하기   우측부분

안드로이드 설치 및 개발
[1]
등록일:2018-10-16 13:35:21 (0%)
작성자:
제목:Path 2.0을 통하여 재조명하는 안드로이드 애니메이션

1. 서론

QuadCurveMenu로 최근에 집중적인 스포트라이트를 받은 Path 2.0앱이 iOS에 이어 Android용앱도 곧바로 선보였습니다. 비슷한 시기에 iOS용은 소스가 공개 되면서 유사한 기능을 구현하고자 하는 개발자들에게 큰 도움이 되었지만 Android용 소스는 공개가 되지 않아서 상상력을 발휘하여 어떻게 구현되었으리라 막연하게 추정해야만 했습니다.
본 리뷰에서는 Android가 지원하는 Animation이 어떤 것들이 있는지 간략하게 살펴보고 Path 2.0의 QuardCurveMenu를 구현하기 위하여 어떤 Animation을 사용하는지 알아 보려고 합니다.
리뷰 마지막에는 Demo 앱을 통하여 Tweened animation의 실제 동작을 확인하실 수 있도록 다운로드 링크가 있습니다만, 샘플 소스애플리케이션도 링크를 클릭하시면 마지막까지 내려가지 않으셔도 곧바로 다운로드 받으실 수 있습니다.
Android 개발자 분들은 참고하시어 Path 2.0 못지 않은 UI를 개발하시길 기대합니다.

2. Android Animation 종류

Android가 지원하는 Animation은 크게 Tweened Animation과 Frame-by-frame Animation으로 나뉘어 집니다.

2-1. Tweened animation

Tweened animation은 Animation 클래스와 그 외 파생 클래스들을 사용하여 구현하며, Flash Animation을 구현할 때와 비슷하게 Key Frame 개념으로 각 Time line 지점에 Alpha, Rotate, Scale, Position 등을 설정하여 Animation을 구현합니다.

2-2. Frame-by-frame animation

AnimationDrawable 클래스를 사용하며, 한 장씩 Frame 별로 각각 지정하는 방법입니다.

Frame-by-frame animation

3. Tweened animation

Tweened animation을 구현하기 위해서는 관련 기본적인 클래스들에 대해 알아야 할 필요가 있습니다. Animation 타입에 따라 AlphaAnimation, RotateAnimation, ScaleAnimation, TranslateAnimation 클래스로 파생되며, 각각에 대해 알아보겠습니다.

3-1. Animation 클래스

View에 Tweened Animation을 적용하기 위한 기본 클래스이며, Matrix 연산을 통한 Graphic 변환을 할 수 있게 해줍니다. 모든 Animation은 기본적으로 duration, fillAfter, fillBefore, interpolator, repeatCount, repeatMode, startOffset, zAdjustment 속성을 제공합니다.

  • AlphaAnimation: 오브젝트의 투명도가 변하는 동작을 적용하기 위한 클래스로, Animation이 동작하는 동안 투명도를 변경하기 위해 사용합니다.
  • RotateAnimation: 오브젝트를 회전시키는 동작을 적용하며, 기준점을 어디로 잡느냐에 따라 오브젝트 중심 또는 Parent layout의 중심으로 회전 시킬 수 있습니다.
  • ScaleAnimation: 오브젝트의 크기를 변경시키는 동작을 적용하며, 마찬가지로 기준점을 어디로 잡느냐에 따라 오브젝트 중심 또는 Parent layout의 중심으로 크기를 설정할 수 있습니다.
  • TranslateAnimation: 오브젝트를 이동시키는 동작을 적용합니다.

3-2. Interpolator 클래스

Key frame 간의 상태 변경 시, magnitude 값을 주어 가속도 같은 물리 효과를 줄 수 있습니다. 각 효과에는 factor값을 조정하여 효과를 조정할 수 있습니다. 여기에 나열한 Interpolator 클래스의 동작은 SDK에 있는 ApiDemos 또는 이 포스트에 포함된 데모 App을 통하여도 확인 할 수 있습니다.

  • AccelerateInterpolator: 변화하는 속도가 처음에는 느리게 시작하다가 점점 빨라집니다.
  • DecelerateInterpolator: 변화하는 속도가 처음에는 빠르다가 점점 느려집니다.
  • AccelerateDecelerateInterpolator: AccelerateInterpolator와 DecelerateInterpolator가 합쳐진 효과입니다.
  • CycleInterpolator: Sine 패턴의 변화 동작을 적용할 수 있습니다.
  • LinearInterpolator: 일정한 비율로 변화하는 동작을 적용할 수 있습니다.
  • AnticipateInterpolator: 변화 효과가 처음에는 뒤로 역행하다가 마지막 상태로 변화합니다.
  • OvershootInterpolator: 변화 효과가 마지막 상태를 넘어서 지나친듯 하다가 마지막 상태로 변화합니다.
  • AnticipateOvershootInterpolator: 변화 효과가 처음에는 뒤로 역행하다가 튀어나가 듯이 빠르게 진행하여, 결국 마지막 상태를 넘어선 후 마지막 상태로 변화합니다.

3-3. AnimationSet 클래스

같이 실행되어야 할 Animation의 그룹을 설정하는데 사용합니다. 각각의 Animation을 혼합하여 한개의 Animation으로 동작하도록 해줍니다. 예를 들어, Path의 하위 메뉴 동작을 구현하기 위해서는 Translate와 Rotate Animation을 동시에 적용해야 하는데, 이럴 때 사용합니다.

4. Path에서 사용한 Animation

지금까지 설명한 Animation 기법들을 사용해서 Path의 메뉴를 구현할 수 있습니다. Demo 앱의 Path Demo를 통하여 확인 할 수 있으니 도움이 되었으면 합니다.

4-1. (+) 메뉴 따라하기

        private void startMenuAnimation(boolean open) {
            Animation rotate;

            if(open)
                rotate = new RotateAnimation(
                        0, 45
                        , Animation.RELATIVE_TO_SELF, 0.5f
                        , Animation.RELATIVE_TO_SELF, 0.5f);
            else
                rotate = new RotateAnimation(
                        -45, 0
                        , Animation.RELATIVE_TO_SELF, 0.5f
                        , Animation.RELATIVE_TO_SELF, 0.5f);

            rotate.setInterpolator(AnimationUtils.loadInterpolator(this,
                            android.R.anim.anticipate_overshoot_interpolator));
            rotate.setFillAfter(true);
            rotate.setDuration(duration);
            plus.startAnimation(rotate);

            for(int i = 0 ; i < buttons.size() ; i++) {
                startSubButtonAnimation(i, open);
            }
        }
  • 메뉴가 열릴 때는, (+) 버튼을 RotateAnimation을 사용하여 45도로 Rotate해 주고, 하위 메뉴 버튼들의 Animation 시작합니다.
  • RotateAnimation에 Animation.RELATIVE_TO_SELF라는 Flag을 설정해준 이유는 자기 자신을 기준으로 회전해야 하기 때문입니다. 만약 RELATIVE_TO_PARENT라는 Flag를 설정해주면 (+)버튼의 상위 layout 기준으로 회전하게 되서 원하는 동작을 줄 수 없습니다. 그리고, 0.5f는 50%를 의미하는 것이며, 중간 지점을 기준으로 잡아야 하므로, 50%인 지점을 pivot으로 회전하게 됩니다. 메뉴가 닫힐 때는 열릴 때와 반대로 설정합니다.
  • 이 Animation에는 anticipate/overshoot interpolator 효과가 적용되어 있어, (+)버튼이 회전할 때, 약간 반대로 회전하는 듯 하다가 정상 방향으로 회전하고, 마지막 상태로 지정했던 각도보다 약간 더 회전했다가 45도로 돌아가도록 동작하도록 되어 있습니다.
  • 또한 마지막 상태로 고정되어야 하므로, FillAfter를 true로 설정합니다. false로 되어 있으면 (+)버튼이 회전한 후 원래 위치로 돌아가게 됩니다.

4-2. 하위 메뉴 따라하기

            private void startSubButtonAnimation(int index, boolean open) {

            PathButton view = buttons.get(index);

            float endX = length * FloatMath.cos(
                    (float) (Math.PI * 1/2 * (index)/(buttons.size()-1))
                    );
            float endY = length * FloatMath.sin(
                    (float) (Math.PI * 1/2 * (index)/(buttons.size()-1))
                    );

            AnimationSet animation = new AnimationSet(false);
            Animation translate;
            Animation rotate = new RotateAnimation(
                    0, 360
                    , Animation.RELATIVE_TO_SELF, 0.5f
                    , Animation.RELATIVE_TO_SELF, 0.5f);
            rotate.setDuration(sub_duration);
            rotate.setRepeatCount(1);
            rotate.setInterpolator(AnimationUtils.loadInterpolator(this,
                    android.R.anim.accelerate_interpolator));

            if(open) {
                translate = new TranslateAnimation(
                        0.0f, endX
                        , 0.0f, -endY);
                translate.setDuration(sub_duration);
                translate.setInterpolator(AnimationUtils.loadInterpolator(this,
                        android.R.anim.overshoot_interpolator));
                translate.setStartOffset(sub_offset*index);

                view.setOffset(endX, -endY);
            } else {
                translate = new TranslateAnimation(
                        endX, 0
                        , -endY, 0);
                translate.setDuration(sub_duration);
                translate.setStartOffset(sub_offset*(buttons.size()-(index+1)));
                translate.setInterpolator(AnimationUtils.loadInterpolator(this,
                        android.R.anim.anticipate_interpolator));

                view.setOffset(-endX, endY);
            }

            //애니메이션이 끝나고 그자리에 남아있어야 한다.
            animation.setFillAfter(true);

            //순서가 바뀌면 안된다.
            animation.addAnimation(rotate);
            animation.addAnimation(translate);

            view.startAnimation(animation);
        }
  • 각 하위 메뉴는 TranslateAnimation을 사용하여 move를 하면서, RotateAnimation을 사용하여 회전효과를 줍니다. RotateAnimation에는 회전 속다가 점점 빨라지는 효과를 주기 위해 AccelateInterpolator를 사용하였습니다. Move 할 지점은 원의 방정식을 이용하여 x, y 위치를 계산합니다. 또한, 두 개의 Animation 효과를 동시에 주어햐 하므로 AnimationSet클래스를 사용합니다.
  • AnimationSet에 각 Animation을 추가할 때는 순서에 유의 해야합니다. 만약 translate보다 rotate를 나중에 add하게되면, 움직이는 경로가 rotate되게 되어 원하는 동작을 볼 수 없습니다.
    Rotate를 위한 Interpolator는 AccelerateInterpolator를 사용하였으며, Move를 위한 Interpolator는 OvershootInterpolator, AnticipateInterpolator를 사용했습니다.
  • 하위 메뉴가 열릴 때는 차례대로 열려야 하므로, start offset 시간을 설정합니다.
    여기서 하위 메뉴 버튼을 일반적인 Button 클래스를 쓰지 않고 PathButton 클래스를 사용한 것에 유념바랍니다. 그리고, Android의 Animation은 실제로 그 View자체가 이동한 것이 아니라는 것을 유념해야 합니다. FillAfter를 true로 주더라도 실제로 그 View가 그 위치에 있는 것이 아니라는 것입니다. 따라서 Button의 Click이벤트를 처리하기 위해서는 몇 가지 수정이 필요했습니다. 이부분은 PathButton 클래스 부분에 설명되어 있습니다.

4-3. 하위 메뉴 선택할 때 따라하기

        private void startSubButtonSelectedAnimation(int index) {
            for(int i = 0 ; i < buttons.size() ; i++) {
                if(index == i) {
                    PathButton view = buttons.get(i);

                    AnimationSet animation = new AnimationSet(false);

                    //실제 버튼이 이동한것이 아니다. 다른 애니메이션을 실행시키기 전에 미리 이동시켜야한다.
                    Animation translate = new TranslateAnimation(
                            0.0f, view.getXOffset()
                            , 0.0f, view.getYOffset());
                    translate.setDuration(0);

                    Animation scale = new ScaleAnimation(
                            1.0f, 2.5f
                            , 1.0f, 2.5f
                            , Animation.RELATIVE_TO_SELF, 0.5f
                            , Animation.RELATIVE_TO_SELF, 0.5f);
                    scale.setDuration(sub_select_duration);

                    Animation alpha = new AlphaAnimation(1.0f, 0.0f);
                    alpha.setDuration(sub_select_duration);

                    animation.addAnimation(scale);
                    animation.addAnimation(translate);
                    animation.addAnimation(alpha);

                    view.startAnimation(animation);
                } else {
                    PathButton view = buttons.get(i);

                    AnimationSet animation = new AnimationSet(false);

                    //실제 버튼이 이동한것이 아니다. 다른 애니메이션을 실행시키기 전에 미리 이동시켜야한다.
                    Animation translate = new TranslateAnimation(
                            0.0f, view.getXOffset()
                            , 0.0f, view.getYOffset());
                    translate.setDuration(0);

                    Animation scale = new ScaleAnimation(
                            1.0f, 0.0f
                            , 1.0f, 0.0f
                            , Animation.RELATIVE_TO_SELF, 0.5f
                            , Animation.RELATIVE_TO_SELF, 0.5f);
                    scale.setDuration(sub_select_duration);

                    Animation alpha = new AlphaAnimation(1.0f, 0.0f);
                    alpha.setDuration(sub_select_duration);

                    animation.addAnimation(scale);
                    animation.addAnimation(translate);
                    animation.addAnimation(alpha);

                    view.startAnimation(animation);
                }
            }

            if(isMenuOpened) {
                //(+)버튼은 닫힌 상태로 돌아가야한다.
                isMenuOpened = false;

                Animation rotate = new RotateAnimation(
                        -45, 0
                        , Animation.RELATIVE_TO_SELF, 0.5f
                        , Animation.RELATIVE_TO_SELF, 0.5f);

                rotate.setInterpolator(AnimationUtils.loadInterpolator(this,
                                android.R.anim.anticipate_overshoot_interpolator));
                rotate.setFillAfter(true);
                rotate.setDuration(sub_select_duration);
                plus.startAnimation(rotate);
            }
        }

 

하위 메뉴가 선택되었을 때의 동작을 표현하기 위해서는, 앞서 말한 것처럼 Animation 동작 후에 실제로 View가 이동한것이 아니라는 것을 염두에 두어야 합니다.
따라서, 새로운 Animation을 동작시키기 위해서는 마지막 위치로 미리 이동시킨 후에 다른 Animation을 실행해야 합니다.
여기서 TranslateAnimation이 바로 미리 이동시키기 위해 추가된 부분입니다. 만약 TranslateAnimation으로 미리 이동시키지 않는다면, 원래 위치인 (+)버튼 밑으로 가려져서 보이지도 않게 됩니다. 유념하기 바랍니다.
선택하면 선택한 버튼은 ScaleAnimation을 사용하여 커지면서, AlphaAnimation을 통해 사라지도록 구현하였습니다.
여기서도 동시에 두개의 Animation이 동작하므로 AnimationSet클래스를 통해 Add 하여 하나의 Animation 동작으로 구현하였습니다.
(+)버튼은 원래대로 돌아가도록 구현하였습니다.

4-4. 하위 메뉴 버튼 클래스는 만들어야 해요

위에서 설명하였듯이, Animation 후에 FillAfter를 true로 설정하여 그 위치에 보이도록 하더라도 실제 그 버튼이 그 위치에 있는 것이 아닙니다. 따라서, 버튼이 눌려지도록 하기 위해서는 두 가지 방법이 있습니다.
첫 번째 방법은, AnimationListener를 등록해서, 동작이 끝날때 들어오는 이벤트인 onAnimationEnd에서 실제로 그 View를 이동시켜주는 방법이고, 두 번째는 실제로 이동은 시키지 않고 누려진 것처럼 처리하는 방법입니다.
여기서는 두번째 방법을 사용했으며, Button 클래스의 Click체크하는 함수를 Override하여, 이동한 만큼 Offset값을 설정하여 눌려진 것처럼 처리하였습니다. 하위 메뉴가 열리거나 닫혔을 때, setOffset을 통하여 이동한 만큼 Offset을 설정하고, 실제로 Click이 되었는지 체크하는 함수인 getHitRect를 Override하여 처리하였습니다.

    public class PathButton extends Button {
        private float x_offset = 0;
        private float y_offset = 0;

        public PathButton(Context context, AttributeSet attrs) {
            super(context, attrs);
        }

        public PathButton(Context context) {
            super(context);
        }

        public PathButton(Context context, AttributeSet attrs, int defStyle) {
            super(context, attrs, defStyle);
        }

        @Override
        public void getHitRect(Rect outRect) {
            Rect curr = new Rect();
            super.getHitRect(curr);

            outRect.bottom = (int) (curr.bottom + y_offset);
            outRect.top = (int) (curr.top + y_offset);
            outRect.left = (int) (curr.left + x_offset);
            outRect.right = (int) (curr.right + x_offset);
        }

        public void setOffset(float endX, float endY) {
            x_offset = endX;
            y_offset = endY;
        }

        public float getXOffset() {
            return x_offset;
        }

        public float getYOffset() {
            return y_offset;
        }
    }

4-5. 화면 레이아웃 따라하기

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
       android:layout_width="fill_parent"
       android:layout_height="fill_parent"
       android:orientation="vertical" >

        <RelativeLayout
           android:layout_width="fill_parent"
           android:layout_height="fill_parent">
            <RelativeLayout
                android:layout_width="220dp"
                android:layout_height="220dp"
                android:layout_alignParentBottom="true"
               android:layout_marginBottom="10dp"
               android:layout_marginLeft="10dp">
                <com.paran.animation.demo.app.animation.PathButton
                    android:id="@+id/camera"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignParentBottom="true"
                    android:layout_alignParentLeft="true"
                    android:layout_marginBottom="4dp"
                    android:layout_marginLeft="4dp"
                    android:background="@drawable/composer_camera"
                    android:visibility="visible">
                </com.paran.animation.demo.app.animation.PathButton>
                <com.paran.animation.demo.app.animation.PathButton
                    android:id="@+id/with"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignParentBottom="true"
                    android:layout_alignParentLeft="true"
                    android:layout_marginBottom="4dp"
                    android:layout_marginLeft="4dp"
                    android:background="@drawable/composer_with"
                    android:visibility="visible">
                </com.paran.animation.demo.app.animation.PathButton>
                <com.paran.animation.demo.app.animation.PathButton
                    android:id="@+id/place"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignParentBottom="true"
                    android:layout_alignParentLeft="true"
                    android:layout_marginBottom="4dp"
                    android:layout_marginLeft="4dp"
                    android:background="@drawable/composer_place"
                    android:visibility="visible">
                </com.paran.animation.demo.app.animation.PathButton>
                <com.paran.animation.demo.app.animation.PathButton
                    android:id="@+id/music"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignParentBottom="true"
                    android:layout_alignParentLeft="true"
                    android:layout_marginBottom="4dp"
                    android:layout_marginLeft="4dp"
                    android:background="@drawable/composer_music"
                    android:visibility="visible">
                </com.paran.animation.demo.app.animation.PathButton>
                <com.paran.animation.demo.app.animation.PathButton
                    android:id="@+id/thought"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignParentBottom="true"
                    android:layout_alignParentLeft="true"
                    android:layout_marginBottom="4dp"
                    android:layout_marginLeft="4dp"
                    android:background="@drawable/composer_thought"
                    android:visibility="visible">
                </com.paran.animation.demo.app.animation.PathButton>
                <com.paran.animation.demo.app.animation.PathButton
                    android:id="@+id/sleep"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignParentBottom="true"
                    android:layout_alignParentLeft="true"
                    android:layout_marginBottom="4dp"
                    android:layout_marginLeft="4dp"
                    android:background="@drawable/composer_sleep"
                    android:visibility="visible">
                </com.paran.animation.demo.app.animation.PathButton>
                <RelativeLayout
                   android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignParentBottom="true"
                    android:clickable="true"
                    android:visibility="visible">
                    <Button
                       android:id="@+id/plus_button"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_centerInParent="true"
                        android:background="@drawable/composer_button">
                    </Button>
                    <ImageView
                        android:id="@+id/plus"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_centerInParent="true"
                        android:src="@drawable/composer_icn_plus">
                    </ImageView>
                </RelativeLayout>
            </RelativeLayout>
        </RelativeLayout>
    </LinearLayout>

하위 메뉴는 Button 클래스를 상속받은 PathButton 클래스로 구성하였습니다. 하위 메뉴가 Animation하면서 이동될 Layout은 Parent인 RelativeLayout 이므로, 크기는 이동될 공간을 생각해 넉넉하게 잡습니다. (+) 메뉴는 회전시킬 (+) 이미지를 표현하는 ImageView와, 배경 이미지를 표현하며 실제 버튼으로 동작할 Button으로 구성되어 있습니다.

5. 맺음말

Android에서는 너무나 쉽게 Animation을 지원해주고 있습니다. 사실 코딩할 것이 별로 없을 정도였습니다. Flash로 Animation을 만드는 방식과 유사한 방식으로 Animation을 구현할 수 있습니다. 실제 Flash처럼 Key Frame을 제공하여 각 Key Frame마다 상태를 주어 구현할 수 있는 것은 아니지만, 개념적으로 유사한 방식으로 구현할 수 있는 방식인 Tweened Animation을 지원하는 것만으로도 막강하다 할 수 있습니다.

혹시 Final Fantasy라는 게임을 해보았는지 모르겠습니다. Final Fantasy는 전통 일본 RPG 게임이며, 지금까지 15편까지 나올 정도로 인기를 끌고 있는 게임입니다. 이 게임이 다른 게임보다 더 인기를 끌 수 있었던 이유는 뭘까? 여러 가지가 있겠지만, 숨겨진 퀘스트, 보물 등이 그 한가지라고 할 수 있습니다. 정상적인 루트로 시나리오를 끌고 간다면, 얻을 수 없는 퀘스트, 보물, 게임 캐릭터 때문에, 심지어 수십 번을 다시 하기도 합니다. 화려한 그래픽은 두말하면 잔소리죠. 우리에게도 필요한 부분이라 생각합니다.

지금까지 대부분의 App들은 정적인 형태의 App이라 생각합니다. 기본에 충실한 정적인 UI/UX는 사용자에게 예측 가능한 경험을 주어, 접근하기 쉽게 해준다는 장점은 있겠지만, 숨겨진 Dynamic한 UI/UX를 통해 사용자에게 재미를 주고, 더 나아가 편리하게 쓸 수 있는 방법까지 준다면, 좀더 재미있는 App이 되지 않을까 생각합니다.

6. References

7. Demo 소스와 설치 파일 링크

 

Path의 모든것 – 연작글

[본문링크] Path 2.0을 통하여 재조명하는 안드로이드 애니메이션
[1]
코멘트(이글의 트랙백 주소:/cafe/tb_receive.php?no=34875
작성자
비밀번호

 

SSISOCommunity

[이전]

Copyright byCopyright ⓒ2005, SSISO Community All Rights Reserved.