개발

안드로이드 MotionLayout

Android MotionLayout 사용해보기

안드로이드 MotionLayout안드로이드 MotionLayout

Android Developer

Android MotionLayout

안드로이드에서는 아래와 같은 툴을 사용해서 애니메이션 처리를 할 수 있었습니다.

그리고 2018년에 구글에서는 MotionLayout 이라는 새로운 애니메이션 툴을 발표했습니다.

지금부터 MotionLayout 을 한번 훑어보려 합니다.

MotionLayout 특징

  1. MotionLayout 은 ConstraintLayout 을 상속받은 ViewGroup 입니다.
  2. MotionLayout 은 property animation framework, TransitionManger, CoordinatorLayout 의 혼합체 라고 할 수 있습니다.
  3. TransitionManager 와 같이 두 layout 간의 변화를 애니메이션 처리 할 수 있습니다.
  4. Property Animation 과 같이 View의 속성을 애니메이션 처리 할 수 있습니다.
  5. CoordinatorLayout 과 같이 사용자의 터치에 따라 반응하는 애니메이션 처리 할 수 있습니다.
  6. Fully Declarative 합니다. 애니메이션에 관한 모든것을 XML 으로 선언가능합니다.
  7. ConstraintLayout 2.0에 포함되어 있으며, support library 로 Api level 14이상에서 호환됩니다. (2019.9.29 현재 ConstraintLayout 2.0 은 베타로 사용가능)
  8. TransitionManager 는 Nested Layout 이나 Activity Transition 에도 사용가능하나, MotionLayout 은 직접적인 하위 view 들에게만 적용 가능합니다.

결론적으로, MotionLayout 은 한 번 시작하면 끝날때 까지 컨트롤할 수 없는 애니메이션이 아닌, UI Element 와 사용자가 실시간으로 상호작용하는 애니메이션 처리가 필요 할 때 적절한 선택으로 보입니다.

이후로는 아래와 같은 순서로 정리하겠습니다.


MotionLayout 구조

231d9338-d3d4-80e8-acb8-c4e236a00492.png231d9338-d3d4-80e8-acb8-c4e236a00492.png

MotionLayout 은 MotionScene 을 레퍼런싱 하고, 애니메이션 관련된 모든 것은 이 MotionScene 에 정의됩니다. ConstraintSet 은 Constraint 정의를 위한 별도의 layout xml 파일을 만들 필요 없이 직접 Constraint 를 정의할 때 사용할 수 있습니다. 타겟 뷰의 크기와 위치를 선언할 수 있으며, CustomAttribute 를 통해 타겟 뷰의 다른 속성들도 정의할 수 있습니다. Transition 은 애니메이션의 시작과 끝 Constraint 를 정의하고, duration 등의 기타 애니메이션 속성들을 정의합니다. 사용자와 인터랙션할 수 있는 OnClick 과 OnSwipe 핸들러를 등록할 수 있으며, KeyFrameSet 을 통해 애니메이션의 시작과 끝 뿐만 아니라 그 사이 지점들의 Constraint 를 정의할 수 있습니다.

자세한 속성들에 대한 설명 이전에 일단은 MotionLayout 을 사용해서 간단한 애니메이션을 구현하는 예제를 살펴보려 합니다.


MotionLayout 기본 예제

위와 같이 터치에 따라 움직이는 뷰를 만들기 위해 먼저 MotionLayout xml 파일을 만듭니다.

231d9338-d3d4-80b4-b92c-d47b70a85040.gif
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="<http://schemas.android.com/apk/res/android>"
        xmlns:app="<http://schemas.android.com/apk/res-auto>"
        xmlns:tools="<http://schemas.android.com/tools>"
        android:id="@+id/motionLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutDescription="@xml/scene_01"
        tools:showPaths="true">

<View
        android:id="@+id/button"
        android:layout_width="64dp"
        android:layout_height="64dp"
        android:background="@color/colorAccent" />

