[Android Kotlin 기초] 8-3. Filtering and detail views with internet data

Filtering and detail views with internet data

Objective

  1. Adding ‘for sale’ images to the overview

  2. Filtering the results

  3. Creating a detail page and setting up navigation

  4. Creating a more useful detail page

Showing ‘for sale image’

{
   "price":8000000,
   "id":"424908",
   "type":"rent",
   "img_src": "http://mars.jpl.nasa.gov/msl-raw-images/msss/01000/mcam/1000ML0044631290305226E03_DXXX.jpg"
},

Given json response, we update MarsProperty class with type property. type property is to be indicate whether a property is for rent or not. (rent or buy)

data class MarsProperty (
	val id: String,
	@Json(name = "img_src") val imgSrcUrl: String,
        val type: String,
        val price: Double)
{
   val isRental
       get() = type == "rent"
}

Updating the grid item layout

In order to indicate the status of property, we will import View to the given xml layout, and add extra imageview over the existing ImageView.

Next, we can bind the status of visibility using the ternary operator.

<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="android.view.View"/>
       <variable
           name="property"
           type="com.example.android.marsrealestate.network.MarsProperty" />
   </data>
   <FrameLayout
       android:layout_width="match_parent"
       android:layout_height="170dp">

       <ImageView
           android:id="@+id/mars_image"
           android:layout_width="match_parent"
           android:layout_height="match_parent"
           android:scaleType="centerCrop"
           android:adjustViewBounds="true"
           android:padding="2dp"
           app:imageUrl="@{property.imgSrcUrl}"
           tools:src="@tools:sample/backgrounds/scenic"/>

       <ImageView
           android:id="@+id/mars_property_type"
           android:layout_width="wrap_content"
           android:layout_height="45dp"
           android:layout_gravity="bottom|end"
           android:adjustViewBounds="true"
           android:padding="5dp"
           android:scaleType="fitCenter"
           android:src="@drawable/ic_for_sale_outline"
           android:visibility="@{property.rental ? View.GONE : View.VISIBLE}"
           tools:src="@drawable/ic_for_sale_outline"/>
   </FrameLayout>
</layout>

