관리 메뉴

막내의 막무가내 프로그래밍 & 일상

[안드로이드] 구글 공식 프로젝트 Sunflower 스터디 (5) MVVM - View 본문

안드로이드/코틀린 & 아키텍처 & Recent

[안드로이드] 구글 공식 프로젝트 Sunflower 스터디 (5) MVVM - View

막무가내막내 2021. 4. 19. 15:22
728x90

 

 

[2021-05-08 업데이트]

 

[참고]

github.com/android/sunflower

 

android/sunflower

A gardening app illustrating Android development best practices with Android Jetpack. - android/sunflower

github.com

 

 

[View]

MVVM에서 View 에 해당하는 Activity, Fragment, Adapter 에 대해 보려고한다. 데이터바인딩하고 ViewModel도 연관이 되있기 때문에 다른 내용이 추가로 들어갈 수도 있다.

 


 

[GardenActivity]

먼저 SPA(Single-Page-Application) 구조인 이 프로젝트에서 유일한 액티비티인 GardenActivity이다. 

네비게이션 Host로 프래그먼트의 껍데기 역할을 담당해서 별다른 로직은 없다.

@AndroidEntryPoint
class GardenActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView<ActivityGardenBinding>(this, R.layout.activity_garden)
    }
}

XML에서는 Jetpack Navigation을 활용하기 위한 옵션들을 갖고있고 FragmetContainerView 레이아웃으로 프래그먼트를 담게 된다.

<layout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_garden" />

</layout>

 


 

 

[HomeViewPagerFragment]

@AndroidEntryPoint
class HomeViewPagerFragment : Fragment() {

    override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
    ): View {
        // 데이터바인딩 세팅
        val binding = FragmentViewPagerBinding.inflate(inflater, container, false)
        val tabLayout = binding.tabs
        val viewPager = binding.viewPager
        // 뷰페이저 어댑터 세팅
        viewPager.adapter = SunflowerPagerAdapter(this)

        // 탭 레이아웃 아이콘 및 텍스트 세팅 (TabLayoutMediator 는 처음 봤다.)
        // https://developer.android.com/training/animation/vp2-migration?hl=ko#tablayout 와 연계된다.
        // Set the icon and text for each tab
        TabLayoutMediator(tabLayout, viewPager) { tab, position ->
            tab.setIcon(getTabIcon(position))
            tab.text = getTabTitle(position)
        }.attach()

        // 툴바 세팅
        (activity as AppCompatActivity).setSupportActionBar(binding.toolbar)

        // 데이터바인딩과 초기화 작업을 한후 데이터바인딩 클래스에는 상응하는 레이아웃 파일의
        // 루트 뷰에 관한 직접 참조를 제공하는 binding.root 를 반환해준다.
        return binding.root
    }

    // 아이콘 리소스 가져오기
    private fun getTabIcon(position: Int): Int {
        return when (position) {
            MY_GARDEN_PAGE_INDEX -> R.drawable.garden_tab_selector
            PLANT_LIST_PAGE_INDEX -> R.drawable.plant_list_tab_selector
            else -> throw IndexOutOfBoundsException()
        }
    }

    // 타이틀 텍스트 가져오기
    private fun getTabTitle(position: Int): String? {
        return when (position) {
            MY_GARDEN_PAGE_INDEX -> getString(R.string.my_garden_title)
            PLANT_LIST_PAGE_INDEX -> getString(R.string.plant_list_title)
            else -> null
        }
    }
}
<layout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <!--
     Note: even though the IDs for the CoordinatorLayout and the AppBarLayout unused in HomeViewPagerFragment, they are
     both required to preserve the toolbar scroll / collapse state when navigating to a new screen and then coming back.
    -->
    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:id="@+id/coordinator_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fitsSystemWindows="true">

        <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/view_pager"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior"/>

        <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/app_bar_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:fitsSystemWindows="true"
            android:theme="@style/Theme.Sunflower.AppBarOverlay">

            <com.google.android.material.appbar.CollapsingToolbarLayout
                android:id="@+id/toolbar_layout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:layout_scrollFlags="scroll|snap"
                app:toolbarId="@id/toolbar">

                <com.google.android.material.appbar.MaterialToolbar
                    android:id="@+id/toolbar"
                    style="@style/Widget.MaterialComponents.Toolbar.Primary"
                    android:layout_width="match_parent"
                    android:layout_height="?attr/actionBarSize"
                    app:contentInsetStart="0dp"
                    app:layout_collapseMode="parallax">

                <TextView
                    android:layout_width="match_parent"
                    android:layout_height="?attr/actionBarSize"
                    android:gravity="center"
                    android:text="@string/app_name"
                    android:textAppearance="?attr/textAppearanceHeadline5" />

                </com.google.android.material.appbar.MaterialToolbar>

            </com.google.android.material.appbar.CollapsingToolbarLayout>

            <!-- Override tabIconTint attribute of style with selector -->
            <com.google.android.material.tabs.TabLayout
                android:id="@+id/tabs"
                style="@style/Widget.MaterialComponents.TabLayout.Colored"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:tabIconTint="@drawable/tab_icon_color_selector"
                app:tabTextColor="?attr/colorPrimaryDark"/>

        </com.google.android.material.appbar.AppBarLayout>

    </androidx.coordinatorlayout.widget.CoordinatorLayout>