</androidx.constraintlayout.motion.widget.MotionLayout>

motion_layout_ex1.xml

id:button 인 View 가 애니메이션 할 대상이며, 위치에 대한 정의는 하지 않아도 됩니다. MotionLayout 의 app:layoutDescription 속성으로 scene_01 을 설정해 주었는데, 바로 모든 애니메이션 관련 내용이 들어갈 MotionScene 을 정의한 파일입니다.

<?xml version="1.0" encoding="utf-8"?>
    <MotionScene xmlns:motion="<http://schemas.android.com/apk/res-auto>">

        <Transition
            motion:constraintSetEnd="@layout/motion_cl_end"
            motion:constraintSetStart="@layout/motion_cl_start">

            <OnSwipe
                motion:dragDirection="dragLeft"
                motion:touchAnchorId="@+id/button"
                motion:touchAnchorSide="right"/>

        </Transition>
    </MotionScene>

scene_01.xml

MotionScene 안에 Transition 을 정의하고, motion:constraintSetStart 와 motion:constraintSetEnd 속성으로 layout 들을 설정해주었습니다. 이 layout 들은 각각 애니메이션의 시작과 끝의 View 의 위치를 정의해놓은 파일입니다.

그리고 Transition 안에 OnSwipe 핸들러를 정의해 사용자의 터치에 반응하도록 하였습니다. motion:touchAnchorId 로 애니메이션 처리할 뷰의 id 를 등록해 주었으며, 그 외의 속성들은 간단한 사용법만을 전달하기 위해 뒤에서 다루겠습니다.

<?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="<http://schemas.android.com/apk/res/android>"
            xmlns:app="<http://schemas.android.com/apk/res-auto>"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        <View
                android:id="@+id/button"
                android:layout_width="64dp"
                android:layout_height="64dp"
                android:layout_marginStart="8dp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>

motion_cl_start.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="<http://schemas.android.com/apk/res/android>"
        xmlns:app="<http://schemas.android.com/apk/res-auto>"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

<View
        android:id="@+id/button"
        android:layout_width="64dp"
        android:layout_height="64dp"
        android:layout_marginEnd="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

motion_cl_end.xml

Transition 에 정의해둔 애니메이션의 시작과 끝 layout xml 파일입니다.

이것으로 끝입니다. 다음에는 ConstraintSet 사용 예제를 보겠습니다.


ConstraintSet 사용 예제

231d9338-d3d4-806f-97ff-d3c9beea3409.gif
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="<http://schemas.android.com/apk/res/android>"
        xmlns:app="<http://schemas.android.com/apk/res-auto>"
        android:id="@+id/motionLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutDescription="@xml/scene_02"
        app:showPaths="true">

<View
        android:id="@+id/button"
        android:layout_width="64dp"
        android:layout_height="64dp"
        android:background="@color/colorAccent" />

</androidx.constraintlayout.motion.widget.MotionLayout>

motion_layout_ex2.xml

동일하게 MotionLayout layout xml 을 만들고, layoutDescription 으로 scene_02.xml 을 참조해주었습니다.

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="<http://schemas.android.com/apk/res/android>"
        xmlns:motion="<http://schemas.android.com/apk/res-auto>">

<Transition
        motion:constraintSetEnd="@id/end"
        motion:constraintSetStart="@id/start"
        motion:motionInterpolator="linear">

        <OnSwipe
                motion:dragDirection="dragLeft"
                motion:touchAnchorId="@+id/button"
                motion:touchAnchorSide="right" />
</Transition>

<!--  ConstraintSet 을 통해 background color 조정  -->
<ConstraintSet android:id="@+id/start">
        <Constraint
                android:id="@+id/button"
                android:layout_width="64dp"
                android:layout_height="64dp"
                android:layout_marginStart="8dp"
                motion:layout_constraintBottom_toBottomOf="parent"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toTopOf="parent">

        <CustomAttribute
                motion:attributeName="backgroundColor"
                motion:customColorValue="@color/colorPrimary" />

        </Constraint>
