Android UI Animation 들이붓기

Sungyong An
26 min readSep 19, 2020
2020 DROID KNIGHTS

2주 전부터 안드로이드 개발자 분들을 대상으로 하는 2020 드로이드나이츠 행사가 시작되었습니다. 작년과 다르게 올해는 모두 온라인으로 진행되며, 매 주마다 3개의 세션 영상을 공식 YouTube 채널을 통해 공개하고 있습니다.

이번 주에 제 영상이 올라왔는데요. 주제 때문인지 반응이 별로 없는 느낌이 들었습니다. 문득 든 생각이 영상이 너무 딱딱해서 그런게 아닌가 싶어서 글로 정리합니다. (👀 핑계하고는!)

그럼 내용을 시작하겠습니다. 🚀

발표 주제를 Animation으로 정한 것은 두가지 이유가 있는데요. 그동안의 드로이드나이츠 세션에서는 Animation을 다룬 적이 없었던 것 같았고, 그래서 앱 UX/UI를 개선할 수 있는 방법으로 한번쯤 다뤄보면 좋을 것 같았습니다. 두 번째는 Android 개발환경의 변화와 관련되어 있는데요. 최근 많은 앱들이 minSdkVersion을 API 21(L) 이상으로 상향 조정하는 것으로 알고 있습니다. 따라서 Animation 관련 API를 사용함에 있어서 제약이 많이 줄어들었으므로, 활용하기에는 지금이 적기가 아닐까 생각했습니다. 🤔

처음 발표를 준비할 때는 조금 화려한 Animation을 소개해볼까 생각해서 제목을 거창하게 지었지만, 요구사항과 상황에 따라 구현 난이도가 천차만별이고, 실제로 써먹지 못한다면 무용지물이라는 생각이 들었습니다. 그래서 “누구나 손쉽게 사용할 수 있는 방법들”만을 정리했구요. 제목을 조금 정정하자면 들이붓기 보다는 살짝 찍어보기에 가까울 것 같습니다. 🙏

기피하는 이유?

우선 Animation 구현을 기피하게 되는 이유가 있지 않을까 생각해봤습니다.

Animation을 구현할 때는 실제로 빌드된 결과물을 봐야만 하기 때문에, 평소에 UI를 구현하던 시간보다 많은 시간이 소요됩니다. ⏳ 그리고 안드로이드에서 흔히 쓰이는 MVP, MVVM 같은 패턴을 사용하는 이유 중 하나는 View가 하는 일을 줄이려는 건데요. Animation은 오히려 View Layer가 하는 일을 늘어나게 만듭니다. 그럼 구현하는 Animation에 따라 View Layer 복잡도가 올라가고, 자연스럽게 구현 난이도도 올라가게 됩니다. 📈 마지막으로 앱 디자인 개편 등으로 Animation을 변경해야 하는 상황이 생길 수도 있습니다. 이 때, 유지보수 비용도 만만치 않겠죠. 🛠

즉, 가성비가 떨어지는 작업이라서 Animation 구현을 최소화 하는것 같다는 생각입니다. 그래서 많은 앱들이 간단한 로딩 UI 정도만 Animation을 보여주거나, 혹은 그조차도 없는 경우를 흔히 볼 수 있게 된 것 같습니다. 😭

오늘은 손쉽게 구현할 수 있는 Animation UseCase를 정리해봅니다.

Loading Animation ⏳

Loading / Progress / Frame Animation을 이용하는 방법을 알아봅니다.

Loading

1. Loading…

언제 끝날지 모르는 작업이 있을 때, Loading UI를 보여줍니다.
ProgressBar와 Drawable을 이용하여, Custom Loading UI를 만들 수 있습니다. 가운데는 브랜드 이미지라고 가정했구요. 바깥쪽 이미지는 <rotate>로 감싸주고, 두 장의 이미지는 <layer-list>로 겹쳐주면 완성됩니다.