</layout>

 

Toolbar + ViewPager2 + TabLayout 구조로 되어 있는 HomeViewPagerFragment 이다. 

XML은 <layout>으로 데이터바인딩 처리 되어있고 프래그먼트에서는 FragemntViewPagerBinding.infalte()로 infalte 하는 것을 볼 수 있다.

그리고 TabLayout 과 ViewPager가 연결되고 ViewPager에는 어댑터가 세팅된다.

데이터바인딩과 초기화 작업을 한후 데이터바인딩 클래스에는 상응하는 레이아웃 파일의 루트 뷰에 관한 직접 참조를 제공하는 binding.root 를 반환해준다.

 

또한 TabLayoutMediator 라는 나는 처음본 클래스가 있었다. 이는 밑에서 살펴볼 수 있다. ViewPager2는 1버전과 비교하여 에 세로방향 지원, DiffUtill 적용 등 여러가지 이점과 특징이 있는데  그 중 하나로 TabLayout 객체와 함께 사용할 수 있다는 점이다. 밑 설명과 링크를 참고하면 좋을 것 같다.

 

https://developer.android.com/training/animation/vp2-migration?hl=ko#tablayout
https://developer.android.com/training/animation/vp2-migration?hl=ko#tablayout
https://developer.android.com/training/animation/vp2-migration?hl=ko#tablayout

 

 

 


 

 

[SunflowerPagerAdapter]

const val MY_GARDEN_PAGE_INDEX = 0
const val PLANT_LIST_PAGE_INDEX = 1

class SunflowerPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {

    /**
     * Mapping of the ViewPager page indexes to their respective Fragments
     */
    private val tabFragmentsCreators: Map<Int, () -> Fragment> = mapOf(
        MY_GARDEN_PAGE_INDEX to { GardenFragment() },
        PLANT_LIST_PAGE_INDEX to { PlantListFragment() }
    )

    override fun getItemCount() = tabFragmentsCreators.size

    override fun createFragment(position: Int): Fragment {
        return tabFragmentsCreators[position]?.invoke() ?: throw IndexOutOfBoundsException()
    }
}

https://developer.android.com/training/animation/vp2-migration?hl=ko#adapter-classes

 

위 HomeViewPagerFragment 의 ViewPager2 에서 세팅한 SunflowerPagerAdapter 에 대해 살펴봤다.

ViewPager2 + Fragment 단위로 이루어진 어댑터이므로 FragmentStateAdapter 를 상속받는 것을 볼 수 있다. ViewPager1 에서 사용하던 FragmentStatePagerAdapter는 deprecated 되고 FragmentStateAdapter 로 대체되었다

 

ViewPager2 에 대해서는 이전 포스팅에서 다룬적이 있다.

youngest-programming.tistory.com/336

 

[안드로이드] 안드로이드 ViewPager2 적용 (feat. Viewpager number indicator)

[2021-04-13 업데이트] [참고 사이트] https://developer.android.com/jetpack/androidx/releases/viewpager2?hl=ko ViewPager2  | Android 개발자  | Android Developers 스와이프할 수 있는 형식으로 뷰 또는..

youngest-programming.tistory.com

공식문서에서 더 자세한 내용을 볼 수 있다.

developer.android.com/training/animation/vp2-migration?hl=ko

 

ViewPager에서 ViewPager2로 이전  |  Android 개발자  |  Android Developers

ViewPager2는 ViewPager 라이브러리의 개선된 버전으로, 향상된 기능을 제공하며 ViewPager 사용 시 발생하는 일반적인 문제를 해결합니다. 앱에서 ViewPager를 이미 사용하고 있는 경우 이 페이지에서 ViewP

developer.android.com

 

 


 

 

[PlantListFragment]

@AndroidEntryPoint
class PlantListFragment : Fragment() {

