본문 바로가기

Android

BottomNavigationView

JetPack-Compose에 대해서만 정리를 하려다보니 Kotlin으로 개발을 어떻게 했었는지 상기하고 비교하고자

Kotlin으로 잠시 돌아왔습니다.

오늘 정리해 볼 것은 BottomNavigationView입니다.  보통 BottomNavigationView는 하단에 탭과 활용되는 경우가 많은데, 

우선 이 부분을 Kotlin을 통해 구현한 다음, 하나의 프레임에 대해 구현된 Fragment에 RecyclerView를 두어

화면 구성을 진행해보겠습니다.

 

[libs.versions.toml]

[versions]
navigationFragmentKtx = "2.7.7"
navigationUiKtx = "2.7.7"

[libraries]
androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtx" }
androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" }

[build.gradle.ktx]

dependencies {
    implementation(libs.androidx.navigation.fragment.ktx)
    implementation(libs.androidx.navigation.ui.ktx)
}

 

후후... 3년 전과는 implementation 하는 방법이 꽤나 달라졌는데, 이 방법이 좀 더 직관적이고 관리하기 편할 것 같다.

Sync 진행하기!! 그리고 자연스럽게 import하며 코드를 정리해봅시다.


 

[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"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingTop="5dp">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/nav_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="0dp"
        android:layout_marginEnd="0dp"
        android:background="?android:attr/windowBackground"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:menu="@menu/bottom_nav_menu" />

    <fragment
        android:id="@+id/nav_host_fragment_activity_main"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toTopOf="@id/nav_view"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/mobile_navigation" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

우선 다음과 같이 BottomNavigationView를 다음과 같이 셋팅한다. 

메뉴로 설정된 부분은 bottom_nav_menu.xml에서 관리됩니다.  탭에 들어가는 아이템을 관리하고 처리합니다.

 

[bottom_nav_menu.xml]

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

    <item
        android:id="@+id/navigation_home"
        android:icon="@drawable/ic_home_black_24dp"
        android:title="@string/title_home" />

    <item
        android:id="@+id/navigation_dashboard"
        android:icon="@drawable/ic_dashboard_black_24dp"
        android:title="@string/title_dashboard" />

    <item
        android:id="@+id/navigation_notifications"
        android:icon="@drawable/ic_notifications_black_24dp"
        android:title="@string/title_notifications" />

</menu>

 

다시 activity_main.xml로 돌아와서 BottomNavigationView 아래에 있는 fragment는 이후 각 탭에 의해서 바뀌게 될 Fragment

를 관리하고 처리할 것을 셋팅한 것입니다.

여기서 중요한 것은 navGraph로 처리된 부분입니다.


navGraph란?

→ 안드로이드 네비게이션 컴포넌트에서 구조 역할을 담당하고 있습니다.

 

1) 목적지 정의

- 앱의 프레그먼트, 액티비티, 또는 대화상자 등 목적지로의 이동 경로를 명시

2) 앱의 다양한 화면 간에 일관된 네비게이션을 관리

3) 복잡한 네비게이션 패턴을 간소화 할 수 있음

4) 액션 정의

- 목적지 간의 이동경로를 액션으로 정의 (A 목적지  → B 목적지 사이에 발생)

5) 딥링크 정의

- 앱 외부에서 특정 목적지로 직접 이동할 수 있도록 함

6) 전환 애니메이션

- 목적지 간 전환시 사용할 애니메이션을 정의

7) argument

- 목적지로 이동할 때 인수를 넘겨줄 것을 셋팅한다. 

 