<ProgressBar
style="@style/Widget.AppCompat.ProgressBar"
...
android:indeterminateDrawable="@drawable/loading"
android:indeterminateDuration="1000" />
<!-- res/drawable/loading.xml —>
<layer-list>
<item android:gravity="center">
<rotate
android:drawable="@drawable/loading_outer"
android:pivotX="50%"
android:pivotY="50%"
android:fromDegrees="0"
android:toDegrees="360" />
</item>
<item
android:drawable="@drawable/loading_inner"
android:gravity="center" />
</layer-list>

하지만 ProgressBar를 사용하면 컨텐츠가 표시되기 전에는 항상 로딩 UI가 보이게 되는데요. 빈번한 로딩 UI 노출이 사용자에게는 서비스 품질이 떨어지는 것처럼 느껴질 수도 있습니다. 이런 경우, ContentLoadingProgressBar를 사용할 수 있습니다.

Progress

2. Progress

앞에서본 것과 다르게 진행단계를 보여주는 UI입니다.
마찬가지로 ProgressBar와 Drawable을 이용하여, 만들 수 있습니다.
예를 들어, 음악 재생 / 녹음 / 녹화 / 다운로드 등에 사용할 수 있겠죠.
android:useLevel 속성을 사용하는 것이 핵심입니다.

val progressBar: ProgressBar = ...
progressBar.progress = currentProgress
<ProgressBar
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
...
android:indeterminate="false"
android:progressDrawable="@drawable/progress"
android:background="@drawable/progress_background"
android:max="500"
tools:progress="200" />
<!-- res/drawable/progress.xml -->
<rotate android:fromDegrees="270"
android:toDegrees="270">
<shape android:shape="ring"
android:thickness="5dp"
android:useLevel="true">
<solid android:color="@color/colorAccent" />
</shape>
</rotate>
<!-- res/drawable/progress_background.xml -->
<layer-list>
<item>
<shape android:shape="ring"
android:thickness="5dp"
android:useLevel="false">
<solid android:color="@color/white" />
</shape>
</item>
<item android:drawable="@drawable/ic_android"
android:gravity="center" />
</layer-list>
Frame Animation

3. Frame Animation

로딩 UI를 Frame Animation으로 보여줄 수도 있겠죠.
AnimationDrawable을 이용하여 손쉽게 만들 수 있습니다.
다만 Activity 등의 Lifecycle에 따라 시작과 종료를 관리해줘야 합니다.

val drawable: AnimationDrawable = ...
imageView.setImageDrawable(drawable)
drawable.start()
drawable.stop()
<!-- res/drawable/frame_loading.xml -->
<animation-list android:oneshot="false">
<item android:drawable="@drawable/frame_loading_01"
android:duration="500" />
<item android:drawable="@drawable/frame_loading_02"
android:duration="500" />
...
</animation-list>

이 때, AnimatedImageView를 이용하면 XML에 선언하는 형태로 조금 더 편하게 사용할 수 있습니다. AOSP에서는 오래전부터 사용하던 방식입니다. 👀

<com.example.widget.AnimatedImageView
android:src="@drawable/frame_loading" />

Click Animation 👆

AnimatedStateListDrawable / AnimatedVectorDrawable / RippleDrawable / StateListAnimator를 이용하는 방법을 알아봅니다.

AnimatedStateListDrawable + Frame Animation

1. AnimatedStateListDrawable (=ASLD)

View 상태에 따라 Drawable을 보여줄 때, 각 상태 사이에 Animation을 보여주는 방법입니다. Animation의 각 프레임에 해당되는 이미지를 준비해야 하고, 앞서 Loading Animation에서 사용했던 Frame Animation 방식을 이용합니다. 중요한 점은 <animated-selector> 을 이용하여 각 상태 사이의 <transition>을 선언하는 부분입니다. XML 코드 만으로 Animation을 정의할 수 있습니다.