    private val viewModel: PlantListViewModel by viewModels()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        Log.d("FFFFFF", "PlantListFragment")
        val binding = FragmentPlantListBinding.inflate(inflater, container, false)
        context ?: return binding.root

        val adapter = PlantAdapter()
        binding.plantList.adapter = adapter
        subscribeUi(adapter)

        setHasOptionsMenu(true)
        return binding.root
    }

    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
        inflater.inflate(R.menu.menu_plant_list, menu)
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when (item.itemId) {
            R.id.filter_zone -> {
                updateData()
                true
            }
            else -> super.onOptionsItemSelected(item)
        }
    }

    private fun subscribeUi(adapter: PlantAdapter) {
        viewModel.plants.observe(viewLifecycleOwner) { plants ->
            adapter.submitList(plants)
        }
    }

    private fun updateData() {
        with(viewModel) {
            if (isFiltered()) {
                clearGrowZoneNumber()
            } else {
                setGrowZoneNumber(9)
            }
        }
    }
}
<layout 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">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/plant_list"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:clipToPadding="false"
            android:paddingEnd="@dimen/card_side_margin"
            android:paddingStart="@dimen/card_side_margin"
            android:paddingTop="@dimen/header_margin"
            app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
            app:spanCount="@integer/grid_columns"
            tools:context="com.google.samples.apps.sunflower.GardenActivity"
            tools:listitem="@layout/list_item_plant"/>

    </FrameLayout>
</layout>

식물들의 리스트를 보여주는 프래그먼트이다. 리스트다 보니 리사이클러뷰와 어댑터를 세팅하는 코드가 있다.

오른쪽 상단의 메뉴를 만드는 onCreateOptionsMenu() 함수도 오버라이드 되어있는 것을 볼 수 있고 이 메뉴를 클릭 시 onOptionsItemSelected() 에 의해 데이터가 업데이트 되는 로직이 실행된다.

 

뷰모델 관련 코드는 다음 챕터에서 살펴보려고 하고 뷰모델 코드를 안봐도 함수명으로 어떻게 동작할지 대충 짐작할 수 있다.

 

이어서 이 프래그먼트에서 사용한 어댑터를 살펴보면 ListAdapter를 상속받아 Diffutil을 적용하였다. 이에 대해 간략하게 설명하면,  요약하면 안드로이드 어댑터에서 현재 데이터 리스트와 교체될 데이터 리스트를 비교하여 무엇이 다른지(바꼈는지) 알아내는 클래스로 리사이클러뷰에서 기존 아이템리스트에 수정 혹은 변경이 있을 시 전체를 갈아치우는게 아니라 변경되야하는 데이터만 빠르게 바꿔주는 역할을 합니다. 또한 기존 일반적인 어댑터와 다르게 submitList() 라는 함수로 아이템을 추가하는 것도 특징이다.

 

이에 관련해서는 예전에 다룬적이 있다. 참고할 링크도 다음 포스팅에서 확인할 수 있다. youngest-programming.tistory.com/474

 

[안드로이드] RecyclerView -> ListAdapter + Diffutil 예제 정리

[2021-04-14 업데이트] [개념(출처) 참고 및 공부자료들] thdev.tech/kotlin/2020/09/22/kotlin_effective_03/ data class를 활용하여 RecyclerView.DiffUtil을 잘 활용하는 방법 | I’m an Android Developer. th..

youngest-programming.tistory.com

자세한 내용은 공식문서에서 보면 된다. 

developer.android.com/reference/androidx/recyclerview/widget/ListAdapter

 

ListAdapter  |  Android 개발자  |  Android Developers

ListAdapter public abstract class ListAdapter extends Adapter RecyclerView.Adapter base class for presenting List data in a RecyclerView, including computing diffs between Lists on a background thread. This class is a convenience wrapper around AsyncListDi

developer.android.com

 

 

이 프래그먼트에서 식물 리스트를 나타내기 위해 사용한 ListAdapter 에 대해 살펴보겠다.

/**
 * Adapter for the [RecyclerView] in [PlantListFragment].
 */
class PlantAdapter : ListAdapter<Plant, RecyclerView.ViewHolder>(PlantDiffCallback()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return PlantViewHolder(
            ListItemPlantBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val plant = getItem(position)
        (holder as PlantViewHolder).bind(plant)
    }

    class PlantViewHolder(
        private val binding: ListItemPlantBinding
    ) : RecyclerView.ViewHolder(binding.root) {
        init {
            binding.setClickListener {
                binding.plant?.let { plant ->
                    navigateToPlant(plant, it)
                }
            }
        }

        private fun navigateToPlant(
            plant: Plant,
            view: View
        ) {
            // Jetpack Navigation Directions
            val direction =
                HomeViewPagerFragmentDirections.actionViewPagerFragmentToPlantDetailFragment(
                    plant.plantId
                )
            view.findNavController().navigate(direction)
        }

        fun bind(item: Plant) {
            binding.apply {
                plant = item
                executePendingBindings()
            }
        }
    }
}