</ConstraintSet>

<ConstraintSet android:id="@+id/end">
        <Constraint
                android:id="@+id/button"
                android:layout_width="64dp"
                android:layout_height="32dp"
                android:layout_marginEnd="8dp"
                motion:layout_constraintBottom_toBottomOf="parent"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintTop_toTopOf="parent">

        <CustomAttribute
                motion:attributeName="backgroundColor"
                motion:customColorValue="@color/colorAccent" />

        </Constraint>
</ConstraintSet>
</MotionScene>

scene_02.xml

기본예제와 다르게, Transition 의 motion:constraintSetStart, motion:constraintSetEnd 속성에 레이아웃 파일이 아닌 ConstraintSet 의 id를 설정해주었습니다. Constraint 에는 layout_height 가 변경되도록 설정하고 CustomAttribute 로 backgroundColor 도 설정해주었습니다. CustomAttribute 는 타겟 뷰가 가지고 있는 여러 속성들을 설정해줄 수 있습니다. attributeName 은 타겟뷰의 getter/setter 메소드명과 동일하게 해주면 되고, value 는 color, Integer, Float, Dimension, String 등 값을 설정해줄 수 있습니다.

즉, 타겟뷰를 커스텀뷰로 만들고 setFaceSize(size: Int) 라는 메소드를 만들면 motion:attributeName=”faceSize”, motion:customIntegerValue=”10” 이런식으로 자유도 높게 사용할 수 있습니다.

ConstraintSet 사용법을 간단히 살펴보았습니다. 다음은 ConstraintSet 과 ImageFilterView 를 같이 사용해보겠습니다.


ConstraintSet 과 ImageFilterView 사용 예제

231d9338-d3d4-8069-aaa6-d4578b329585.gif

타겟뷰에 ImageFilterView 를 사용해서 이미지가 변경되는 애니메이션을 만들어보겠습니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="<http://schemas.android.com/apk/res/android>"
        xmlns:app="<http://schemas.android.com/apk/res-auto>"
        android:id="@+id/motionLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutDescription="@xml/scene_03"
        app:showPaths="true">

<androidx.constraintlayout.utils.widget.ImageFilterView
        android:id="@+id/image"
        android:layout_width="64dp"
        android:layout_height="64dp"
        android:background="@color/white"
        android:src="@drawable/ic_999_icon_gnb_info_off"
        app:altSrc="@drawable/ic_999_icon_gnb_info_on" />

</androidx.constraintlayout.motion.widget.MotionLayout>

motion_layout_ex3.xml

타겟뷰에 ImageFilterView 를 사용했습니다. ImageFilterView 는 AppCompatImageView 를 상속받는 뷰로 이미지에 여러가지 효과를 줄 수 있는 뷰입니다. src 에 변경전 이미지를, altSrc 에 변경후 이미지를 세팅해주었습니다. (png 이든 벡터 이미지든 상관없이 정상동작합니다)

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="<http://schemas.android.com/apk/res/android>"
        xmlns:motion="<http://schemas.android.com/apk/res-auto>">

        <Transition
                motion:constraintSetEnd="@id/end"
                motion:constraintSetStart="@id/start"
                motion:motionInterpolator="linear">

                <OnSwipe
                        motion:dragDirection="dragLeft"
                        motion:touchAnchorId="@+id/image"
                        motion:touchAnchorSide="right" />
        </Transition>

        <ConstraintSet android:id="@+id/start">
                <Constraint
                        android:id="@+id/image"
                        android:layout_width="64dp"
                        android:layout_height="64dp"
                        android:layout_marginStart="8dp"
                        motion:layout_constraintBottom_toBottomOf="parent"
                        motion:layout_constraintStart_toStartOf="parent"
                        motion:layout_constraintTop_toTopOf="parent">

                <CustomAttribute
                        motion:attributeName="crossfade"
                        motion:customFloatValue="0" />

                </Constraint>
        </ConstraintSet>

        <ConstraintSet android:id="@+id/end">
                <Constraint
                        android:id="@+id/image"
                        android:layout_width="64dp"
                        android:layout_height="64dp"
                        android:layout_marginEnd="8dp"
                        motion:layout_constraintBottom_toBottomOf="parent"
                        motion:layout_constraintEnd_toEndOf="parent"
                        motion:layout_constraintTop_toTopOf="parent">

                <CustomAttribute
                        motion:attributeName="crossfade"
                        motion:customFloatValue="1" />

                </Constraint>
        </ConstraintSet>
