우리가 매일 보는 kakaoTalk이나 youtube은 화면전환 시 기존에 있던 화면이 상태가 유지되는 것을 볼 수 있다.
어떤 방식으로 유지되는 걸까?
이 포스트에서는 Android Component인 Navigration을 사용하여 구현하는 방법을 아래 내용으로 다룬다.
navigation 2.6.0 버전 이상부터 FragmentTransaction을 수동으로 실행하면 에러가 발생한다. 해당 포스팅을 이용해서 구현할 때 navigation 2.6.0 이상 버전을 사용하면 에러가 발생한다. navigation 버전을 2.6.0 이하 버전을 사용하여야 한다. 다른 방법을 사용하는 방법은 추후 추가하도록 하겠다.
미리 탭으로 전환될 세 개의 fragment(FirstFragment, SecondFragment, ThirdFragment)와 FirstFragment에서 특정 버튼을 클릭하면 이동할 fragment(BookDetailFragment)를 생성한다.
우선 첫번째로 build.gradle (:app)에 navigation 의존성을 추가한다.
dependencies {
...
implementation 'androidx.navigation:navigation-fragment-ktx:2.4.2'
implementation 'androidx.navigation:navigation-ui-ktx:2.4.2'
}
FragmentNavigator를 상속받는 클래스를 생성한다. 파일 위치는 app/java/패키지 하위에 위치한다.
이 클래스의 역할은 fragment의 전환과 상태유지를 담당한다. fragment 최초 생성 시 클래스를 생성 후 FragmentManager에 더해주고, 이미 생성되어있는 fragment는 보여준다.
/app/java/패키지/KeepStateFragment.kt
package com.crystal.keepstatefragment.utils
import android.content.Context
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import androidx.navigation.NavDestination
import androidx.navigation.NavOptions
import androidx.navigation.Navigator
import androidx.navigation.fragment.FragmentNavigator
@Navigator.Name("keep_state_fragment")
class KeepStateFragment(
private val context: Context,
private val manager: FragmentManager,
private val containerId: Int
): FragmentNavigator(context, manager, containerId) {
override fun navigate(
destination: Destination,
args: Bundle?,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
): NavDestination? {
val tag = destination.id.toString()
val transaction = manager.beginTransaction()
var initialNavigate = false
val currentFragment = manager.primaryNavigationFragment
if (currentFragment != null) {
transaction.hide(currentFragment)
} else {
initialNavigate = true
}
var fragment = manager.findFragmentByTag(tag)
if (fragment == null) {
// add로 fragment 최초 생성 (add)
val className = destination.className
fragment = manager.fragmentFactory.instantiate(context.classLoader, className)
transaction.add(containerId, fragment, tag)
} else {
transaction.show(fragment)
}
// destination fragment를 primary로 설정
transaction.setPrimaryNavigationFragment(fragment)
// transaction 관련 fragment 상태 변경 최적화
transaction.setReorderingAllowed(true)
transaction.commitNow()
return if (initialNavigate) {
destination
} else {
null
}
}
}
fragment 전환 시 사용할 menu를 만든다. 파일 위치는 /app/res/menu/main_navi_menu.xml이고 menu 디렉토리가 없다면 아래 방법으로 디렉토리 생성 후 파일 만들어주면 된다.
탭으로 화면전환하고 싶은 fragment 수 만큼 넣어주시면 됩니다. item의 id, title, icon은 아무거나 지어준다.
여기서는 세 개의 fragment인 FirstFragment, SecondFragment, ThirdFragment를 탭으로 화면전환 할거라서 이 세 개를 item으로 넣었다.
디렉토리 생성 방법
res 마우스 우클릭 -> New -> Android Resource Directory -> Resource Type -> mene 선택 -> OK
/app/res/menu/main_navi_menu.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/first_fragment"
android:title="first_fragment"
android:icon="@drawable/ic_menu" />
<item
android:id="@+id/second_fragment"
android:title="second_fragment"
android:icon="@drawable/ic_message" />
<item
android:id="@+id/third_fragment"
android:title="third_fragment"
android:icon="@drawable/ic_my" />
</menu>
layout은 data-binding을 걸기위해 넣은 거라 빼도 무방하다.
FragmentContainerView를 추가하시고 필수적으로 android:name="" 을 지정해야 한다. 아니면 navHostFragment 설정할 때 찾지를 못한다..
위에서 만든 main_navi_menu.xml을 BottomNavigationView의 app:menu로 지정한다.
<?xml version="1.0" encoding="utf-8"?>
<layout>
<RelativeLayout 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"
tools:context=".MainActivity">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@+id/main_navi"
app:defaultNavHost="true" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/main_navi"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
app:itemBackground="@color/yellow"
app:itemIconTint="@color/black"
app:labelVisibilityMode="unlabeled"
app:menu="@menu/main_navi_menu" />
</RelativeLayout>
</layout>
Navigation의 지도 역할을 하는 파일이다. Fragment 전환을 위해서는 이 파일에 생성한 Fragment를 등록한다.
nav_graph.xml 파일은 navigation 디렉토리에 생성해야 한다. navigation 디렉토리가 없다면 디렉토리 먼저 생성 후 파일을 만든다.
디렉토리 생성 방법
res 마우스 우클릭 -> New -> Android Resource Directory -> Resource Type -> navigation 선택 -> OK
app/res/navigation/nav_graph.xml
<?xml version="1.0" encoding="utf-8"?>
<navigation 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"
app:startDestination="@id/first_fragment"
android:id="@+id/nav_graph">
<keep_state_fragment
android:id="@+id/first_fragment"
android:name="com.crystal.keepstatefragment.fragments.FirstFragment"
app:popUpToSaveState="true"
tools:layout="@layout/fragment_first"
android:label="FirstFragment" >
<action
android:id="@+id/action_first_to_detail"
app:destination="@+id/book_detail_fragment"/>
</keep_state_fragment>
<keep_state_fragment
android:id="@+id/second_fragment"
android:name="com.crystal.keepstatefragment.fragments.SecondFragment"
app:popUpToSaveState="true"
tools:layout="@layout/fragment_second"
android:label="SecondFragment" />
<keep_state_fragment
android:id="@+id/third_fragment"
android:name="com.crystal.keepstatefragment.fragments.ThirdFragment"
app:popUpToSaveState="true"
tools:layout="@layout/fragment_third"
android:label="ThirdFragment" />
<keep_state_fragment
android:id="@+id/book_detail_fragment"
android:name="com.crystal.keepstatefragment.fragments.BookDetailFragment"
android:label="BookDetailFragment" />
</navigation>
activity_main.xml의 BottomNavigation의 지도(nav_graph.xml)을 설정해주고 KeepStateFragment로 만들어주는 과정이다.
package com.crystal.keepstatefragment
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.databinding.DataBindingUtil
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import com.crystal.keepstatefragment.databinding.ActivityMainBinding
import com.crystal.keepstatefragment.utils.KeepStateFragment
class MainActivity : AppCompatActivity() {
// MainActivity Data Binding
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
setNavigation()
}
// navigation 설정
private fun setNavigation() {
val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val navController = navHostFragment.navController
val navigator = KeepStateFragment(this, navHostFragment.childFragmentManager, R.id.nav_host_fragment)
navController.navigatorProvider.addNavigator(navigator)
navController.setGraph(R.navigation.nav_graph)
// MainActivity의 main_navi와 navController 연결
binding.mainNavi.setupWithNavController(navController)
}
}
BottomNavigationView으로 이동하는 것이 아닌 버튼 클릭 시 특정 fragment로 이동하는 것이다. 필요없을 시 생략가능.
FirstFragment는 도서 목록을 띄우는 fragment이고 여기서 목록에 있는 아이템을 클릭하면 아이템에 대한 내용을 보여주는 BookDetailFragment으로 이동.
<?xml version="1.0" encoding="utf-8"?>
<layout>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- 도서 목록을 띄울 RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/book_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</layout>
BookAdapter는 RecyclerView의 Adapter로 RecyclerView를 사용하지 않다면 onItemClick() 내용만 참고.
package com.crystal.keepstatefragment.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.crystal.keepstatefragment.R
import com.crystal.keepstatefragment.adapters.BookAdapter
import com.crystal.keepstatefragment.databinding.FragmentFirstBinding
class FirstFragment: Fragment() {
private lateinit var binding: FragmentFirstBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_first, container, false)
// data binding
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setRecyclerView()
}
private fun setRecyclerView() {
binding.bookRecyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.bookRecyclerView.addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
val adapter = BookAdapter(requireContext(), resources.getStringArray(R.array.book_list))
// BookAdapter 인터페이스로 전달받은 아이템의 BookTitle을 번들로 저장하여 BookDetailFragment로 이동
adapter.setOnItemClickListener(object : BookAdapter.OnItemClickListener {
override fun onItemClick(book: String) {
val bundle = bundleOf("bookTitle" to book)
findNavController().navigate(R.id.action_first_to_detail, bundle)
}
})
binding.bookRecyclerView.adapter = adapter
}
}
<?xml version="1.0" encoding="utf-8"?>
<layout>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:gravity="center"
android:orientation="vertical"
android:layout_height="match_parent">
<TextView
android:id="@+id/book_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="25sp"
android:layout_marginBottom="20dp"
android:textColor="@color/black" />
<ImageView
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@drawable/ic_menu" />
</LinearLayout>
</layout>
onCreate()에서 arguments로 저장한 값을 변수에 옮겨담으면 된다.
package com.crystal.keepstatefragment.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import com.crystal.keepstatefragment.R
import com.crystal.keepstatefragment.databinding.FragmentBookDetailBinding
class BookDetailFragment: Fragment() {
private lateinit var binding: FragmentBookDetailBinding
private var bookTitle: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// bundle에서 bookTitle 값 가져오기
bookTitle = arguments?.getString("bookTitle")
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_book_detail, container, false)
binding.bookTextView.text = bookTitle
return binding.root
}
}
package com.crystal.keepstatefragment.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.crystal.keepstatefragment.R
class BookAdapter(
private val context: Context,
private val list: Array<String>,
): RecyclerView.Adapter<BookAdapter.ViewHolder>(){
// FirstFragment에서 처리하기 위해 인터페이스 생성
interface OnItemClickListener {
fun onItemClick(book: String)
}
private var listener: OnItemClickListener? = null
fun setOnItemClickListener(listener: OnItemClickListener) {
this.listener = listener
}
inner class ViewHolder(view: View): RecyclerView.ViewHolder(view) {
private val bookTextView: TextView = itemView.findViewById(R.id.book_text_view)
fun bind(book: String) {
bookTextView.text = book
// 아이템 클릭 시 listener로 클릭된 아이템(book: String) 전달
itemView.setOnClickListener {
listener?.onItemClick(book)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.book_list_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val book = list[position]
holder.bind(book)
}
override fun getItemCount() = list.size
}
다음엔 특정 화면으로 전환할 때 Safe args를 쓰는 방법을 적용해봐야겠다.