private class PlantDiffCallback : DiffUtil.ItemCallback<Plant>() {

    override fun areItemsTheSame(oldItem: Plant, newItem: Plant): Boolean {
        return oldItem.plantId == newItem.plantId
    }

    override fun areContentsTheSame(oldItem: Plant, newItem: Plant): Boolean {
        return oldItem == newItem
    }
}
// 리스트 아이템 XML

<layout 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">

    <data>
        <variable
            name="clickListener"
            type="android.view.View.OnClickListener"/>
        <variable
            name="plant"
            type="com.google.samples.apps.sunflower.data.Plant"/>
    </data>

    <com.google.samples.apps.sunflower.views.MaskedCardView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginStart="@dimen/card_side_margin"
        android:layout_marginEnd="@dimen/card_side_margin"
        android:layout_marginBottom="@dimen/card_bottom_margin"
        android:onClick="@{clickListener}"
        app:cardElevation="@dimen/card_elevation"
        app:cardPreventCornerOverlap="false"
        app:shapeAppearanceOverlay="@style/ShapeAppearance.Sunflower.Card">

            <androidx.constraintlayout.widget.ConstraintLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content">

                <ImageView
                    android:id="@+id/plant_item_image"
                    android:layout_width="0dp"
                    android:layout_height="@dimen/plant_item_image_height"
                    android:contentDescription="@string/a11y_plant_item_image"
                    android:scaleType="centerCrop"
                    app:imageFromUrl="@{plant.imageUrl}"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent" />

                <TextView
                    android:id="@+id/plant_item_title"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_marginBottom="@dimen/margin_normal"
                    android:layout_marginTop="@dimen/margin_normal"
                    android:text="@{plant.name}"
                    android:textAppearance="?attr/textAppearanceListItem"
                    android:gravity="center_horizontal"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/plant_item_image"
                    app:layout_constraintBottom_toBottomOf="parent"
                    tools:text="Tomato"/>

            </androidx.constraintlayout.widget.ConstraintLayout>

    </com.google.samples.apps.sunflower.views.MaskedCardView>

</layout>

설명을 서술형이 아닌 번호 나열형으로 하려고한다. 이게 더 깔끔해보임 ..

 

1. 먼저 아이템 XML이 데이터바인딩 처리되고 값이 세팅되는 것을 볼 수 있다.

 

2. navigateToPlant() 함수를 보면 식물아이템 클릭시 Jetpack Navigation을 사용해 DeatailFragment 로 식물 고유 ID 와 함께 네비게이션 됨을 볼 수 있다.

 

3. ListAdapter 를 상속받고 DiffutilCallback을 설정하여 기존 아이템 리스트에 변경이 있을 경우 달라진 아이템일 경우에만 빠르게 수정해줄 수 있게 구현이 되어 있다. 

 

4. 기존의 어댑터처럼 itemList 변수나 add(), notifyDatachanger() 와 같은 함수를 따로 만들 필요없고 자체적으로 구현이 되어있다.

 

 

 


 

 

[PlantDetailFragment]

식물 리스트의 아이템 클릭 시 띄어지는 PlantDetailFragment 이다. 말 그대로 식물의 상세정보를 제공해준다.

 

/**
 * A fragment representing a single Plant detail screen.
 */
@AndroidEntryPoint
class PlantDetailFragment : Fragment() {

    private val args: PlantDetailFragmentArgs by navArgs()

    @Inject
    lateinit var plantDetailViewModelFactory: PlantDetailViewModelFactory