</MotionScene>

scene_03.xml

이번에는 CustomAttribute 에 crossfade 라는 속성을 설정해주었습니다.ImageFilterView 에는 public void setCrossfade(float crossfade) 라는 함수가 있기에 가능한 것입니다.


KeyFrameSet 사용 예제

231d9338-d3d4-80be-b961-d2e57480584f.gif

MotionLayout 의 기본 아이디어는 애니메이션의 시작과 끝의 상태인 “resting state” 를 ConstraintSet 으로 구현하는 것입니다. 여기에 KeyFrameSet 을 사용하면 시작과 끝 사이지점의 상태 또한 구현할 수 있습니다. ( 이는 resting state 는 아니고 단순히 지나가는 state 입니다. )

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="<http://schemas.android.com/apk/res/android>"
        xmlns:app="<http://schemas.android.com/apk/res-auto>"
        android:id="@+id/motionLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutDescription="@xml/scene_04"
        app:showPaths="true">

<View
        android:id="@+id/button"
        android:layout_width="64dp"
        android:layout_height="64dp"
        android:background="@color/colorAccent" />

</androidx.constraintlayout.motion.widget.MotionLayout>

motion_layout_ex4.xml

layoutDescription 으로 scene_04.xml 을 설정해주었습니다.

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="<http://schemas.android.com/apk/res/android>"
        xmlns:motion="<http://schemas.android.com/apk/res-auto>">

<Transition
        motion:constraintSetEnd="@id/end"
        motion:constraintSetStart="@id/start"
        motion:motionInterpolator="linear">

        <OnSwipe
                motion:dragDirection="dragLeft"
                motion:touchAnchorId="@+id/button"
                motion:touchAnchorSide="right" />

        <KeyFrameSet>

        <KeyAttribute
                android:rotation="-45"
                android:scaleX="2"
                android:scaleY="2"
                motion:framePosition="50"
                motion:motionTarget="@+id/button" />

        <KeyPosition
                motion:framePosition="50"
                motion:keyPositionType="parentRelative"
                motion:motionTarget="@+id/button"
                motion:percentY="0.25" />
        </KeyFrameSet>
</Transition>

<ConstraintSet android:id="@+id/start">
        <Constraint
                android:id="@+id/button"
                android:layout_width="64dp"
                android:layout_height="64dp"
                android:layout_marginStart="8dp"
                motion:layout_constraintBottom_toBottomOf="parent"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toTopOf="parent">

        <CustomAttribute
                motion:attributeName="backgroundColor"
                motion:customColorValue="@color/colorPrimary" />

        </Constraint>
</ConstraintSet>

<ConstraintSet android:id="@+id/end">
        <Constraint
                android:id="@+id/button"
                android:layout_width="64dp"
                android:layout_height="64dp"
                android:layout_marginEnd="8dp"
                motion:layout_constraintBottom_toBottomOf="parent"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintTop_toTopOf="parent">

        <CustomAttribute
                motion:attributeName="backgroundColor"
                motion:customColorValue="@color/colorAccent" />

        </Constraint>
</ConstraintSet>
</MotionScene>

scene_04.xml