Filtering the results

  1. We can update the Mars API Service. At first, we define filter with enum called MarsApiFilter to define constants that match the query values.

    enum class MarsApiFilter(val value: String) {
       SHOW_RENT("rent"),
       SHOW_BUY("buy"),
       SHOW_ALL("all") }
    
  2. Modify the corresponding method to take type of filter, with annotating with @Query("filter"). So each time getProperties() is called, the request URL includes ?filter=type portion, which directs the web service to respond with results that match that query.

    suspend fun getProperties(@Query("filter") type: String): List<MarsProperty>  
    
  3. Add MarsApiFilter as a parameter for getMarsRealEstateProperties() method.

  4. Modify the call to getProperties() in the Retrofit service to pass query along the filter.

  5. In the init {} block for getMarsRealEstateProperties() to take the query parameter.

  6. Add an updateFilter() method that takes a MarsApiFilter argument and calls getMarsRealEstateProperties() with the argument.

    enum class MarsApiStatus { LOADING, ERROR, DONE }
       
    /**
     * The [ViewModel] that is attached to the [OverviewFragment].
     */
    class OverviewViewModel : ViewModel() {
       
        // The internal MutableLiveData that stores the status of the most recent request
        private val _status = MutableLiveData<MarsApiStatus>()
       
        // The external immutable LiveData for the request status
        val status: LiveData<MarsApiStatus>
            get() = _status
       
        // Internally, we use a MutableLiveData, because we will be updating the List of MarsProperty
        // with new values
        private val _properties = MutableLiveData<List<MarsProperty>>()
       
        // The external LiveData interface to the property is immutable, so only this class can modify
        val properties: LiveData<List<MarsProperty>>
            get() = _properties
       
        // LiveData to handle navigation to the selected property
        private val _navigateToSelectedProperty = MutableLiveData<MarsProperty>()
        val navigateToSelectedProperty: LiveData<MarsProperty>
            get() = _navigateToSelectedProperty
       
       
        /**
         * Call getMarsRealEstateProperties() on init so we can display status immediately.
         */
        init {
            getMarsRealEstateProperties(MarsApiFilter.SHOW_ALL)
        }
       
        /**
         * Gets filtered Mars real estate property information from the Mars API Retrofit service and
         * updates the [MarsProperty] [List] and [MarsApiStatus] [LiveData]. The Retrofit service
         * returns a coroutine Deferred, which we await to get the result of the transaction.
         * @param filter the [MarsApiFilter] that is sent as part of the web server request
         */
        private fun getMarsRealEstateProperties(filter: MarsApiFilter) {
            viewModelScope.launch {
                _status.value = MarsApiStatus.LOADING
                try {
                    _properties.value = MarsApi.retrofitService.getProperties(filter.value)
                    _status.value = MarsApiStatus.DONE
                } catch (e: Exception) {
                    _status.value = MarsApiStatus.ERROR
                    _properties.value = ArrayList()
                }
            }
        }
       
        /**
         * Updates the data set filter for the web services by querying the data with the new filter
         * by calling [getMarsRealEstateProperties]
         * @param filter the [MarsApiFilter] that is sent as part of the web server request
         */
        fun updateFilter(filter: MarsApiFilter) {
            getMarsRealEstateProperties(filter)
        }
       
        /**
         * When the property is clicked, set the [_navigateToSelectedProperty] [MutableLiveData]
         * @param marsProperty The [MarsProperty] that was clicked on.
         */
        fun displayPropertyDetails(marsProperty: MarsProperty) {
            _navigateToSelectedProperty.value = marsProperty
        }
       
        /**
         * After the navigation has taken place, make sure navigateToSelectedProperty is set to null
         */
        fun displayPropertyDetailsComplete() {
            _navigateToSelectedProperty.value = null
        }
    }
    
    1. Open option menu and modify like below, in order to respond the interaction from the option menu at the top.
    <menu xmlns:android="http://schemas.android.com/apk/res/android">
       <item
           android:id="@+id/show_all_menu"
           android:title="@string/show_all" />
       <item
           android:id="@+id/show_rent_menu"
           android:title="@string/show_rent" />
       <item
           android:id="@+id/show_buy_menu"
           android:title="@string/show_buy" />
    </menu>
    
    1. Implement onOptionsItemSelected() method to handle menu item selections.
    override fun onOptionsItemSelected(item: MenuItem): Boolean {
       viewModel.updateFilter(
               when (item.itemId) {
                   R.id.show_rent_menu -> MarsApiFilter.SHOW_RENT
                   R.id.show_buy_menu -> MarsApiFilter.SHOW_BUY
                   else -> MarsApiFilter.SHOW_ALL
               }
       )
       return true
    }
    