    private val plantDetailViewModel: PlantDetailViewModel by viewModels {
        PlantDetailViewModel.provideFactory(plantDetailViewModelFactory, args.plantId)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        Log.d("FFFFFF", "PlantDetailFragment")
        val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(
            inflater,
            R.layout.fragment_plant_detail,
            container,
            false
        ).apply {
            viewModel = plantDetailViewModel
            lifecycleOwner = viewLifecycleOwner
            callback = Callback { plant ->
                plant?.let {
                    hideAppBarFab(fab)
                    plantDetailViewModel.addPlantToGarden()
                    Snackbar.make(root, R.string.added_plant_to_garden, Snackbar.LENGTH_LONG)
                        .show()
                }
            }

            galleryNav.setOnClickListener { navigateToGallery() }

            var isToolbarShown = false

            // scroll change listener begins at Y = 0 when image is fully collapsed
            plantDetailScrollview.setOnScrollChangeListener(
                NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, _ ->

                    // User scrolled past image to height of toolbar and the title text is
                    // underneath the toolbar, so the toolbar should be shown.
                    val shouldShowToolbar = scrollY > toolbar.height

                    // The new state of the toolbar differs from the previous state; update
                    // appbar and toolbar attributes.
                    if (isToolbarShown != shouldShowToolbar) {
                        isToolbarShown = shouldShowToolbar

                        // Use shadow animator to add elevation if toolbar is shown
                        appbar.isActivated = shouldShowToolbar

                        // Show the plant name if toolbar is shown
                        toolbarLayout.isTitleEnabled = shouldShowToolbar
                    }
                }
            )

            toolbar.setNavigationOnClickListener { view ->
                view.findNavController().navigateUp()
            }

            toolbar.setOnMenuItemClickListener { item ->
                when (item.itemId) {
                    R.id.action_share -> {
                        createShareIntent()
                        true
                    }
                    else -> false
                }
            }
        }
        setHasOptionsMenu(true)