<Transition> 안의<KeyFrameSet> 이라는 태그를 주목해서 보겠습니다. <KeyFrameSet><KeyAttribute><KeyPosition>을 세팅해주었습니다. <KeyPosition> 은 레이아웃 위치를 설정할 때 사용할 수 있습니다. motion:motionTarget 속성으로 애니메이팅할 타겟뷰를 설정해주고, motion:keyPositionType 으로 parentRelative 을 설정했습니다. 이는 타겟뷰의 위치를 부모뷰와의 상대적 위치로 계산합니다. motion:percentY 를 0.25로 설정해줌으로, 부모뷰의 상단부터 하단까지의 25% 인 지점에 위치하도록 합니다. motion:framePosition 으로 전체 애니메이션의 시작과 끝 사이 어느위치에서의 상태인지를 설정합니다. 50으로 해 주었으니 전체 애니메이션의 딱 중간 부분을 나타냅니다. <KeyAttribute> 는 레이아웃 위치 이후 뷰의 속성들을 설정할 때 사용할 수 있습니다. 예제에서는 scale 을 2로 해서 뷰의 크기를 2배로 높였고, rotation 은 반시계방향으로 45도 회전하도록 해주었습니다.

지금까지 기본예제, ConstraintSet 사용 예제, KeyFrameSet 사용 예제를 다루어보았습니다. 다음은 MotionLayout, MotionScene 과 관련된 여러가지 속성들을 더 자세히 다루어보겠습니다.


<MotionLayout> 속성

<Transition> 속성

<OnClick> 속성

<OnSwipe> 속성

<Constraint> 속성

<KeyPosition> 속성

KeyFrameSet 의 KeyPosition 은 레이아웃 위치에 대한 설정

<KeyCycle> 의 속성

KeyFrameSet 의 KeyCycle 은 이전 state 에서 해당 framePosition 까지 특정 속성들을 진동시킨다.

<KeyAttribute> 속성

애니메이션중 해당 위치에서 레이아웃 이후 타겟 뷰의 속성들을 설정합니다.

지금까지 각 태그들이 어떤 속성을 갖고 있는지 알아보았습니다.마지막으로 MotionLayout 을 사용해서 안드로이드 OS 퀵메뉴를 만들어보겠습니다.



안드로이드 OS 퀵세팅 비스무리하게 따라해보기

231d9338-d3d4-80f9-bfe7-c710cb07bbfa.gif

MotionLayout 을 이용해서 간단한 안드로이드 퀵세팅을 구현해보았습니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="<http://schemas.android.com/apk/res/android>"
        xmlns:app="<http://schemas.android.com/apk/res-auto>"
        xmlns:tools="<http://schemas.android.com/tools>"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/white"
        app:layoutDescription="@xml/scene_quick_setting"
        tools:motionProgress="0">

<ImageView
        android:id="@+id/backgroundImage"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:adjustViewBounds="true"
        android:scaleType="centerCrop"
        android:src="@drawable/free_cat"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

<View
        android:id="@+id/iconBackground"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@color/quick_setting_background"
        app:layout_constraintBottom_toBottomOf="@+id/view9"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

<!--  Row 1  -->
<ImageView
        android:id="@+id/view1"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_cake_black_24dp" />

<ImageView
        android:id="@+id/view2"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_fingerprint_black_24dp" />

<ImageView
        android:id="@+id/view3"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_volume_up_black_24dp" />

<ImageView
        android:id="@+id/view4"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_access_alarms_black_24dp" />

<!--  Row 2  -->
<ImageView
        android:id="@+id/view5"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_airplanemode_active_black_24dp" />

<ImageView
        android:id="@+id/view6"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_bluetooth_black_24dp" />

<ImageView
        android:id="@+id/view7"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_bug_report_black_24dp" />

<ImageView
        android:id="@+id/view8"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_cloud_queue_black_24dp" />

<!--  Row 3  -->

<ImageView
        android:id="@+id/view9"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_favorite_border_black_24dp" />

<ImageView
        android:id="@+id/view10"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_location_on_black_24dp" />

<ImageView
        android:id="@+id/view11"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_screen_rotation_black_24dp" />

