Filtering and detail views with internet data
Objective
-
Adding ‘for sale’ images to the overview
-
Filtering the results
-
Creating a detail page and setting up navigation
-
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
-
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") }
-
Modify the corresponding method to take type of filter, with annotating with
@Query("filter")
. So each timegetProperties()
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>
-
Add
MarsApiFilter
as a parameter forgetMarsRealEstateProperties()
method. -
Modify the call to
getProperties()
in the Retrofit service to pass query along the filter. -
In the
init {}
block forgetMarsRealEstateProperties()
to take the query parameter. -
Add an
updateFilter()
method that takes aMarsApiFilter
argument and callsgetMarsRealEstateProperties()
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 } }
- 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>
- 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
-
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 })) } }
-
Open the layout file,
fragment_detail.xml
. Note that the constraintlayout is wrapped with aScrollView
so it will automatically scroll if the view gets too large for the display. -
Add a
<data>
element to associate the detail view model with the layout, and addapp:imageUrl
attribute to the ImageView. As soon as the viewmodel updatesimgSrcUrl
, 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" />
-
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, adddisplayPropertyDetails()
to set the data for the livedata. Finally, adddisplayPropertyDetailsComplete()
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. -
Create a custom class that takes lambda with
MarsProperty
, in order to handle the click event from user. -
Add a property
onClickListener
to the constructor. -
Make the viewholder clickable, by adding
onClickListener
to the grid item in theonBindingViewHolder()
.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) } }
-
Modify the existing adapter initialization with the code.
PhotoGridAdapter
takesOnClickListener
as a parameter, which callsviewModel.displayPropertyDetails()
with the passed propertyMarsProperty
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) } ) ... }
-
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>
-
In order to deliver the data object between fragments via
SafeArgs
, we makeMarsProperty
implementParcelable
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" }
-
Implement the last bit of navigation in Overview.
viewModel.navigateToSelectedProperty.observe(this, Observer { if ( null != it ) { this.findNavController().navigate( OverviewFragmentDirections.actionShowDetail(it)) viewModel.displayPropertyDetailsComplete() } })
-
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
-
Define string resources in
strings.xml
. It will be used inDetailViewModel
.<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>
-
We will use
Transformation
to choose appropriate string from the resources with Kotlinwhen{}
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 })) }
-
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" />