        return binding.root
    }

    private fun navigateToGallery() {
        plantDetailViewModel.plant.value?.let { plant ->
            val direction =
                PlantDetailFragmentDirections.actionPlantDetailFragmentToGalleryFragment(plant.name)
            findNavController().navigate(direction)
        }
    }

    // Helper function for calling a share functionality.
    // Should be used when user presses a share button/menu item.
    @Suppress("DEPRECATION")
    private fun createShareIntent() {
        val shareText = plantDetailViewModel.plant.value.let { plant ->
            if (plant == null) {
                ""
            } else {
                getString(R.string.share_text_plant, plant.name)
            }
        }
        val shareIntent = ShareCompat.IntentBuilder.from(requireActivity())
            .setText(shareText)
            .setType("text/plain")
            .createChooserIntent()
            .addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
        startActivity(shareIntent)
    }

    // FloatingActionButtons anchored to AppBarLayouts have their visibility controlled by the scroll position.
    // We want to turn this behavior off to hide the FAB when it is clicked.
    //
    // This is adapted from Chris Banes' Stack Overflow answer: https://stackoverflow.com/a/41442923
    private fun hideAppBarFab(fab: FloatingActionButton) {
        val params = fab.layoutParams as CoordinatorLayout.LayoutParams
        val behavior = params.behavior as FloatingActionButton.Behavior
        behavior.isAutoHideEnabled = false
        fab.hide()
    }

    fun interface Callback {
        fun add(plant: Plant?)
    }
}
<layout 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">

    <data>
        <import type="com.google.samples.apps.sunflower.data.Plant"/>
        <variable
            name="viewModel"
            type="com.google.samples.apps.sunflower.viewmodels.PlantDetailViewModel" />
        <variable
            name="callback"
            type="com.google.samples.apps.sunflower.PlantDetailFragment.Callback" />
    </data>

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fitsSystemWindows="true"
        android:background="?attr/colorSurface"
        tools:context="com.google.samples.apps.sunflower.GardenActivity"
        tools:ignore="MergeRootFrame">

        <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/appbar"
            android:layout_width="match_parent"
            android:layout_height="@dimen/plant_detail_app_bar_height"
            android:fitsSystemWindows="true"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            android:stateListAnimator="@animator/show_toolbar"
            android:background="?attr/colorSurface"
            android:animateLayoutChanges="true">

            <com.google.android.material.appbar.CollapsingToolbarLayout
                android:id="@+id/toolbar_layout"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:fitsSystemWindows="true"
                app:contentScrim="?attr/colorSurface"
                app:statusBarScrim="?attr/colorSurface"
                app:collapsedTitleGravity="center"
                app:collapsedTitleTextAppearance="@style/TextAppearance.Sunflower.Toolbar.Text"
                app:layout_scrollFlags="scroll|exitUntilCollapsed"
                app:title="@{viewModel.plant.name}"
                app:titleEnabled="false"
                app:toolbarId="@id/toolbar">

                <ImageView
                    android:id="@+id/detail_image"
                    android:layout_width="match_parent"
                    android:layout_height="@dimen/plant_detail_app_bar_height"
                    android:contentDescription="@string/plant_detail_image_content_description"
                    android:fitsSystemWindows="true"
                    android:scaleType="centerCrop"
                    app:imageFromUrl="@{viewModel.plant.imageUrl}"
                    app:layout_collapseMode="parallax" />

                <com.google.android.material.appbar.MaterialToolbar
                    android:id="@+id/toolbar"
                    android:layout_width="match_parent"
                    android:layout_height="?attr/actionBarSize"
                    android:background="@android:color/transparent"
                    app:titleTextColor="?attr/colorOnSurface"
                    app:layout_collapseMode="pin"
                    app:contentInsetStartWithNavigation="0dp"
                    app:navigationIcon="@drawable/ic_detail_back"
                    app:menu="@menu/menu_plant_detail" />

            </com.google.android.material.appbar.CollapsingToolbarLayout>

        </com.google.android.material.appbar.AppBarLayout>

        <androidx.core.widget.NestedScrollView
            android:id="@+id/plant_detail_scrollview"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:clipToPadding="false"
            android:paddingBottom="@dimen/fab_bottom_padding"
            app:layout_behavior="@string/appbar_scrolling_view_behavior">

            <androidx.constraintlayout.widget.ConstraintLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_margin="@dimen/margin_normal">

                <TextView
                    android:id="@+id/plant_detail_name"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/margin_small"
                    android:layout_marginEnd="@dimen/margin_small"
                    android:gravity="center_horizontal"
                    android:clickable="true"
                    android:focusable="true"
                    android:text="@{viewModel.plant.name}"
                    android:textAppearance="?attr/textAppearanceHeadline5"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent"
                    tools:text="Apple" />

                <TextView
                    android:id="@+id/plant_watering_header"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/margin_small"
                    android:layout_marginTop="@dimen/margin_normal"
                    android:layout_marginEnd="@dimen/margin_small"
                    android:gravity="center_horizontal"
                    android:text="@string/watering_needs_prefix"
                    android:textColor="?attr/colorAccent"
                    android:textStyle="bold"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/plant_detail_name" />

                <TextView
                    android:id="@+id/plant_watering"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/margin_small"
                    android:layout_marginEnd="@dimen/margin_small"
                    android:gravity="center_horizontal"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/plant_watering_header"
                    app:wateringText="@{viewModel.plant.wateringInterval}"
                    tools:text="every 7 days" />

                <ImageView
                    android:id="@+id/gallery_nav"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginEnd="@dimen/margin_small"
                    android:layout_marginTop="@dimen/margin_normal"
                    android:clickable="true"
                    android:contentDescription="@string/gallery_content_description"
                    android:focusable="true"
                    android:src="@drawable/ic_photo_library"
                    app:isGone="@{!viewModel.hasValidUnsplashKey()}"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/plant_detail_name" />

                <TextView
                    android:id="@+id/plant_description"
                    style="?android:attr/textAppearanceMedium"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/margin_small"
                    android:layout_marginTop="@dimen/margin_small"
                    android:layout_marginEnd="@dimen/margin_small"
                    android:textIsSelectable="true"
                    android:minHeight="@dimen/plant_description_min_height"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/plant_watering"
                    app:renderHtml="@{viewModel.plant.description}"
                    tools:text="Details about the plant" />

            </androidx.constraintlayout.widget.ConstraintLayout>

        </androidx.core.widget.NestedScrollView>

        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            style="@style/Widget.MaterialComponents.FloatingActionButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="@dimen/fab_margin"
            android:onClick="@{() -> callback.add(viewModel.plant)}"
            android:tint="@android:color/white"
            app:shapeAppearance="@style/ShapeAppearance.Sunflower.FAB"
            app:isFabGone="@{viewModel.isPlanted}"
            app:layout_anchor="@id/appbar"
            app:layout_anchorGravity="bottom|end"
            app:srcCompat="@drawable/ic_plus" />

    </androidx.coordinatorlayout.widget.CoordinatorLayout>

</layout>

1.  private val args: PlantDetailFragmentArgs by navArgs() 을 보면 알 수 있듯이 Jetpack Navgation의 파라미터 인자값을 전달받는 기능을 사용하고 있다. 여기서는 식물의 고유 ID 값을 전달 받는다.

 

2. 데이터바인딩 처리가 되어있다.

 

3. 스크롤에 따라 화면의 툴바(앱바)가 접혀지고 펴지는 애니메이션을 가진 Collapsing Toolbar Layout 이 사용되었다.  XML을 보면

CoordinatorLayout

   -AppBarLayout

       --CollapsingToolbarLayout

            ---ImageView, Toolbar

    -NestedScrollView

        --ConstraintLayout

    -FloatingActionButton

와 같은 구조로 되어있는 것을 볼 수 있다.

 

추가로 plantDetailScrollview.setOnScrollChangeListener 부분을 보면, 스크롤 리스너로 더 세밀하게 뷰를 컨트롤 할 수 있다. (주석에 설명 잘 되어있다.)

 