<ImageView
        android:id="@+id/view12"
        android:layout_width="@dimen/quick_setting_icon_width"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:paddingBottom="30dp"
        android:scaleType="fitCenter"
        android:src="@drawable/ic_thumb_up_black_24dp" />

</androidx.constraintlayout.motion.widget.MotionLayout>

quick_setting_activity.xml

움직일 아이콘 12개를 준비해주고, layoutDescription 으로 scene_quick_setting.xml 을 참조하도록 했습니다.

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="<http://schemas.android.com/apk/res/android>"
        xmlns:motion="<http://schemas.android.com/apk/res-auto>">

<Transition
        motion:constraintSetEnd="@id/end"
        motion:constraintSetStart="@id/start"
        motion:motionInterpolator="linear">

        <OnSwipe
                motion:dragDirection="dragDown"
                motion:touchAnchorSide="bottom" />

        <KeyFrameSet>
        <!-- Row 1 -->

        <!-- Row 2 -->
        <KeyPosition
                motion:framePosition="50"
                motion:keyPositionType="deltaRelative"
                motion:motionTarget="@+id/view5"
                motion:percentX="0"
                motion:percentY="1" />

        <KeyPosition
                motion:framePosition="50"
                motion:keyPositionType="deltaRelative"
                motion:motionTarget="@+id/view6"
                motion:percentX="0"
                motion:percentY="1" />

        <KeyPosition
                motion:framePosition="50"
                motion:keyPositionType="deltaRelative"
                motion:motionTarget="@+id/view7"
                motion:percentX="0"
                motion:percentY="1" />

        <KeyPosition
                motion:framePosition="50"
                motion:keyPositionType="deltaRelative"
                motion:motionTarget="@+id/view8"
                motion:percentX="0"
                motion:percentY="1" />

        <!-- Row 3 -->

        <KeyPosition
                motion:framePosition="50"
                motion:keyPositionType="deltaRelative"
                motion:motionTarget="@+id/view9"
                motion:percentX="1"
                motion:sizePercent="1" />

        <KeyPosition
                motion:framePosition="50"
                motion:keyPositionType="deltaRelative"
                motion:motionTarget="@+id/view10"
                motion:percentX="1"
                motion:sizePercent="1" />

        <KeyPosition
                motion:framePosition="50"
                motion:keyPositionType="deltaRelative"
                motion:motionTarget="@+id/view11"
                motion:percentX="1"
                motion:sizePercent="1" />

        <KeyPosition
                motion:framePosition="50"
                motion:keyPositionType="deltaRelative"
                motion:motionTarget="@+id/view12"
                motion:percentX="1"
                motion:sizePercent="1" />

        <KeyAttribute
                android:alpha="0"
                motion:framePosition="70"
                motion:motionTarget="@+id/view9" />

        <KeyAttribute
                android:alpha="0"
                motion:framePosition="70"
                motion:motionTarget="@+id/view10" />

        <KeyAttribute
                android:alpha="0"
                motion:framePosition="70"
                motion:motionTarget="@+id/view11" />

        <KeyAttribute
                android:alpha="0"
                motion:framePosition="70"
                motion:motionTarget="@+id/view12" />
        </KeyFrameSet>
</Transition>

