We still have an Apply button that doesn't work. We'll make this button open the URL contained in the job listing in the browser.
The flow for this will be the following:
- The user presses the Apply button.
- The click listener in the
Fragment
calls theViewModel
. - The
ViewModel
fetches the URL for the current job from the lower layers in a coroutine. - The
ViewModel
somehow notifies theFragment
that the URL it fetched should be opened for browsing...
This last part is what we need to tackle first, the rest will be fairly straightforward based on the things we've already done.
So far, when a ViewModel
needed to communicate with its Fragment
, we did so by updating the state, which triggered the Observer
in the Fragment
. This URL, however, isn't really a part of our view state, for example, it's not displayed on the UI.
We could somehow work it into view state classes, and have it be a null
value by default, and set it to the actual URL after we've fetched it to signal that we should navigate to it, and handle this in render
. But then if we come back to the Fragment
again, render
will run, and the actual URL will still be in the state object, triggering navigation again... It would get messy, because we'd be breaking the semantics of view state.
What we need here instead is a way for the ViewModel
to notify the Fragment
a single time of something that it should do, without changing its persistent state. This URL being fetched is a contrived example to fit our demo application. A network error Toast message to display, or the need to navigate away from the current screen to the next one after a long-running background save has completed in an asynchronous coroutine would be the more common examples of when we need this sort of communication.
Enter events, the architecture's solution to this problem. We'll use a special LiveData
implementation which doesn't persistently store the data, but instead only delivers it a single time, to a single Observer
. This will still happen in a lifecycle-safe manner, however.
This mechanism is already set up in BaseViewModel
and BaseFragment
.
In BaseViewModel
, there's an instance of SingleShotLiveData
stored in a private property, and exposed through the LiveData
interface in a public property. There's also a helper method for ViewModel
subclasses to call in order to post events into this LiveData
.
private val viewEvents = SingleShotLiveData<OneShotEvent>()
val events: LiveData<OneShotEvent> = viewEvents
protected fun postEvent(event: OneShotEvent) = viewEvents.postValue(event)
This LiveData
is observed in BaseFragment
the same way that state changes are, in onViewCreated
:
viewModel.events.observe(viewLifecycleOwner, Observer { event ->
event?.let { onEvent(it) }
})
The onEvent
method is the equivalent of the render
method for this flow, except it's not mandatory to override it, as not all Fragment
s need event support.
Finally, OneShotEvent
is nothing but a marker interface that concrete events should implement for some increased type safety and code legibility. We could be posting Any
typed objects as events as well if we wanted to.
Let's create an event class nested inside our JobDetailViewModel
, implementing the marker interface:
class BrowseUrlEvent(val url: String) : OneShotEvent
It has a single parameter, the URL to browse. If it had no parameters, we could make it an object
, just like we do with certain state classes.
We'll add a browse
method to our JobDetailViewModel
which fetches the URL of a given job, and then posts an instance of this event to JobDetailFragment
:
fun browse(jobListingId: String) = launch {
val url = jobDetailPresenter.getUrl(jobListingId)
postEvent(BrowseUrlEvent(url))
}
The getUrl
method isn't implemented in JobDetailPresenter
yet, let's add this implementation next:
suspend fun getUrl(jobListingId: String): String = withContext(Contexts.IO) {
jobsInteractor.getJobUrlById(jobListingId)
}
JobInteractor
already has this functionality implemented, and we don't need to do any mapping in this case between the domain and presentation models. We'll just return the primitive String
type as is.
Finally, let's set up everything in JobDetailFragment
. We'll need a button listener that triggers the entire flow, we can set this up in onViewCreated
:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initArguments()
applyButton.setOnClickListener {
viewModel.browse(jobListingId)
}
}
Then, when the event comes back as a result, we need to handle it in onEvent
:
override fun onEvent(event: OneShotEvent) {
when (event) {
is BrowseUrlEvent -> {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(event.url)
startActivity(intent)
}
}
}