추가로 Collapsing Toolbar Layout 관련해서는 예전에 프로젝트에 사용도 해보고 포스팅도 했던 경험이 있다. (구조도 똑같다 :) ) 

youngest-programming.tistory.com/380

 

[안드로이드] 안드로이드 CollapsingToolbarLayout UI 기록

[2021-04-14 업데이트] 이전에 기본적인 CollapsingToolbarLayout 에 대해 구현해보고 알아보는 포스팅을 했었습니다. youngest-programming.tistory.com/353 [코틀린] 안드로이드 Collapsing Toolbar Layout with..

youngest-programming.tistory.com

youngest-programming.tistory.com/353

 

[안드로이드] 안드로이드 Collapsing Toolbar Layout with Constraint Layout, NestedScroll 구현

[2021-04-14 업데이트] Collapsing Toolbar Layout 를 적용하기전에 샘플로 구현해보았습니다. CoordinatorLayout  -AppBarLayout  --CollapsingToolbarLayout ---ImageView, Toolbar -NestedScrollView --Const..

youngest-programming.tistory.com

 

4.  플러스(+) 모양의 플로팅액션버튼을 클릭하면 ViewModel의 addPlantToGarden() 를 호출해 식물이 My Garden에 저장되고 스낵바 메시지가 띄어진다. 그리고 플로팅버튼은 사라지게 된다. 그리고 이미 MyGarden에 추가된 식물은 플로팅버튼이 hide되게 구현되어 있다.

 

5.  툴바 클릭 시 createShareIntent() 가 호출되어 공유하기 기능이 실행된다. 카카오링크라는 공유하기 기능은 사용해봤는데 이런건 처음 봤다. 텍스트가 공유되게 구현되어있다.

 

추가로 XML 데이터바인딩에서 데이터바인딩 어댑터를 사용한 데이터바인딩 속성이(app:XXX) 적용되어 있는데 여기서 볼 수 있다. 

Ex) 이미지로딩, Visiblity, HTML to Text 랜더링 등

이에 대해서는 이전에 데이터바인딩 어댑터 사용하는 것들을 기록해논 적이 있다.

youngest-programming.tistory.com/473

 

[안드로이드] 데이터바인딩 어댑터 (DatabindingAdapter) 및 확장함수 모음 정리

[2021-04-14] 이전 프로젝트에서 본인이 사용했던 바인딩 어댑터나 확장함수를 막무가내로 일단 기록해놓는 공간입니다. [2021-01-07] 일단 사용했던 것들 중 몇개만 기록해 놓습니다. [확장함수] 시간

youngest-programming.tistory.com

@BindingAdapter("imageFromUrl")
fun bindImageFromUrl(view: ImageView, imageUrl: String?) {
    if (!imageUrl.isNullOrEmpty()) {
        Glide.with(view.context)
            .load(imageUrl)
            .transition(DrawableTransitionOptions.withCrossFade())
            .into(view)
    }
}

@BindingAdapter("isFabGone")
fun bindIsFabGone(view: FloatingActionButton, isGone: Boolean?) {
    if (isGone == null || isGone) {
        view.hide()
    } else {
        view.show()
    }
}

@BindingAdapter("renderHtml")
fun bindRenderHtml(view: TextView, description: String?) {
    if (description != null) {
        view.text = HtmlCompat.fromHtml(description, FROM_HTML_MODE_COMPACT)
        view.movementMethod = LinkMovementMethod.getInstance()
    } else {
        view.text = ""
    }
}

@BindingAdapter("wateringText")
fun bindWateringText(textView: TextView, wateringInterval: Int) {
    val resources = textView.context.resources
    val quantityString = resources.getQuantityString(
        R.plurals.watering_needs_suffix,
        wateringInterval,
        wateringInterval
    )

    textView.text = quantityString
}

 

 

6. @AssistedFactory를 사용하여 뷰모델의 @Assisted 생성자 파라미터를 주입해준다.  참고 : dagger.dev/dev-guide/assisted-injection.html

Fragment

 

ViewModel 코드

 


 

 

[GardenFragment]

마지막으로 내가 위에서 살펴본 식물 상세화면에서 추가한 식물들을 보여준 GardenFragment 프래그먼트이다.

@AndroidEntryPoint
class GardenFragment : Fragment() {

    private lateinit var binding: FragmentGardenBinding

    private val viewModel: GardenPlantingListViewModel by viewModels()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentGardenBinding.inflate(inflater, container, false)
        val adapter = GardenPlantingAdapter()
        binding.gardenList.adapter = adapter