예를 들어, BottomNavigationView, TabLayout에서는 selected 상태를 지원하므로 메뉴 선택 Animation에 사용할 수 있습니다. 또는 checked 상태를 지원하는 커스텀 위젯을 만들어 사용할 수도 있습니다.

imageView.setOnClickListener {
it.isSelected = true // or false
}
<ImageView android:src="@drawable/asld_battery" /><!-- res/drawable/asld_battery.xml -->
<animated-selector>
<item android:id="@+id/selected"
android:drawable="@drawable/ic_battery_100"
android:state_selected="true" />
<item android:id="@+id/unselected"
android:drawable="@drawable/ic_battery_0"
android:state_selected="false" />
<transition android:drawable="@drawable/ad_battery_select"
android:fromId="@id/unselected"
android:toId="@id/selected" />
<transition android:drawable="@drawable/ad_battery_unselect"
android:fromId="@id/selected"
android:toId="@id/unselected" />
</animated-selector>
<!-- res/drawable/ad_battery_select.xml -->
<animation-list android:oneshot="true">
<item android:drawable="@drawable/ic_battery_0"
android:duration="32" />
<item android:drawable="@drawable/ic_battery_20"
android:duration="32" />
...
<item android:drawable="@drawable/ic_battery_100"
android:duration="32" />
</animation-list>
ASLD + AnimatedVectorDrawable

2. ASLD + AnimatedVectorDrawable (=AVD)

Frame Animation을 사용하게 되면 이미지 수가 훨씬 많아져서 관리하는 게 힘들어지고, 앱 용량도 증가합니다. 커다란 이미지가 있다면 메모리 문제도 발생할 수 있구요. 다행히 Frame Animation을 사용하는 대신 VectorDrawable에 Animation을 구현하는 방법도 제공되고 있습니다. 👏

사용방법은 비슷합니다만, Frame Animation 대신 AnimatedVectorDrawable를 구현해줘야 합니다. <animated-vector>로 시작하는 다량의 코드를 작성해줘야 합니다. 뭔가 어렵게 느껴지죠? 🤦‍♂

imageView.setOnClickListener {
it.isSelected = true
}
<ImageView android:src="@drawable/asld_settings" /><!-- res/drawable/asld_settings.xml -->
<animated-selector>
<item android:id="@+id/selected"
android:drawable="@drawable/ic_settings"
android:state_selected="true" />
<item android:id="@+id/unselected"
android:drawable="@drawable/ic_settings"
android:state_selected="false" />
<transition android:drawable="@drawable/avd_settings_select"
android:fromId="@id/unselected"
android:toId="@id/selected" />
</animated-selector>
<!-- res/drawable/avd_settings_select.xml -->
<animated-vector>
<aapt:attr name="android:drawable">
<vector>
<group android:name="gear">
<path android:pathData="M19.43,12.98c0.04,..." />
</group>
</vector>
</aapt:attr>
<target android:name="gear">
<aapt:attr name="android:animation">
<set>
<objectAnimator android:duration="300"
android:propertyName="scaleX"
android:valueFrom="1"
android:valueTo="0.8"
android:valueType="floatType" />
...
<objectAnimator android:startOffset="300"
android:duration="300"
android:propertyName="rotation"
android:valueFrom="0"
android:valueTo="720"
android:valueType="floatType" />
...
</set>
</aapt:attr>
</target>
</animated-vector>

이외에도 PathData 간의 morphing도 지원합니다만.. 정확하게 from/to의 꼭지점이 동일해야만 가능하기 때문에, 디자이너 분들이 이미지를 주더라도 잘 안맞는 경우가 있을 수 있습니다.

Shape Shifter

다행히 AVD 구현의 어려움을 해소할 수 있는 Shape Shifter라는 툴이 있으니, 필요한 분은 한번 사용해보시는 것을 추천드립니다. 😉

RippleDrawable

3. RippleDrawable