<ConstraintSet android:id="@+id/start">
        <!-- Row 1 -->
        <Constraint
                android:id="@+id/view1"
                android:layout_width="@dimen/quick_setting_icon_width_start"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view2"
                motion:layout_constraintHorizontal_chainStyle="spread"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toTopOf="parent" />

        <Constraint
                android:id="@+id/view2"
                android:layout_width="@dimen/quick_setting_icon_width_start"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view3"
                motion:layout_constraintStart_toEndOf="@+id/view1"
                motion:layout_constraintTop_toTopOf="parent" />

        <Constraint
                android:id="@+id/view3"
                android:layout_width="@dimen/quick_setting_icon_width_start"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view4"
                motion:layout_constraintStart_toEndOf="@+id/view2"
                motion:layout_constraintTop_toTopOf="parent" />

        <Constraint
                android:id="@+id/view4"
                android:layout_width="@dimen/quick_setting_icon_width_start"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view5"
                motion:layout_constraintStart_toEndOf="@+id/view3"
                motion:layout_constraintTop_toTopOf="parent" />

        <!-- Row 2 -->

        <Constraint
                android:id="@+id/view5"
                android:layout_width="@dimen/quick_setting_icon_width_start"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view6"
                motion:layout_constraintStart_toEndOf="@+id/view4"
                motion:layout_constraintTop_toTopOf="parent" />

        <Constraint
                android:id="@+id/view6"
                android:layout_width="@dimen/quick_setting_icon_width_start"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view7"
                motion:layout_constraintStart_toEndOf="@+id/view5"
                motion:layout_constraintTop_toTopOf="parent" />

        <Constraint
                android:id="@+id/view7"
                android:layout_width="@dimen/quick_setting_icon_width_start"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view8"
                motion:layout_constraintStart_toEndOf="@+id/view6"
                motion:layout_constraintTop_toTopOf="parent" />

        <Constraint
                android:id="@+id/view8"
                android:layout_width="@dimen/quick_setting_icon_width_start"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toEndOf="@+id/view7"
                motion:layout_constraintTop_toTopOf="parent" />

        <!-- Row 3 -->

        <Constraint
                android:id="@+id/view9"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="gone"
                motion:layout_constraintEnd_toStartOf="@+id/view10"
                motion:layout_constraintHorizontal_chainStyle="spread"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toBottomOf="@+id/view5" />

        <Constraint
                android:id="@+id/view10"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="gone"
                motion:layout_constraintEnd_toStartOf="@+id/view11"
                motion:layout_constraintStart_toEndOf="@+id/view9"
                motion:layout_constraintTop_toBottomOf="@+id/view5" />

        <Constraint
                android:id="@+id/view11"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="gone"
                motion:layout_constraintEnd_toStartOf="@+id/view12"
                motion:layout_constraintStart_toEndOf="@+id/view10"
                motion:layout_constraintTop_toBottomOf="@+id/view5" />

        <Constraint
                android:id="@+id/view12"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="gone"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toEndOf="@+id/view11"
                motion:layout_constraintTop_toBottomOf="@+id/view5" />


</ConstraintSet>

<ConstraintSet android:id="@+id/end">
        <!-- Row 1 -->
        <Constraint
                android:id="@+id/view1"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view2"
                motion:layout_constraintHorizontal_chainStyle="spread"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toTopOf="parent" />

        <Constraint
                android:id="@+id/view2"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view3"
                motion:layout_constraintStart_toEndOf="@+id/view1"
                motion:layout_constraintTop_toTopOf="parent" />

        <Constraint
                android:id="@+id/view3"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view4"
                motion:layout_constraintStart_toEndOf="@+id/view2"
                motion:layout_constraintTop_toTopOf="parent" />

        <Constraint
                android:id="@+id/view4"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:layout_marginTop="20dp"
                android:visibility="visible"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toEndOf="@+id/view3"
                motion:layout_constraintTop_toTopOf="parent" />

        <!-- Row 2 -->

        <Constraint
                android:id="@+id/view5"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view6"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toBottomOf="@+id/view1" />

        <Constraint
                android:id="@+id/view6"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view7"
                motion:layout_constraintStart_toEndOf="@+id/view5"
                motion:layout_constraintTop_toBottomOf="@+id/view1" />

        <Constraint
                android:id="@+id/view7"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view8"
                motion:layout_constraintStart_toEndOf="@+id/view6"
                motion:layout_constraintTop_toBottomOf="@+id/view1" />

        <Constraint
                android:id="@+id/view8"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="visible"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toEndOf="@+id/view7"
                motion:layout_constraintTop_toBottomOf="@+id/view1" />

        <!-- Row 3 -->

        <Constraint
                android:id="@+id/view9"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view10"
                motion:layout_constraintHorizontal_chainStyle="spread"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toBottomOf="@+id/view5" />

        <Constraint
                android:id="@+id/view10"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view11"
                motion:layout_constraintStart_toEndOf="@+id/view9"
                motion:layout_constraintTop_toBottomOf="@+id/view5" />

        <Constraint
                android:id="@+id/view11"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="visible"
                motion:layout_constraintEnd_toStartOf="@+id/view12"
                motion:layout_constraintStart_toEndOf="@+id/view10"
                motion:layout_constraintTop_toBottomOf="@+id/view5" />

        <Constraint
                android:id="@+id/view12"
                android:layout_width="@dimen/quick_setting_icon_width"
                android:layout_height="wrap_content"
                android:visibility="visible"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toEndOf="@+id/view11"
                motion:layout_constraintTop_toBottomOf="@+id/view5" />