<?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/mobile_navigation"
    app:startDestination="@+id/navigation_home">

    <fragment
        android:id="@+id/navigation_home"
        android:name="com.example.meetus.ui.home.HomeFragment"
        android:label="@string/title_home"
        tools:layout="@layout/fragment_home" />

    <fragment
        android:id="@+id/navigation_dashboard"
        android:name="com.example.meetus.ui.dashboard.DashboardFragment"
        android:label="@string/title_dashboard"
        tools:layout="@layout/fragment_dashboard">
        <action
            android:id="@+id/action_navigation_dashboard_to_navigation_notifications"
            app:destination="@id/navigation_notifications" />
    </fragment>

    <fragment
        android:id="@+id/navigation_notifications"
        android:name="com.example.meetus.ui.notifications.NotificationsFragment"
        android:label="@string/title_notifications"
        tools:layout="@layout/fragment_notifications">
        <deepLink
            android:id="@+id/deepLink"
            app:uri="앱 공개 url" />
    </fragment>
</navigation>

 

다음 내용에서 두 번째 fragment 안에 action을 두었고 이는 세 번째 fragment로 연결하는 액션을 취합니다.

두 번째 fragment의 xml에다가 버튼 하나를 만든 다음에 Kotlin 파일에서 리스너를 셋팅했습니다.

// 이동
btnNavMove.setOnClickListener({
    findNavController().navigate(R.id.action_navigation_dashboard_to_navigation_notifications)
})

 

NavigationController를 통해 navigate 기능을 구현하는데, id 값은 action에 셋팅한 id 값으로 둡니다.

그러면 해당 버튼을 클릭했을때 세 번째 fragment로 이동하게 되고 두 번째 fragment로 탭이동하더라도 세 번째 fragment를 가리킵니다.  이처럼 action을 두게 되고 이를 navigate하게 되면 앱 내부에서 접근하여 화면 전환하는 방향으로 설정됩니다.

 

이와 다르게 deepLink는 앱 공개 url로 셋팅한 fragment를 외부에서 직접 입력해 접속했을때 해당 화면을 보여주게 하는 역할을 합니다.

action은 내부에서 접근하여 화면을 전환시키는 구조라면, deepLink는 외부에서 앱 공개 url로 접근했을때 설정된 fragment로 

화면이 전환되는 즉, 외부에서 앱을 접근할 때 셋팅된 화면으로 전환되는 구조입니다.

 

 


 

 

화면을 가만히 두니 뭔가 허전하여..  Fragment 하나에 RecyclerView를 적용하여 화면을 구성해보겠습니다.

RecyclerView를 만들기 위해서 생각해야 할 것 4가지를 기억해두세요.

 

1. RecyclerView를 선언하고 반드시 레이아웃 매니저와 연결시킬 것

2. RecyclerView.Adapter를 구현하는 클래스를 만들것

3. ViewModel에서 LiveData로 셋팅하는데 리스트를 활용하고 이를 실제 Fragment에서 활용한다.

4. item을 관리하는 model과 xml을 만든다.

 

 

1. RecyclerView 선언 & 레이아웃 매니저 연결

<?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:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.dashboard.DashboardFragment">

    <TextView
        android:id="@+id/txt_hobby"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="5dp"
        android:layout_marginTop="5dp"
        android:textSize="20sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_goneMarginStart="10dp" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/hobby_recycler_view"
        android:layout_width="match_parent"
        android:layout_height="330dp"
        android:layout_marginTop="5dp"
        android:background="@color/cardview_shadow_start_color"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/txt_hobby" />


</androidx.constraintlayout.widget.ConstraintLayout>

 

RecyclerView 선언과 셋팅

 val hobbyRecyclerView: RecyclerView = binding.hobbyRecyclerView
 // Adapter를 초기화를 먼저합니다.
 profileAdapter = ProfileAdapter(
 	mutableListOf()
 )
 
 // ViewModel에 선언한 MutableList를 바라보는데 여기서 선언한 프로필 목록을 바라보고
 // 프로필 리스트로 셋팅합니다
 dashboardViewModel.profiles.observe(viewLifecycleOwner) { profiles ->
    profileAdapter.setProfiles(profiles)
 }

 

 

2. RecyclerView.Adapter 만들기

package com.example.meetus.ui.dashboard.adapter
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.example.meetus.R
import com.example.meetus.ui.dashboard.model.Profile