Material Design에서는 클릭 효과에 Ripple Animation을 권장하고 있습니다. <ripple>을 사용하면 손쉽게 구현할 수 있구요. mask 기능도 있어서, 상황에 따라 모양을 다르게 할 수도 있습니다.

<TextView android:background="@drawable/ripple" /><!-- res/drawable/ripple.xml -->
<ripple android:color="?colorControlHighlight">
<item android:drawable="@drawable/ripple_mask" />
<item android:id="@android:id/mask"
android:drawable="@drawable/ripple_mask" />
</ripple>
<!-- res/drawable/ripple_mask.xml -->
<shape android:shape="rectangle">
<corners android:radius="8dp" />
<solid android:color="@color/colorAccent" />
</shape>

항상 RippleDrawable을 구현할 필요는 없습니다. 이미 정의된 리소스가 있고, 아래 코드처럼 사용하면 됩니다.

<View android:background="?selectableItemBackground" />
<!— or ?selectableItemBackgroundBorderless —>

내부적으로는 아래와 같은 리소스를 참조하게 됩니다.
물론 Theme에 따라 조금씩 달라집니다. 🤣

<!-- res/drawable/item_background_material.xml -->
<ripple android:color="?attr/colorControlHighlight">
<item android:id="@id/mask">
<color android:color="@color/white" />
</item>
</ripple>
StateListAnimator

4. StateListAnimator

StateListDrawable과 조금 다르게 View 상태 변경에 따라 View 속성 Animation을 보여주는 방법입니다. (drawable이 아닌, animator입니다.)

<TextView android:stateListAnimator="@animator/sla" /><!-- res/animator/sla.xml -->
<selector>
<item android:state_pressed="true">
<objectAnimator android:duration="200"
android:propertyName="backgroundColor"
android:valueFrom="#00F2B2"
android:valueTo="#9900F2B2"
android:valueType="colorType" />
</item>
<item>
<objectAnimator android:duration="200"
android:propertyName="backgroundColor"
android:valueFrom="#9900F2B2"
android:valueTo="#00F2B2"
android:valueType="colorType" />
</item>
</selector>

여기서는 배경색 Animation에 사용했지만, Translation / Scale 처리에 사용하는 경우가 일반적입니다. 예를 들어, Button / FAB 클릭 효과를 구현할 때 주로 사용됩니다.

Transition Animation 🏃

SharedElements / View Animation / TransitionManager를 이용하는 방법을 알아봅니다.

SharedElements

1. SharedElements

Activity 혹은 Fragment 간에 화면을 전환할 때, 콘텐츠가 이어지는 것처럼 보여줄 수 있다면 어떨까요? 프로필 사진을 확대해서 보거나, 이미지 목록형 화면에서 사용하면 적합하겠죠.

가장 쉽게 사용하는 방법은 From/To가 되는 View에 동일한 transitionName을 설정해두고, startActivity()를 호출할 때 SceneTransitionAnimation 정보를 전달하는 것입니다.

<!-- profile_item_photo.xml -->
<ImageView android:transitionName="photo" />
<!-- viewer_activity.xml -->
<ImageView android:transitionName="photo" />
// ProfileActivity.kt
val intent = Intent(this, ViewerActivity::class.java)
...
ActivityCompat.startActivity(
this, intent,
ActivityOptionsCompat
.makeSceneTransitionAnimation(
this, view, view.transitionName
)
.toBundle()
)

그리고나서 어떤 Transition이 일어나야 할지 정의해줘야 합니다. 여기서는 From/To 간에 곡선 이동을 하도록 ArcMotion을 사용해봤습니다. 약간의 bounce가 느껴지게 interpolator도 적절히 정해주면 완성됩니다.