Creating a detail page and setting up navigation

  1. Create the detail viewmodel and pass MarsProperty as the argument.

    class DetailViewModel( marsProperty: MarsProperty,
                          app: Application) : AndroidViewModel(app) {
       
        // The internal MutableLiveData for the selected property
        private val _selectedProperty = MutableLiveData<MarsProperty>()
       
        // The external LiveData for the SelectedProperty, Encapsulation!
        val selectedProperty: LiveData<MarsProperty>
            get() = _selectedProperty
       
        // Initialize the _selectedProperty MutableLiveData, Initialization
        init {
            _selectedProperty.value = marsProperty
        }
       
        // The displayPropertyPrice formatted Transformation Map LiveData, which displays the sale
        // or rental price.
        val displayPropertyPrice = Transformations.map(selectedProperty) {
            app.applicationContext.getString(
                    when (it.isRental) {
                        true -> R.string.display_price_monthly_rental
                        false -> R.string.display_price
                    }, it.price)
        }
       
        // The displayPropertyType formatted Transformation Map LiveData, which displays the
        // "For Rent/Sale" String
        val displayPropertyType = Transformations.map(selectedProperty) {
            app.applicationContext.getString(R.string.display_type,
                    app.applicationContext.getString(
                            when(it.isRental) {
                                true -> R.string.type_rent
                                false -> R.string.type_sale
                            }))
        }
    }
    
  2. Open the layout file, fragment_detail.xml . Note that the constraintlayout is wrapped with a ScrollView so it will automatically scroll if the view gets too large for the display.

  3. Add a <data> element to associate the detail view model with the layout, and add app:imageUrl attribute to the ImageView. As soon as the viewmodel updates imgSrcUrl , it will automatically loads the image by Glide.

    <data>
        <variable
         name="viewModel"
     	     	type="com.example.android.marsrealestate.detail.DetailViewModel" />
    </data>
    
    <ImageView
        android:id="@+id/main_photo_image"
        android:layout_width="0dp"
        android:layout_height="266dp"
        android:scaleType="centerCrop"
        app:imageUrl="@{viewModel.selectedProperty.imgSrcUrl}"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:src="@tools:sample/backgrounds/scenic" />
    
  4. As seen in the code of OverviewViewModel, we can modify the code for the navigation. At first, declare and define the livedata for the property information. Secondly, add displayPropertyDetails() to set the data for the livedata. Finally, add displayPropertyDetailsComplete() which we can set livedata to null. This is required for navigation state to complete, and to avoid the navigation being triggered again when the user returns from the detail.

  5. Create a custom class that takes lambda with MarsProperty, in order to handle the click event from user.

  6. Add a property onClickListener to the constructor.

  7. Make the viewholder clickable, by adding onClickListener to the grid item in the onBindingViewHolder().

    class PhotoGridAdapter( private val onClickListener: OnClickListener ) :
            ListAdapter<MarsProperty,
                    PhotoGridAdapter.MarsPropertyViewHolder>(DiffCallback) {
       
        /**
         * The MarsPropertyViewHolder constructor takes the binding variable from the associated
         * GridViewItem, which nicely gives it access to the full [MarsProperty] information.
         */
        class MarsPropertyViewHolder(private var binding: GridViewItemBinding):
                RecyclerView.ViewHolder(binding.root) {
            fun bind(marsProperty: MarsProperty) {
                binding.property = marsProperty
                // This is important, because it forces the data binding to execute immediately,
                // which allows the RecyclerView to make the correct view size measurements
                binding.executePendingBindings()
            }
        }
       
        /**
         * Allows the RecyclerView to determine which items have changed when the [List] of [MarsProperty]
         * has been updated.
         */
        companion object DiffCallback : DiffUtil.ItemCallback<MarsProperty>() {
            override fun areItemsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
                return oldItem === newItem
            }
       
            override fun areContentsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
                return oldItem.id == newItem.id
            }
        }
       
        /**
         * Create new [RecyclerView] item views (invoked by the layout manager)
         */
        override fun onCreateViewHolder(parent: ViewGroup,
                                        viewType: Int): MarsPropertyViewHolder {
            return MarsPropertyViewHolder(GridViewItemBinding.inflate(LayoutInflater.from(parent.context)))
        }
       
        /**
         * Replaces the contents of a view (invoked by the layout manager)
         */
        override fun onBindViewHolder(holder: MarsPropertyViewHolder, position: Int) {
            val marsProperty = getItem(position)
            holder.itemView.setOnClickListener {
                onClickListener.onClick(marsProperty)
            }
            holder.bind(marsProperty)
        }
       
        /**
         * Custom listener that handles clicks on [RecyclerView] items.  Passes the [MarsProperty]
         * associated with the current item to the [onClick] function.
         * @param clickListener lambda that will be called with the current [MarsProperty]
         */
        class OnClickListener(val clickListener: (marsProperty:MarsProperty) -> Unit) {
            fun onClick(marsProperty:MarsProperty) = clickListener(marsProperty)
        }
    }
    
  8. Modify the existing adapter initialization with the code. PhotoGridAdapter takes OnClickListener as a parameter, which calls viewModel.displayPropertyDetails() with the passed property MarsProperty object.

    override fun onCreateView(
        inflater: LayoutInflater, 
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View?
    {
      	...
        val binding = FragmentOverviewBinding.inflate(inflater)
        binding.photosGrid.adapter = PhotoGridAdapter(PhotoGridAdapter.OnClickListener {
                                         viewModel.displayPropertyDetails(it)
                                       }
        														)
       ...
    }
    
  9. Add argument under the <fragment> tag. By doing this, each fragment can trasnfer the data as described in the navigation graph.

    <navigation>
    ...
        <fragment
            android:id="@+id/detailFragment"
            android:name="com.example.android.marsrealestate.detail.DetailFragment"
            android:label="fragment_detail"
            tools:layout="@layout/fragment_detail">
            <argument
                android:name="selectedProperty"
                app:argType="com.example.android.marsrealestate.network.MarsProperty" />
        </fragment>
    </navigation>
    
  10. In order to deliver the data object between fragments via SafeArgs, we make MarsProperty implement Parcelable interface. Kotlin provides an easy shortcut for implementing that interface.

    @Parcelize
    data class MarsProperty (
            val id: String,
            // used to map img_src from the JSON to imgSrcUrl in our class
            @Json(name = "img_src") val imgSrcUrl: String,
            val type: String,
            val price: Double) : Parcelable {
        val isRental
            get() = type == "rent"
    }
    
  11. Implement the last bit of navigation in Overview.

    viewModel.navigateToSelectedProperty.observe(this, Observer {
       if ( null != it ) {   
          this.findNavController().navigate(
                  OverviewFragmentDirections.actionShowDetail(it))             
          viewModel.displayPropertyDetailsComplete()
       }
    })
    
  12. Add these lines in Detail, to receive bundle from the previous fragment, and initialize the ViewModel using ViewModelFactory.

    val marsProperty = DetailFragmentArgs.fromBundle(arguments!!).selectedProperty
    val viewModelFactory = DetailViewModelFactory(marsProperty, application)
    binding.viewModel = ViewModelProvider(this, viewModelFactory).get(DetailViewModel::class.java)
    

Creating a more useful detail page

  1. Define string resources in strings.xml. It will be used in DetailViewModel.

    <string name="type_rent">Rent</string>
    <string name="type_sale">Sale</string>
    <string name="display_type">For %s</string>
    <string name="display_price_monthly_rental">$%,.0f/month</string>
    <string name="display_price">$%,.0f</string>
    
  2. We will use Transformationto choose appropriate string from the resources with Kotlin when{} switch.

    val displayPropertyPrice = Transformations.map(selectedProperty) {
       app.applicationContext.getString(
               when (it.isRental) {
                   true -> R.string.display_price_monthly_rental
                   false -> R.string.display_price
               }, it.price)
    }
       
    val displayPropertyType = Transformations.map(selectedProperty) {
       app.applicationContext.getString(R.string.display_type,
               app.applicationContext.getString(
                       when (it.isRental) {
                           true -> R.string.type_rent
                           false -> R.string.type_sale
                       }))
    }
    
  3. Replace the text with this code, to bind the result from the transformation in the previous code.

    <TextView
        android:id="@+id/property_type_text"
        ...
        android:text="@{viewModel.displayPropertyType}"
        ...
        tools:text="To Rent" />
       
    <TextView
        android:id="@+id/price_value_text"
        ...
        android:text="@{viewModel.displayPropertyPrice}"
        ...
        tools:text="$100,000" />