        binding.addPlant.setOnClickListener {
            navigateToPlantListPage()
        }

        subscribeUi(adapter, binding)
        Log.d("FFFFFF", "GardenFragment")
        return binding.root
    }

    private fun subscribeUi(adapter: GardenPlantingAdapter, binding: FragmentGardenBinding) {
        viewModel.plantAndGardenPlantings.observe(viewLifecycleOwner) { result ->
            binding.hasPlantings = !result.isNullOrEmpty()
            adapter.submitList(result)
        }
    }

    // TODO: convert to data binding if applicable
    private fun navigateToPlantListPage() {
        requireActivity().findViewById<ViewPager2>(R.id.view_pager).currentItem =
            PLANT_LIST_PAGE_INDEX
    }
}
<layout 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">

    <data>

        <variable
                name="hasPlantings"
                type="boolean" />

    </data>

    <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/garden_list"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:clipToPadding="false"
                android:layout_marginStart="@dimen/card_side_margin"
                android:layout_marginEnd="@dimen/card_side_margin"
                android:layout_marginTop="@dimen/margin_normal"
                app:isGone="@{!hasPlantings}"
                app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
                app:spanCount="@integer/grid_columns"
                tools:listitem="@layout/list_item_garden_planting"/>

        <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:gravity="center"
                android:orientation="vertical"
                app:isGone="@{hasPlantings}">

            <TextView
                    android:id="@+id/empty_garden"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="@string/garden_empty"
                    android:textAppearance="?attr/textAppearanceHeadline5" />

            <com.google.android.material.button.MaterialButton
                android:id="@+id/add_plant"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textColor="?attr/colorPrimary"
                android:text="@string/add_plant"
                app:backgroundTint="?attr/colorOnPrimary"
                app:shapeAppearance="@style/ShapeAppearance.Sunflower.Button.Add"/>

        </LinearLayout>

    </FrameLayout>

</layout>

1. 마찬가지로 데이터바인딩이 되어있다.

 

2. 이 화면도 리사이클러뷰로 리스트 구조로 되어있므르 어댑터(GalleryAdapter)를 사용한다. 

 

3. XML을 보면 저장한 식물이 있냐 없냐에 따라 Visiblity가 결정됨을 볼 수 있다. app:isGone 은 밑과 같이 데이터바인딩 어댑터로 구현되어있다. 

[BindingAdapters.kt]

@BindingAdapter("isGone")
fun bindIsGone(view: View, isGone: Boolean) {
    view.visibility = if (isGone) {
        View.GONE
    } else {
        View.VISIBLE
    }
}

 

 

이 프래그먼트에서 사용한 GardenPlantingAdapter는 PlantList 에서 ListAdapter 로 거의 똑같으므로 생략하도록 하겠다.

class GardenPlantingAdapter :
    ListAdapter<PlantAndGardenPlantings, GardenPlantingAdapter.ViewHolder>(
        GardenPlantDiffCallback()
    ) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder(
            DataBindingUtil.inflate(
                LayoutInflater.from(parent.context),
                R.layout.list_item_garden_planting,
                parent,
                false
            )
        )
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    class ViewHolder(
        private val binding: ListItemGardenPlantingBinding
    ) : RecyclerView.ViewHolder(binding.root) {
        init {
            binding.setClickListener { view ->
                binding.viewModel?.plantId?.let { plantId ->
                    navigateToPlant(plantId, view)
                }
            }
        }

        private fun navigateToPlant(plantId: String, view: View) {
            val direction = HomeViewPagerFragmentDirections
                .actionViewPagerFragmentToPlantDetailFragment(plantId)
            view.findNavController().navigate(direction)
        }

        fun bind(plantings: PlantAndGardenPlantings) {
            with(binding) {
                viewModel = PlantAndGardenPlantingsViewModel(plantings)
                executePendingBindings()
            }
        }
    }
}

private class GardenPlantDiffCallback : DiffUtil.ItemCallback<PlantAndGardenPlantings>() {

    override fun areItemsTheSame(
        oldItem: PlantAndGardenPlantings,
        newItem: PlantAndGardenPlantings
    ): Boolean {
        return oldItem.plant.plantId == newItem.plant.plantId
    }

    override fun areContentsTheSame(
        oldItem: PlantAndGardenPlantings,
        newItem: PlantAndGardenPlantings
    ): Boolean {
        return oldItem.plant == newItem.plant
    }
}

 

 

 

 

이상 Sunlfower의 View 관련 클래스들에 대해 알아봤다.

 

 

댓글과 공감은 큰 힘이 됩니다. 감사합니다. !!

 

 

 

 

 

728x90
Comments