우리가 자주 사용하는 앱에는 알게 모르게 많은 애니메이션들이 들어가서 스크롤이나 화면 전환 등을 더욱 부드럽게 해줍니다. 카카오뱅크에 있는 애니메이션이 간단해보여서 한 번 따라 해보았는데 생각보다 간단하진 않았습니다... 역시 개발자들 리스펙..
애니메이션 | 카카오뱅크 | 따라만든 것 |
---|---|---|
앱바 애니메이션 | ||
스크롤 애니메이션 |
안드로이드에서 다음과 같은 방법으로 애니메이션 처리를 할 수 있습니다.
위에 카카오뱅크 애니메이션은 2018년에 발표한 애니메이션 툴인 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:id="@+id/motionlayout"
app:layoutDescription="@xml/motion_scene2"
app:layout_scrollFlags="scroll|enterAlwaysCollapsed|exitUntilCollapsed"
tools:context=".appbar_anim.MotionActivity">
<androidx.core.widget.NestedScrollView
android:id="@+id/nested_scrollview"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/linearlayout"
app:layout_constraintBottom_toBottomOf="parent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/kakao_bank_txt" />
</androidx.core.widget.NestedScrollView>
<LinearLayout
android:id="@+id/linearlayout"
android:layout_width="match_parent"
android:layout_height="80dp"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent"
android:background="@color/white"
/>
<TextView
android:id="@+id/name_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:autoSizeTextType="uniform"
android:text="최은정"
android:textStyle="bold"
android:textSize="17sp"
android:textColor="@color/black"
app:layout_constraintBottom_toBottomOf="@id/linearlayout"
app:layout_constraintTop_toTopOf="@id/linearlayout"
app:layout_constraintEnd_toEndOf="@id/linearlayout"
app:layout_constraintHorizontal_bias="0.1"
app:layout_constraintStart_toStartOf="@id/linearlayout"/>
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btn"
android:layout_width="65dp"
android:layout_height="32dp"
android:text="내 계좌"
android:textSize="13sp"
android:layout_marginStart="20dp"
android:background="@drawable/background_btn"
app:layout_constraintTop_toTopOf="@id/name_tv"
app:layout_constraintBottom_toBottomOf="@id/name_tv"
app:layout_constraintStart_toEndOf="@id/name_tv"/>
<ImageView
android:id="@+id/profile_iv"
android:layout_width="40dp"
android:layout_height="40dp"
app:layout_constraintBottom_toBottomOf="@id/linearlayout"
app:layout_constraintEnd_toEndOf="@id/linearlayout"
app:layout_constraintHorizontal_bias="0.9"
app:layout_constraintStart_toStartOf="@id/linearlayout"
app:layout_constraintTop_toTopOf="@id/linearlayout"
app:srcCompat="@drawable/curby" />
</androidx.constraintlayout.motion.widget.MotionLayout>
우선 앱바 전체가 크기가 변하기 때문에 부모 layout 을 MotionLayout 으로 합니다.
그리고 nestedScrollView 를 제외한 부분이 스크롤에 따라서 움직이기 때문에
이것에 대한 MotionScene xml 파일을 작성해서 MotionLayout 의 layoutDescription 속성으로 연결해주어야 합니다.
MotionLayout 은 ConstraintLayout 의 하위 클래스이기 때문에 constraint 를 지정해서 위치를 잡아주면 됩니다. 또한 MotionLayout은 자신을 포함해서 direct child 는 반드시 id 를 가지고 있어야 합니다.
참고로 안드로이드 스튜디오 layout design 탭에서 보이는 디자인은 end 애니메이션을 보여줍니다.
motion_scene2.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/collapsed"
motion:constraintSetStart="@+id/expanded"
motion:duration="1000"
motion:motionInterpolator="linear">
<OnSwipe
motion:dragDirection="dragUp"
motion:touchAnchorId="@id/nested_scrollview"
motion:touchAnchorSide="top" />
</Transition>
<ConstraintSet android:id="@+id/expanded">
<Constraint
android:id="@+id/linearlayout"
android:layout_width="match_parent"
android:layout_height="80dp"
android:orientation="vertical"
motion:layout_constraintTop_toTopOf="parent"
android:background="@color/white"
/>
<Constraint
android:id="@+id/name_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="최은정"
android:scaleX="1.3"
android:scaleY="1.3"
android:textColor="@color/black"
motion:layout_constraintBottom_toBottomOf="@id/linearlayout"
motion:layout_constraintTop_toTopOf="@id/linearlayout"
motion:layout_constraintEnd_toEndOf="@id/linearlayout"
motion:layout_constraintHorizontal_bias="0.1"
motion:layout_constraintStart_toStartOf="@id/linearlayout"/>
<Constraint
android:id="@+id/btn"
android:layout_width="65dp"
android:layout_height="32dp"
android:text="내 계좌"
android:textSize="13sp"
android:layout_marginStart="20dp"
android:background="@drawable/background_btn"
motion:layout_constraintTop_toTopOf="@id/name_tv"
motion:layout_constraintBottom_toBottomOf="@id/name_tv"
motion:layout_constraintStart_toEndOf="@id/name_tv"/>
<Constraint
android:id="@+id/profile_iv"
android:layout_width="40dp"
android:layout_height="40dp"
motion:layout_constraintBottom_toBottomOf="@id/linearlayout"
motion:layout_constraintEnd_toEndOf="@id/linearlayout"
motion:layout_constraintHorizontal_bias="0.9"
motion:layout_constraintStart_toStartOf="@id/linearlayout"
motion:layout_constraintTop_toTopOf="@id/linearlayout"
motion:srcCompat="@drawable/curby" />
</ConstraintSet>
<ConstraintSet android:id="@+id/collapsed">
<Constraint
android:id="@+id/linearlayout"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:orientation="vertical"
android:elevation="10dp"
motion:layout_constraintTop_toTopOf="parent"
android:background="@color/white"
/>
<Constraint
android:id="@id/name_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleY="1.0"
android:scaleX="1.0"
android:elevation="10dp"
motion:layout_constraintTop_toTopOf="@id/linearlayout"
motion:layout_constraintEnd_toEndOf="@id/linearlayout"
motion:layout_constraintHorizontal_bias="0.05"
motion:layout_constraintBottom_toBottomOf="@id/linearlayout"
motion:layout_constraintStart_toStartOf="@id/linearlayout" />
<Constraint
android:id="@+id/btn"
android:layout_width="65dp"
android:layout_height="32dp"
android:text="내 계좌"
android:textSize="13sp"
android:elevation="10dp"
android:layout_marginStart="20dp"
android:background="@drawable/background_btn"
motion:layout_constraintTop_toTopOf="@id/name_tv"
motion:layout_constraintBottom_toBottomOf="@id/name_tv"
motion:layout_constraintStart_toEndOf="@id/name_tv"/>
<Constraint
android:id="@id/profile_iv"
android:layout_width="30dp"
android:layout_height="30dp"
android:elevation="10dp"
motion:layout_constraintStart_toStartOf="@id/linearlayout"
motion:layout_constraintHorizontal_bias="0.95"
motion:layout_constraintBottom_toBottomOf="@id/linearlayout"
motion:layout_constraintEnd_toEndOf="@id/linearlayout"
motion:layout_constraintTop_toTopOf="@id/linearlayout" />
</ConstraintSet>
</MotionScene>
애니메이션을 직접적으로 만드는 부분이 바로 이 MotionScene 입니다.
MotionScene에는 반드시 하나 이상의 Transition이 있어야 합니다. 이는 애니메이션의 시작(start)과 끝(end) ConstraintSet 을 정의하고, duration 등의 기타 속성들을 정의합니다. 애니메이션 시작을 OnClick 과 OnSwipe 핸들러로 정의할 수 있으며, KeyFrameSet 을 통해 애니메이션의 시작과 끝 뿐만 아니라 그 사이 지점들의 Constraint 를 정의할 수 있습니다. 여기서는 onSwipe 을 통해 nestedScrollView 가 스크롤 될 때 애니메이션이 시작될 수 있도록 하였습니다.
코드에서 id 가 expanded(start) 로 정의된 ConstraintSet 태그 안에는 앱바가 늘어났을 때의 뷰들의 상태를 지정합니다. 여기서 linearLayout 의 height 가 80dp 인 것에 주목해주세요.
그리고 collapsed(end) ConstraintSet 은 앱바가 줄어들었을 때의 상태를 정의합니다. 여기서는 linearLayout 의 height 를 actionBarSize 로 하였습니다. 이렇게 애니메이션의 시작과 끝 상태를 지정하면 똑똑한 MotionLayout 이 계산해서 부드러운 애니메이션을 만들어준답니다.
추가적으로 이름 부분이 앱바가 늘어났을 때 오른쪽으로 조금 이동하고, 프로필 사진은 왼쪽으로 이동해서 안쪽으로 모아지게 되는데요,
이러한 애니메이션 같은 경우는, layout_constraintHorizontal_bias 를 조절해서 구현하였고, 크기 변화같은 경우는 이름 textView는 scaleX, scaleY 크기를 조절했습니다. 매우 간단하죠?
자료 조사를 통해서 저는 이 애니메이션을 3가지 방법으로 구현 해보았습니다. 그런데 소개드린 방법이 가장 간단명료한 방법이라 생각이 들어 이 방법을 공유하게 되었습니다.
앱바 애니메이션이기 때문에 AppBarLayout 안에 MotionLayout 을 사용할 수도 있긴 한데요, 제가 이렇게 해보려고 했을 때는 앱바가 크기가 줄어들었을 때는 constraint 로 가운데 정렬이 되지 않는 문제가 있었습니다. 그래서 bottom 에만 constraint 를 주고 margin_bottom 으로 정렬 유사하게 구현해야 되었기 때문에 이 방법을 선택하지 않았습니다.
그런데 이렇게 되면 AppBarLayout 의 liftOnScroll 속성을 사용할 수 없어서 스크롤을 위로 했을 때 앱바 아래 부분에 그림자가 생기는 것을 구현할 수 없게 되는데요,
저는 이를 collapsed 상태에서 elevation 속성을 주어서 해결했습니다.
<?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/motion_scene_tabscroll"
app:layout_scrollFlags="scroll"
tools:context=".tab_scroll_anim.TabScrollActivity">
<androidx.core.widget.NestedScrollView
android:id="@+id/nested_scrollview"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginVertical="12dp"
app:layout_constraintTop_toBottomOf="@id/tablayout"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginHorizontal="12dp"
>
<ImageView
android:id="@+id/viewpager_iv"
android:layout_width="match_parent"
android:layout_height="220dp"
android:src="@drawable/curby_full"
android:scaleType="centerCrop"
app:layout_constraintTop_toTopOf="parent"/>
<TextView
android:id="@+id/deposit_tv"
android:layout_width="match_parent"
android:layout_height="480dp"
android:text="예금,적금"
android:textSize="16sp"
android:textColor="@color/black"
android:fontFamily="@font/roboto_bold"
android:paddingTop="40dp"
android:layout_marginTop="10dp"
android:background="@color/purple_200"
app:layout_constraintTop_toBottomOf="@id/viewpager_iv"/>
...
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@color/white"
app:layout_constraintTop_toTopOf="@id/title_tv"
app:layout_constraintBottom_toBottomOf="@id/tablayout"
android:id="@+id/linearlayout_shadow"
android:orientation="horizontal" />
<TextView
android:id="@+id/title_tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="13dp"
android:paddingTop="25dp"
android:text="상품/서비스"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tablayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="15dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:scrollIndicators="bottom"
app:layout_constraintTop_toBottomOf="@id/title_tv"
app:tabIndicatorColor="@color/black"
app:tabTextAppearance="@style/myCustomTabText"
app:tabRippleColor="@android:color/transparent"
app:tabIndicatorFullWidth="false"
app:tabMode="auto"
app:tabMinWidth="22dp"
app:tabSelectedTextColor="@color/black"
app:tabTextColor="@color/gray">
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/purple_200"
android:text="전체" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/purple_200"
android:text="예적금" />
...
</com.google.android.material.tabs.TabLayout>
</androidx.constraintlayout.motion.widget.MotionLayout>
앱바 애니메이션과 유사하게 전체를 MotionLayout 으로 감싸고 nestedScrollView 는 대충.. scrollTo 를 확인할 수 있도록 색으로 각 부분을 표시했습니다... ㅎ
그리고 상단을 linearlayout, textview, tablayout 으로 구성했습니다.
motion_scene_tabscroll
<?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/collapsed"
motion:constraintSetStart="@+id/expanded"
motion:duration="10"
motion:motionInterpolator="linear" >
<OnSwipe
motion:dragDirection="dragUp"
motion:touchAnchorId="@id/nested_scrollview"
motion:touchAnchorSide="top" />
</Transition>
<ConstraintSet android:id="@+id/expanded">
<Constraint
android:id="@+id/title_tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
motion:layout_constraintTop_toTopOf="parent" />
<Constraint
android:id="@+id/tablayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
motion:layout_constraintTop_toBottomOf="@id/title_tv" />
<Constraint
android:id="@+id/linearlayout_shadow"
android:layout_width="match_parent"
android:layout_height="0dp"
motion:layout_constraintTop_toTopOf="@id/title_tv"
motion:layout_constraintBottom_toBottomOf="@id/tablayout" />
</ConstraintSet>
<ConstraintSet android:id="@+id/collapsed">
<Constraint
android:id="@+id/title_tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="5dp"
motion:layout_constraintBottom_toTopOf="@id/tablayout" />
<Constraint
android:id="@+id/tablayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="5dp"
motion:layout_constraintTop_toTopOf="parent" />
<Constraint
android:id="@+id/linearlayout_shadow"
android:layout_width="match_parent"
android:layout_height="0dp"
android:elevation="5dp"
motion:layout_constraintTop_toTopOf="@id/title_tv"
motion:layout_constraintBottom_toBottomOf="@id/tablayout" />
</ConstraintSet>
</MotionScene>
motionScene 부분에서 살짝 머리를 좀 썼는데요, title_tv 만 스크롤시 위로 사라지고, tablayout 은 sticky scrollview 처럼 위에 착 딸라붙어야 합니다.
이를 motionLayout 으로 구현하기 위해서는 title_tv 를 collapsed 시에 height 를 0dp 로 만들거나 visibility 를 gone 으로 하면 위로 스크롤과 동시에 tablayout 이 title_tv 위로 올라와서 하나도 똑같아지지가 않습니다.
그래서 저는 대신에 text_tv 의 constraint 를 layout_constraintTop_toTopOf="parent" 에서 layout_constraintBottom_toTopOf="@id/tablayout" 으로 바꾸고,
tablayout 은 layout_constraintTop_toBottomOf="@id/title_tv" 에서 layout_constraintTop_toTopOf="parent" 로 바꿨습니다.
즉, title_tv 가 tablayout 위쪽에 붙도록 해서 tablayout 이 천장에 달라붙을 때 위로 사라지는 것 같은 효과를 낸 것입니다.
그리고 AppBarLayout 의 liftOnScroll 그림자를 표현하기 위해 collapsed 상태의 모든 뷰에게 elevation 을 주었습니다.
class TabScrollActivity : AppCompatActivity(), TabLayout.OnTabSelectedListener,
View.OnScrollChangeListener {
private lateinit var binding: ActivityTabScrollBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityTabScrollBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.tablayout.addOnTabSelectedListener(this)
binding.nestedScrollview.setOnScrollChangeListener(this)
}
override fun onTabSelected(tab: TabLayout.Tab?) {
when (tab?.position) {
0 -> binding.nestedScrollview.scrollToView(binding.viewpagerIv) //전체
1 -> binding.nestedScrollview.scrollToView(binding.depositTv) //예적금
2 -> binding.nestedScrollview.scrollToView(binding.loanTv) //대출
3 -> binding.nestedScrollview.scrollToView(binding.serviceTv) //서비스
4 -> binding.nestedScrollview.scrollToView(binding.partnershipTv) //제휴
5 -> binding.nestedScrollview.scrollToView(binding.miniTv) //mini
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
override fun onTabReselected(tab: TabLayout.Tab?) = Unit
override fun onScrollChange(p0: View?, p1: Int, scrollY: Int, p3: Int, p4: Int) {
if (scrollY > 0) binding.motionlayout.transitionToEnd()
when {
scrollY == 0
-> {
binding.tablayout.setScrollPosition(0, 0f, true)
binding.motionlayout.transitionToStart()
}
scrollY >= binding.nestedScrollview.computeDistanceToView(binding.depositTv) &&
scrollY < binding.nestedScrollview.computeDistanceToView(binding.loanTv)
-> binding.tablayout.setScrollPosition(1, 0f, true)
scrollY >= binding.nestedScrollview.computeDistanceToView(binding.loanTv) &&
scrollY < binding.nestedScrollview.computeDistanceToView(binding.serviceTv)
-> binding.tablayout.setScrollPosition(2, 0f, true)
scrollY >= binding.nestedScrollview.computeDistanceToView(binding.serviceTv) &&
scrollY < binding.nestedScrollview.computeDistanceToView(binding.partnershipTv)
-> binding.tablayout.setScrollPosition(3, 0f, true)
scrollY >= binding.nestedScrollview.computeDistanceToView(binding.partnershipTv) &&
scrollY < binding.nestedScrollview.computeDistanceToView(binding.miniTv)
-> binding.tablayout.setScrollPosition(4, 0f, true)
}
if(!binding.nestedScrollview.canScrollVertically(1)) binding.tablayout.setScrollPosition(5, 0f, true)
}
}
이제 남은 것은 각 탭을 클릭했을 때 해당 내용으로 scroll 되는 것과 scroll 시 해당 탭이 선택되는 로직을 짜는 부분입니다.
참고로 탭 클릭시 해당 내용으로 scroll 되는 로직은 https://greedy0110.tistory.com/41 의 코드를 활용했으니 상세한 설명은 여기를 참고해주시면 감사하겠습니다!
onTabSelected 에서 탭의 index 에 따라 scrollToView 확장함수로 해당 내용으로 scroll 합니다. 그리고 onScrollChange 에서는 scrollY (스크롤이 된 길이) 가 0 이상일 때, 즉 스크롤이 되기 시작했을 때, motionScene 의 collapsed (end) 가 작동하도록 합니다.
0일 때(스크롤이 맨 위로 왔을 때, 초기상태) 는 당연히 expanded(start) 상태가 시작되도록 하고, tablayout 의 scrollPosition 을 '전체' 탭에 가있도록 index 를 넣어줍니다.
이때, 직접적으로 binding.tablayout.getTabAt(0)?.select()을 사용하지 않고 setScrollPositon을 사용하는 이유는 위에 onTabSelected 와 충돌하기 때문입니다.
예를 들어서, scrollY가 전체의 top 과 예적금 top 사이에 있을 때, getTabAt(0)?.select() 을 해서 전체 탭을 select 하게 하면 onTabSelected 에 따라 바로 전체의 top 으로 이동하게 됩니다. 그래서 갑자기 순간이동을 하게 되는 불상사가 발생하게 됩니다. 따라서 setScrollPositon 을 사용해서 실제로 탭이 select 되지는 않았지만 무늬만 선택된 것처럼 색상 변경만 해주는 것입니다.
처음으로 모션 레이아웃을 사용해보았는데요, 생각보다 간단하게 애니메이션을 만들 수 있음에 놀랐고, 한편으로 내부 코드는 얼마나 복잡할까.. 싶기도 했습니다ㅋ. 카카오뱅크 애니메이션과 유사하게 만들긴 했지만, 스크롤 애니메이션 같은 경우에는 약간의 차이가 있어서 아마도 카카오에서는 뷰를 커스텀해서 만들었을 가능성이 커보입니다. 더 공부해보고 더 똑같이 만들어 보도록 하겠습니다!ㅎㅎ
https://medium.com/gomechanic/android-tab-change-recycler-scroll-in-kotlin-17cd05e88c9b
https://greedy0110.tistory.com/41
https://blog.gangnamunni.com/post/MotionLayout/
https://blog.stylingandroid.com/motionlayout-collapsing-toolbar-part-1/
https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=pistolcaffe&logNo=221016672922
https://new-spring-bom.tistory.com/6
https://freehoon.tistory.com/38
https://gist.github.com/mikkipastel/bae0b446f4cffb5b637bc86036d34743
https://blog.gangnamunni.com/post/MotionLayout/
https://gamjatwigim.tistory.com/82
https://www.charlezz.com/?p=717