Android 공부 (3)

백상휘·2025년 10월 5일
0

프래그먼트는 액티비티의 일부 영역을 얘기한다. 프래그먼트 사용법을 알아보고 액티비티 안에서 어떻게 관리하는지 알아본다. 프래그먼트 추가 및 정적/동적 프로그래먼트의 차이도 알아본다.

액티비티와 다르게 프래그먼트는 dual-pane layout 을 사용해 태블릿 레이아웃을 생성할 수도 있다. dual 이기 때문에 최소 2개의 프래그먼트가 필요한데 이는 폰에서 사용하는 프래그먼트를 재활용할 수 있다.

여기서 다루는 내용은 아래와 같다.

  • 프래그먼트 생명주기
  • 정적 프래그먼트와 듀얼 패인 레이아웃
  • 동적 프래그먼트
  • 젯팩 Navigation

프래그먼트 생명주기

프래그먼트도 자체 생명주기가 있다(액티비티 생명주기와 매우 비슷). 그리고 액티비티 생명주기에 종속된다.

프래그먼트 추가 -> onAttach -> onCreate -> onCreateView -> onViewCreated -> onActivityCreated -> onStart -> onResume -> 프래그먼트 실행 -> onPause (앱 백그라운드 전환 시 onResume 으로 돌아감) -> onStop -> onDestroyView -> (프래그먼트 제거/대체 또는 앱 종료) -> onDestroy -> onDetachView -> 프래그먼트 제거

  • onAttach : 액티비티와 연결된 직후
  • onCreate : 프래그먼트 초기화 작업. UI 는 아님. setContentView 사용 못함.
  • onCreateView : 레이아웃 생성 가능. 반환 타입이 View 이므로 실제 View 를 생성해서 반환해야 함. 레이아웃 내 뷰를 참조하려면 우선 생성해야 한다.
  • onViewCreated : 프래그먼트가 사용자에게 표시되기 전에 호출된다. 뷰의 기능과 상호작용 추가.
  • onActivityCreated : 액티비티 onCreate 이후 실행된다. 2번째 초기화 설정하는 함수
  • onStart : onViewCreated 처럼 사용자에게 뷰가 보이기 전 호출. 아직은 상호작용 불가함.
  • onResume : 여기서부턴 상호작용 가능. 앱이 백그라운드 -> 포어그라운드 전환 시 호출된다.
  • onPause : 앱이 백그라운드로 전환될 때, 다른 요소에 가려질 때 실행된다.
  • onStop : 사용자에게 보이지 않고 백그라운드로 전환될 때
  • onDestroyView : 프래그먼트 소멸 전 최종 정리. 프래그먼트가 백스택에 저장되면 프래그먼트 소멸은 안되지만 이 콜백이 호출될 수 있다. 이 콜백의 반환이 프래그먼트의 끝이다.
  • onDestroy : 프래그먼트 소멸 중. 앱 종료 혹은 프래그먼트 교체 시 호출
  • onDetach : 프래그먼트가 액티비티와 분리됨

프래그먼트와 액티비티 관계를 알아보기 위해 아래처럼 코딩한다. 차례대로 레이아웃 xml, Activity 및 Fragment 클래스이다.

<!-- fragment_main.xml -->
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainFragment">

    <!-- TODO: Update blank fragment layout -->
    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:text="@string/hello_blank_fragment" />

</FrameLayout>

<!-- activity_main.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"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <fragment
        android:id="@+id/main_fragment"
        android:name="com.example.fragmentlifecycle.MainFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
// MainActivity.kt
package com.example.fragmentlifecycle

import android.os.Bundle
import android.util.Log
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat

private const val TAG = "MainActivity"

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Log.d(TAG, "onCreate: ")
    }
}

// MainFragment.kt
package com.example.fragmentlifecycle

import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup

private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"
private const val TAG = "MainFragment"
class MainFragment : Fragment() {
    private var param1: String? = null
    private var param2: String? = null

    override fun onAttach(context: Context) {
        super.onAttach(context)
        Log.d(TAG, "onAttach: ")
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d(TAG, "onCreate: ")
        arguments?.let {
            param1 = it.getString(ARG_PARAM1)
            param2 = it.getString(ARG_PARAM2)
        }
}

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        Log.d(TAG, "onCreateView: ")
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_main, container, false)
    }

    companion object {
        @JvmStatic
        fun newInstance(param1: String, param2: String) =
            MainFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_PARAM1, param1)
                    putString(ARG_PARAM2, param2)
                }
            }
    }
}