// ViewerActivity.ktoverride fun onCreate(savedInstanceState: Bundle?) {
window.sharedElementEnterTransition =
TransitionSet().apply {
interpolator = OvershootInterpolator(0.7f)
ordering = TransitionSet.ORDERING_TOGETHER
addTransition(ChangeBounds().apply {
pathMotion = ArcMotion()
})
addTransition(ChangeTransform())
addTransition(ChangeClipBounds())
addTransition(ChangeImageTransform())
}
super.onCreate(savedInstanceState)
}

시간관계상 여기서는 세세한 부분들을 다루지 않았는데요. Transition도 미세하게 조정하려면 굉장히 많은 시간들이 들어갈 수 있는 부분입니다. 관심있는 분들은 직접 관련 코드를 찾아보시는 것이 좋을 것 같아요. 👀

View Animation

2. View Animation

SharedElements는 각 요소를 이어주는 Seamless UX에 해당된다면, 서로 완전히 다른 화면이라서 단순한 Animation이 필요한 경우도 있겠죠. 이럴 때 View Animation을 사용하면 됩니다. Activity에는 기본으로 정의되어 있어서 고민이 필요없을 수도 있지만, Fragment에는 지원하는 것을 고려해볼 만합니다.

위의 예제는 아래처럼 구현할 수 있습니다.

// In Activity
activity.overridePendingTransition(
R.anim.slide_in_right, // enterAnim
R.anim.slide_out_left // exitAnim
)
// In Fragment
fragmentManager.beginTransaction()
.setCustomAnimations(
R.anim.slide_in_right, // enterAnim
R.anim.slide_out_left, // exitAnim
)
.replace(...)
.commit()

<!-- res/anim/slide_in_right.xml -->
<translate
android:duration="@android:integer/config_mediumAnimTime"
android:interpolator="@anim/spring_interpolator"
android:fromXDelta="100%"
android:toXDelta="0" />
<!-- res/anim/slide_out_left.xml -->
<translate
android:duration="@android:integer/config_mediumAnimTime"
android:interpolator="@anim/spring_interpolator"
android:fromXDelta="0"
android:toXDelta="-100%" />
<!-- res/anim/spring_interpolator.xml -->
<overshootInterpolator android:tension="0.7" />

View Animation은 scale / rotate / alpha / translate 기능을 제공하고, 하나만 사용하거나 각각을 조합하여 사용할 수도 있습니다.

<set>
<scale />
<rotate />
<alpha />
<translate />
<set />
</set>
TransitionManager

3. TransitionManager

사진 편집 모드나 Foldable 기기처럼, 상태에 따라 Layout이 변경되어야 하는 경우에는 TransitionManager를 이용할 수 있습니다. 사용방법은 간단합니다. beginDelayedTransition을 호출한 후, 레이아웃을 변경해주면 됩니다.

해당 화면에 ConstraintLayout을 사용하고 있다면, 레이아웃 전체를 변경하는 것보다는 Guideline와 같은 Helper를 이용하는 것이 비교적 편리합니다.

val layout = binding.root
val constraintSet = ConstraintSet().apply {
clone(layout)
if (fold) {
setGuidelinePercent(R.id.fold_guideline, 0.5f)
} else {
setGuidelinePercent(R.id.fold_guideline, 1f)
}
}
TransitionManager.beginDelayedTransition(layout)
constraintSet.applyTo(layout)

이상으로 간단하고 누구나 적용하기 쉬운 방법들을 살펴봤습니다.
본 내용의 발표자료와 영상 링크는 아래 더보기 🔍 부분을 참고해주세요. 🙇

이외에도 다양한 API가 있는데요. 어떤 API가 있는지 궁금하신 분들은 Android Animation 11% 더 활용하기 슬라이드를 보셔도 좋을 듯 합니다.

그리고 Animation에 관심이 생긴 분들께는 Google I/O ’19의 Motional Intelligence: Build Smarter Animations 세션 시청을 권해드리고 싶습니다. MVVM과 같은 Reactive World에서 Animation을 구현하는 도구와 기법들을 소개하고 있습니다. 💯 💯 💯

--

--