class ProfileAdapter(private var profileList: MutableList<Profile>) : RecyclerView.Adapter<ProfileAdapter.Holder>() {
    class Holder(itemView: View) : RecyclerView.ViewHolder(itemView){
        private val txtNickName: TextView = itemView.findViewById(R.id.txt_nick_name)
        private val txtAge : TextView = itemView.findViewById(R.id.txt_age)
        private val cardView : CardView = itemView.findViewById(R.id.card_view)
        private val mainImage : ImageView = itemView.findViewById(R.id.profile_image)
        fun bind(profile: Profile) {
            txtNickName.text = profile.name
            txtAge.text = profile.age
            cardView.elevation = 1.0F
            Glide.with(itemView.context)
                .load(profile.image)
                .placeholder(R.drawable.ic_home_black_24dp)
                .error(R.drawable.ic_notifications_black_24dp)
                .listener(object: RequestListener<Drawable>{
                    override fun onLoadFailed(
                        e: GlideException?,
                        model: Any?,
                        target: Target<Drawable>?,
                        isFirstResource: Boolean
                    ): Boolean {
                        Log.e("Glide", "Image load failed: $e")
                        return false
                    }

                    override fun onResourceReady(
                        resource: Drawable?,
                        model: Any?,
                        target: Target<Drawable>?,
                        dataSource: DataSource?,
                        isFirstResource: Boolean
                    ): Boolean {
                        Log.d("Glide", "Image load Successful")
                        return false
                    }

                })
                .into(mainImage)
        }

    }
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.dashboard_item, parent, false)
        return Holder(view)
    }

    override fun getItemCount(): Int {
        return profileList.size
    }

    override fun onBindViewHolder(holder: Holder, position: Int) {
        holder.bind(profileList[position])
    }

    fun setProfiles(profiles: MutableList<Profile>) {
        this.profileList = profiles
        notifyDataSetChanged()
    }

    fun addProfile(profile: Profile){
        profileList.add(profile)
        notifyItemInserted(profileList.size - 1)
    }

    fun removeProfile(position: Int){
        if(position >= 0 && position < profileList.size){
            profileList.removeAt(position)
            notifyItemRemoved(position)
        }
    }
}

 

코드가 꽤 길지만 하나씩 설명하자면 우선 어댑터의 파라미터로 Profile이라는 모델을 객체로 한 리스트를 담습니다.

추후에 이를 통해 초기 값을 셋팅하여 어댑터를 만들기 위함입니다.

뷰 안에 itemView를 담는 Holder 클래스를 만듭니다.

이는 RecyclerView에서 사용될 item 값들입니다.  즉, RecyclerView라는 뷰 안에 item으로 사용될 뷰(위젯)에 대해서

선언하는 부분입니다.

 

아이템 뷰를 현 RecyclerView(부모 view)에 바인딩하기 위해서 bind 함수를 선언합니다.

이는 onBindViewHolder라는 구현함수에서 호출될 예정이며 여기서 얻은 객체가 각 요소의 값들로 셋팅되어 적용됩니다.

더 간단히 이야기하자면, Glide 부분은 제외하고 설명해보겠습니다.  (Glide는 이미지 가져오는 라이브러리)

fun bind(profile: Profile) {
    txtNickName.text = profile.name
    txtAge.text = profile.age
    cardView.elevation = 1.0F
    
}
override fun onBindViewHolder(holder: Holder, position: Int) {
    holder.bind(profileList[position])
}

 

이렇게 onBindViewHolder에서 객체를 바인딩해주는데, profileList는 Profile이라는 객체를 담고 있는 리스트이고

각 리스트의 인덱스마다 담겨진 객체를 bind 함수를 통해 닉네임, 나이, 카드뷰(이건 공통) 셋팅을 합니다. 

 

fun setProfiles(profiles: MutableList<Profile>) {
    this.profileList = profiles
    notifyDataSetChanged()
}

 

Profile 객체를 담는 리스트를 파라미터로 받아서 현 리스트에 담아주는건데, 데이터 변경사항을 알림으로써 