중요한 점 몇가지를 짚어보자.

  • activity_main.xml 에 fragment 태그를 써서 미리 프래그먼트를 넣었다. 이처럼 액티비티에 이미 정의된 프래그먼트를 정적 프래그먼트라고 한다.
  • Log 를 확인해보면 프래그먼트 관련 생명주기 함수 실행 후 액티비티의 onCreate 함수가 실행됨을 알 수 있다. 액티비티는 자신이 포함한 뷰 기반으로 UI 를 생성하기 때문에 프래그먼트가 먼저 생성된 것이다.

프래그먼트, 액티비티 생명주기 함수에 로그를 찍는 함수를 추가하고 앱을 빌드한다. 그리고 화면을 회전시키면 액티비티가 새로 만들어질 것이다. 아래는 회전시킬 경우 찍히는 로그를 정리한 것이다.

  1. (F) onPause
  2. (A) onPause
  3. (F) onStop
  4. (A) onStop
  5. (F) onDestroyView
  6. (F) onDestroy
  7. (F) onDetach
  8. (A) onDestroy
  9. (F) onAttach
  10. (F) onCreate
  11. (F) onCreateView
  12. (A) onCreate

정리하자면 액티비티가 소멸될 때 수행해야 할 함수들을 프래그먼트-액티비티 순서로 차례를 지켜가며 실행하는 모습을 보여준다. 액티비티의 onDestroy 를 끝으로 액티비티가 소멸되기 전 onDetach 를 통해 프래그먼트도 소멸한다. 그리고 프래그먼트가 onAttach 를 시작으로 생성되기 시작하며 액티비티도 onCreate 됨을 알 수 있다.

액티비티 내에는 아래와 같이 프래그먼트를 추가할 수 있다. layout_weight 로 프래그먼트 높이 비율을 설정했다는 것을 알 수 있다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    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:orientation="vertical"
    tools:context=".MainActivity">

    <fragment
        android:id="@+id/counter_fragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="2"
        android:name="com.example.fragmentintro.CounterFragment" />

    <fragment
        android:id="@+id/color_fragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:name="com.example.fragmentintro.ColorFragment" />

</LinearLayout>

정적 프래그먼트와 듀얼 패인 레이아웃

Fragment 는 2011년 API 11 에서 소개됐으며, FragmentManager 클래스를 사용해 액티비티와의 상호작용을 관리한다. SupportFragmentManager 는 안드로이드 11 이전 버전의 안드로이드 서포트 라이브러리에서 프래그먼트를 사용할 수 있게 도입됐다. SupportFragmentManager 는 프래그먼트를 관리하기 위한 개선 사항이 추가돼 젯팩 fragment 라이브러리의 기반이 됐다.

정적 프래그먼트를 이용해 듀얼 패인 레이아웃을 설정하는 법을 배워보자. 순서는 다음과 같다.

  1. Android Resource 를 통해 레이아웃을 만들어준다. 여기서 Smallest Width 를 옵션으로 추가하는데 값을 600 으로 설정한다.
    • 태블릿인지 아닌지를 나누는 기준의 폭 600dp 이다.
    • res/layout 말고 res/sw600dp 폴더가 하나 더 생긴다.
  2. 프래그먼트를 두 개 만든다. 목록 용, 상세 용
  3. interface 를 선언하고 onSelected 함수를 선언한 뒤 MainActivity 가 이를 구현하도록 한다.
  4. 각 프래그먼트를 구현하고 목록 프래그먼트에는 interface 구현을 하도록 한다.
  5. MainActivity 에서 600dp 기준 듀얼 패인인지 아닌지 확인하고 아래 작업을 수행한다.
    • 600dp 이상 : 상세 프래그먼트를 supportFragmentManagerfindFragmentById 혹은 findFragmentByTag 를 이용해 불러온다.
    • 600dp 미만 : startActivity 를 수행할 Intent 를 생성해서 상세 액티비티를 생성한다.
const val STAR_SIGN_ID = "STAR_SIGN_ID"
interface StarSignListener {
    fun onSelected(id: Int)
}

class MainActivity : AppCompatActivity(), StarSignListener {
    var isDualPane: Boolean = false
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        isDualPane = findViewById<View>(R.id.star_sign_detail) != null
    }

    override fun onSelected(id: Int) {
        if (isDualPane) { // DetailFragment 이용
            val detailFragment = supportFragmentManager
                .findFragmentById(R.id.star_sign_detail) as DetailFragment
            detailFragment.setStarSignData(id)
        } else { // DetailActivity 이용
            val detailIntent = Intent(this, DetailActivity::class.java).apply {
                putExtra(STAR_SIGN_ID, id)
            }
            startActivity(detailIntent)
        }
    }
}