</ConstraintSet>
</MotionScene>

scene_quick_setting.xml

상하로 터치할 것이므로 OnSwipe 방향을 dragDown 으로 하고, constraintSetStart 와 constraintSetEnd 를 설정해주었습니다.

ConstraintSet 의 start 에는 아이콘 8개가 한 행으로 보이도록 하고 나머지 4개의 아이콘은 보이지 않도록 설정해주었습니다.

ConstraintSet 의 end 에는 3행 4열로 아이콘을 열거하고 모두 보이도록 설정해주었습니다.

이렇게만 해주었을 때 아래 이미지와 같이 아이콘들이 자신의 start 상태와 end 상태를 직선으로 움직입니다.

231d9338-d3d4-80a8-8e61-f7f7e3700ad5.gif

아이콘들이 직선으로 움직이기 때문에 1, 2행의 아이콘이 겹치는 현상이 발생하고, 3열의 아이콘들도 아이콘이 작아지며 사라져서 아름답지가 않습니다.

좀 더 Material 스러운 애니메이션을 위해 KeyFrame 을 삽입했습니다.

위 KeyFrame 설정을 간단히 설명하면, 2행의 아이콘들은 framePosition 50 에 아래와 같은 속성을 주었습니다.

motion:keyPositionType=”deltaRelative”
    motion:percentX=”0"
    motion:percentY=”1"

애니메이션의 50%인 시점에 X 위치는 start 상태가 되도록 하고 Y위치는 end 상태가 되도록 합니다. (아이콘들이 50% 까지는 우측으로만 이동합니다.)

3행의 아이콘들은 framePosition 50 에 아래와 같은 속성을 주었습니다.

motion:keyPositionType=”deltaRelative”
    motion:percentX=”1"
    motion:sizePercent=”1"

애니메이션의 50%인 시점에 X 위치는 end 상태가 되도록 하고 크기도 end 상태가 되도록 합니다. (아이콘들이 50% 까지는 위로만 움직이며 크기도 변화하지 않습니다.)

3행에 아이콘에는 추가적으로 알파 애니메이션을 주었습니다. KeyAttribute 를 사용해서 framePosition 70 에 alpha 값을 0으로 설정해서 애니메이션의 70%인 지점에서 아이콘이 완전히 보이지 않도록 설정했습니다.

이것으로 코드 한 줄 없이 xml 만으로 안드로이드 퀵세팅과 비슷한 애니메이션을 만들어 보았습니다. 물론 필요시 MotionLayout 의 progress 를 programmatically 제어하는등의 커스터마이징 역시 가능합니다.

지금까지 MotionLayout 을 가볍게 살펴보았습니다. 비록 지금은 Beta 버전이기에 실제 서비스에 사용하기에는 조금 위험할 수도 있으나, 이렇게 간단히 인터랙션 애니메이션을 구현할 수 있다는 점에서 빨리 정식 빌드가 나오길 기대하고 있습니다.

예제 GitHub

References