일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 막내의막무가내 플러터
- 막내의막무가내 코볼 COBOL
- 막내의막무가내
- 안드로이드 Sunflower 스터디
- 막내의막무가내 rxjava
- 막내의막무가내 코틀린 안드로이드
- 막내의막무가내 안드로이드 코틀린
- 부스트코스
- 막내의막무가내 안드로이드
- 막내의막무가내 프로그래밍
- 막내의 막무가내 알고리즘
- 막내의막무가내 알고리즘
- 프로그래머스 알고리즘
- 막내의막무가내 안드로이드 에러 해결
- flutter network call
- 안드로이드
- 막내의막무가내 SQL
- 2022년 6월 일상
- 안드로이드 sunflower
- 프래그먼트
- 막무가내
- 막내의막무가내 코틀린
- 막내의막무가내 목표 및 회고
- 주택가 잠실새내
- 부스트코스에이스
- 막내의 막무가내
- 막내의막무가내 일상
- 주엽역 생활맥주
- 막내의막무가내 플러터 flutter
- Fragment
- Today
- Total
막내의 막무가내 프로그래밍 & 일상
[안드로이드] 구글 공식 프로젝트 Sunflower 스터디 (5) MVVM - View 본문
[안드로이드] 구글 공식 프로젝트 Sunflower 스터디 (5) MVVM - View
막무가내막내 2021. 4. 19. 15:22
[2021-05-08 업데이트]
[참고]
[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 객체와 함께 사용할 수 있다는 점이다. 밑 설명과 링크를 참고하면 좋을 것 같다.
[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()
}
}
위 HomeViewPagerFragment 의 ViewPager2 에서 세팅한 SunflowerPagerAdapter 에 대해 살펴봤다.
ViewPager2 + Fragment 단위로 이루어진 어댑터이므로 FragmentStateAdapter 를 상속받는 것을 볼 수 있다. ViewPager1 에서 사용하던 FragmentStatePagerAdapter는 deprecated 되고 FragmentStateAdapter 로 대체되었다
ViewPager2 에 대해서는 이전 포스팅에서 다룬적이 있다.
youngest-programming.tistory.com/336
공식문서에서 더 자세한 내용을 볼 수 있다.
developer.android.com/training/animation/vp2-migration?hl=ko
[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
자세한 내용은 공식문서에서 보면 된다.
developer.android.com/reference/androidx/recyclerview/widget/ListAdapter
이 프래그먼트에서 식물 리스트를 나타내기 위해 사용한 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
youngest-programming.tistory.com/353
4. 플러스(+) 모양의 플로팅액션버튼을 클릭하면 ViewModel의 addPlantToGarden() 를 호출해 식물이 My Garden에 저장되고 스낵바 메시지가 띄어진다. 그리고 플로팅버튼은 사라지게 된다. 그리고 이미 MyGarden에 추가된 식물은 플로팅버튼이 hide되게 구현되어 있다.
5. 툴바 클릭 시 createShareIntent() 가 호출되어 공유하기 기능이 실행된다. 카카오링크라는 공유하기 기능은 사용해봤는데 이런건 처음 봤다. 텍스트가 공유되게 구현되어있다.
추가로 XML 데이터바인딩에서 데이터바인딩 어댑터를 사용한 데이터바인딩 속성이(app:XXX) 적용되어 있는데 여기서 볼 수 있다.
Ex) 이미지로딩, Visiblity, HTML to Text 랜더링 등
이에 대해서는 이전에 데이터바인딩 어댑터 사용하는 것들을 기록해논 적이 있다.
youngest-programming.tistory.com/473
@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
[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 관련 클래스들에 대해 알아봤다.
댓글과 공감은 큰 힘이 됩니다. 감사합니다. !!
'안드로이드 > 코틀린 & 아키텍처 & Recent' 카테고리의 다른 글
[안드로이드] 구글 공식 프로젝트 Sunflower 스터디 (7) MVVM - Model (5) | 2021.05.09 |
---|---|
[안드로이드] 구글 공식 프로젝트 Sunflower 스터디 (6) MVVM - ViewModel (2) | 2021.04.27 |
[안드로이드] 구글 공식 프로젝트 Sunflower 스터디 (4) Hilt Dependency Injection (10) | 2021.04.19 |
[안드로이드] 구글 공식 프로젝트 Sunflower 스터디 (3) Jetpack Navigation 구조 (0) | 2021.04.19 |
[안드로이드] 구글 공식 프로젝트 Sunflower 스터디 (2) 패키지 구조 (0) | 2021.04.19 |