class ListFragment : Fragment(), View.OnClickListener {
    override fun onAttach(context: Context) {
        super.onAttach(context)
        if (context is StarSignListener) {
            starSignListener = context
        } else {
            throw RuntimeException("Must implement StarSignListener")
        }
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val starSigns: List<View> = listOf(
            R.id.aquarius, R.id.pisces, R.id.aries,
            R.id.taurus, R.id.gemini, R.id.cancer,
            R.id.leo, R.id.virgo, R.id.libra,
            R.id.scorpio, R.id.sagittarius, R.id.capricorn,
        ).map {
            id -> view.findViewById(id)
        }

        starSigns.forEach {
            it.setOnClickListener(this)
        }
    }

    override fun onClick(p0: View?) {
        p0?.let { starSign ->
            starSignListener.onSelected(starSign.id)
        }
    }
}

class DetailFragment : Fragment() {
    private val starSign: TextView?
        get() = view?.findViewById(R.id.star_sign)
    private val symbol: TextView?
        get() = view?.findViewById(R.id.symbol)
    private val dateRange: TextView?
        get() = view?.findViewById(R.id.date_range)
        
    fun setStarSignData(starSignId: Int) {
        when (starSignId) {
            R.id.aquarius -> {
                starSign?.text = getString(R.string.aquarius)
                symbol?.text = getString(R.string.symbol, "Water Carrier")
                dateRange?.text = getString(R.string.date_range, "January 20 - February 18")
            }
        // ...
        }
    }
}

동적 프래그먼트

사용자의 동작에 대응하기 위해 동적 프래그먼트를 생성할 수도 있다. 프래그먼트 컨테이너 역할을 하는 ViewGroup 을 추가한 뒤 추가/교체/제거하는 방법을 배워본다.

우선 FragmentContainerView 를 사용하기 위해 의존성 추가를 수행한다. toml 파일 수정도 해야 함을 잊지 말아야 한다.

<의존성 추가>

implementation(libs.androidx.fragment.ktx)

<toml 수정>

[versions]
...
fragmentKtx = "1.5.6"

[libraries]
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }

activity_main 을 FragmentContainerView 로 만든다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/fragment_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>

이제 MainActivity 에서 프래그먼트를 교체하는 코드를 추가한다.

interface StarSignListener {
    fun onSelected(id: Int)
}

class MainActivity : AppCompatActivity(), StarSignListener {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (savedInstanceState == null) {
            findViewById<FragmentContainerView>(R.id.fragment_container)?.let { frameLayout ->
                val listFragment = ListFragment()
                supportFragmentManager.beginTransaction()
                    .add(frameLayout.id, listFragment)
                    .commit()
            }
        }
    }

    override fun onSelected(id: Int) {
        findViewById<FragmentContainerView>(R.id.fragment_container)?.let { frameLayout ->
            val detailFragment = DetailFragment.newInstance(id)
            // 백스택에서 꺼내진 트랜잭션은 순서와 작업 자체를 완전 반대로 수행한다.
            // 아래 작업은 "ListFragment 를 제거하고 DetailFragment 로 교체 후 이 작업을 백 스택에 push 하라" 이다.
            // 이 반대작업은 "DetailFragment 를 제거하고 ListFragment 로 교체하라" 이다.
            // 이에 대한 내용은 더 익숙해질 필요가 있을 듯... 약간 자의적인 해석이라 느껴짐.
            supportFragmentManager.beginTransaction()
                .replace(frameLayout.id, detailFragment)
                .addToBackStack(null)
                .commit()
        }
    }
}

Jetpack 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"
    android:id="@+id/nav_graph"
    app:startDestination="@id/starSignList">

    <fragment
        android:id="@+id/starSignList"
        android:name="com.example.jetpackfragments.ListFragment"
        android:label="List"
        tools:layout="@layout/fragment_list">

        <action
            android:id="@+id/star_sign_id_action"
            app:destination="@id/starSign" />
    </fragment>
    <fragment
        android:id="@+id/starSign"
        android:name="com.example.jetpackfragments.DetailFragment"
        android:label="Detail"
        tools:layout="@layout/fragment_detail" />

</navigation>

<activity_main.xml>

<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/nav_host_fragment"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="true"
    app:navGraph="@navigation/nav_graph" />

<ListFragment.kt>

class ListFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_list, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val starSigns: List<View> = listOf(
            R.id.aquarius, R.id.pisces, R.id.aries,
            R.id.taurus, R.id.gemini, R.id.cancer,
            R.id.leo, R.id.virgo, R.id.libra,
            R.id.scorpio, R.id.sagittarius, R.id.capricorn,
        ).map {
                id -> view.findViewById(id)
        }

        starSigns.forEach { starSign ->
            val fragmentBundle = Bundle()
            fragmentBundle.putInt(STAR_SIGN_ID, starSign.id)
            starSign.setOnClickListener(
                Navigation.createNavigateOnClickListener(
                    R.id.star_sign_id_action, fragmentBundle
                )
            )
        }
    }
}
profile
plug-compatible programming unit

0개의 댓글