바로 적용하도록 한다.

 

 

3. ViewModel에서 LiveData로 셋팅하는데 리스트를 활용하고 이를 실제 Fragment에서 활용한다.

package com.example.meetus.ui.dashboard

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.widget.AppCompatButton
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.meetus.R
import com.example.meetus.databinding.FragmentDashboardBinding
import com.example.meetus.ui.dashboard.adapter.ProfileAdapter
import com.example.meetus.ui.dashboard.model.Profile

class DashboardFragment : Fragment() {

    private var _binding: FragmentDashboardBinding? = null

    // This property is only valid between onCreateView and
    // onDestroyView.
    private val binding get() = _binding!!
    private lateinit var profileAdapter: ProfileAdapter
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val dashboardViewModel =
            ViewModelProvider(this).get(DashboardViewModel::class.java)

        _binding = FragmentDashboardBinding.inflate(inflater, container, false)
        val root: View = binding.root
        val txtHobby: TextView = binding.txtHobby
        val hobbyRecyclerView: RecyclerView = binding.hobbyRecyclerView

        // adapter
        profileAdapter = ProfileAdapter(
            mutableListOf()
        )
        /*
        https://drive.google.com/file/d/1UvcNBY3rSWwIhfjOK16q2OZFW9EPQhB8/view?usp=sharing, https://drive.google.com/file/d/1UvkJJGlJV9hUScFpX0OZtk1xl2FvT-jm/view?usp=sharing, https://drive.google.com/file/d/1Uy7ZFc0ga4YgUFamWuspVnc6ULvlHFKB/view?usp=sharing
         */
        val initialProfiles = mutableListOf(
            Profile(
                "-",
                "32",
                R.drawable.haams
            ),
            Profile(
                "-",
                "27",
                R.drawable.nakoong
            ),
            Profile(
                "-",
                "32",
                R.drawable.wooyeong
            ),
            Profile(
                "-",
                "34",
                R.drawable.woong
            )
        )


        dashboardViewModel.setProfile(initialProfiles)

        hobbyRecyclerView.layoutManager = LinearLayoutManager(context)
        hobbyRecyclerView.adapter = profileAdapter
        // viewModel -> 여기서 데이터를 계속 관리하며 바뀌면 그대로 적용
        dashboardViewModel.text.observe(viewLifecycleOwner) {
            txtHobby.text = it
        }

        dashboardViewModel.profiles.observe(viewLifecycleOwner) { profiles ->
            profileAdapter.setProfiles(profiles)
        }


        return root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

 

4. item을 관리하는 model과 xml을 만든다.

 

[dashboard_item.xml]

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/card_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="8dp"
    android:elevation="4dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="16dp">

        <TextView
            android:id="@+id/txt_nick_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Nickname"
            android:textSize="16sp" />

        <TextView
            android:id="@+id/txt_age"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Age"
            android:textSize="14sp" />

        <ImageView
            android:id="@+id/profile_image"
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:scaleType="fitXY" />

    </LinearLayout>

</androidx.cardview.widget.CardView>

 

[Profile.kt]

package com.example.meetus.ui.dashboard.model

data class Profile (
    val name : String,
    val age : String,
    val image : Int
)

 

 

다음 내용은 JetPack Compose에 대해 다시 정리하며, BottomNavigationView를 통해 Fragment를 셋팅하고

RecyclerView를 통해 Adapter를 만들고, ViewModel을 관리하고 Model도 만들고, xml도 생성해야하는 이 불편함을

어떻게 대체하여 개발이 가능한지 자세히 확인해보겠습니다.    감사합니다.

반응형

'Android' 카테고리의 다른 글

Kotlin 기초 2편  (0) 2024.07.11
Kotlin 기초 1편  (0) 2024.07.11
Jetpack Compose란..?  (2) 2024.06.30
FCM 메시지가 최신화가 되지 않을때  (0) 2024.06.27
카메라/갤러리 기능  (0) 2023.01.17