Kotlin onClick events and architecture best practices

Viewed 799

I have just started my first Kotlin app, and I'm aiming to learn the best practices for that language along the way (and end up with a working app of course :) ). I've come across a problem I find myself struggling with for some time: What should be the flow of an onClick event?

At first, I used the strait forward way of setting the onClick in my fragment like so: binding.image_profile_picture.setOnClickListener { onChangePhoto() }.

I went over some of the kotlin training codelabs and changed my code accordingly. One thing I understood is that it is recommended to handle events in the ViewModel and not in the fragment (codelab#3), so now my onClicks are set in the layout xml like so: android:onClick="@{() -> profileViewModel.onChangePhoto()}".

The problem with that is all of my events actually need a context as they start with some kind of dialog (like the image picker). I found this article recommending to solve this problem using an event wrapper. I read the discussion on it's implementation's Github page and decided to give it a shot (also I'm not sure I like this unnecessary ViewModel-Fragment ping-pong). I implemented aminography's OneTimeEvent and now my ViewModel looks like this:

// One time event for the fragment to listen to
    private val _event = MutableLiveData<OneTimeEvent<EventType<Nothing>>>()
    val event: LiveData<OneTimeEvent<EventType<Nothing>>> = _event

    // Types of supported events
    sealed class EventType<in T>(val func: (T) -> Task<Void>?) {
        class ShowMenuEvent(func: (Context) -> Task<Void>?, val view: View) : EventType<Context>(func)
        class ChangePhotoEvent(func: (Uri) -> Task<Void>?) : EventType<Uri>(func)
        class EditNameEvent(func: (String) -> Task<Void>?) : EventType<String>(func)
        ...
    }


    fun onShowMenu(view: View) {
        _event.value = OneTimeEvent(EventType.ShowMenuEvent(Authentication::signOut, view))
    }

    fun onChangePhoto() {
        _event.value = OneTimeEvent(EventType.ChangePhotoEvent(Authentication::updatePhotoUrl))
    }

    fun onEditName() {
        _event.value = OneTimeEvent(EventType.EditNameEvent(Authentication::updateDisplayName))
    }

    ...

and my Fragment's onCreateView looks like this:

        ...

        // Observe if an event was thrown
        viewModel.event.observe(
            viewLifecycleOwner, {
                it.consume { event ->
                    when (event) {
                        is ProfileViewModel.EventType.ShowMenuEvent ->
                            showMenu(event.func, event.view)
                        is ProfileViewModel.EventType.EditEmailEvent ->
                            showEditEmailDialog(event.func)
                        is ProfileViewModel.EventType.ChangePhotoEvent ->
                            showImagePicker(event.func)
                        ...
                    }
                }
            }
        )

        return binding.root

And if we stick to showImagePicker as an example, it looks like this:

    private fun showImagePicker(func: (Uri) -> Task<Void>?) {
        onPickedFunc = func
        val intent =
            Intent(
                Intent.ACTION_PICK,
                android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI
            )
        startActivityForResult(intent, RC_PICK_IMAGE)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == RC_PICK_IMAGE && resultCode == Activity.RESULT_OK) {
            data?.data?.let { onPickedFunc(it)?.withProgressBar(progress_bar) }
        }
    }

The reason I am passing the Authentication::updatePhotoUrl function like that instead of just calling it from the Fragment is that I want to stick to the MVVM guidelines. All the calls to FirebaseAuth API feel like a "Repository-level" to me so I handle them in my Authentication class which I don't want my Fragments to interact with directly. But this is becoming funny, as I am ending up storing those functions as members of my Fragment - and that just doesn't feel right. There has to be a neater solution than that. Please help me find it :)

Thanks! Omer

2 Answers

First, using private backing fields can be very annoying. I would recommend using an interface instead:

interface MyViewModel {
    val event: LiveData<OneTimeEvent<EventType<Nothing>>>
}

class MyViewModelImpl: MyViewModel {
    override val event = MutableLiveData<OneTimeEvent<EventType<Nothing>>>()
}

Second, if all you need is a context then you can move all the logic to the ViewModel and use something like this:

interface FragmentEvent {
    fun invoke(fragment: Fragment)
}

class ShowPickerEvent: FragmentEvent {
    override fun invoke(fragment: Fragment) {
        val intent =
            Intent(
                Intent.ACTION_PICK,
                MediaStore.Images.Media.INTERNAL_CONTENT_URI
            )
        fragment.startActivityForResult(intent, RC_PICK_IMAGE)
    }
}

In general, I think your approach to MVVM is correct and all the logic should be handled by the ViewModel while the View should pass the requests from the user to the ViewModel and display UI changes that may occur as a result of these actions (or some other changes in data).

The authentication should indeed be handled at the repository level so that authentication can be performed from several places and more importantly not to couple the authentication provider with the rest of your app logic. If you'll decide to change the authentication provider in the future, it should have as little impact as possible on your application.

also I'm not sure I like this unnecessary ViewModel-Fragment ping-pong

That's a normal reaction that everyone has to it. The benefits are decoupling of View from ViewModel, which makes the View be just a dumb class that handles user input and display results to the user, while the ViewModel is the smart class that handles the logic. This makes the logic easy to unit-test, as you don't need the entire application to be running (which a Fragment would require). Views are quite boiler-place heavy, so it's great to keep the logic in another place, where its easier to read as it isn't surrounded by a ton of view code.

My events need a context [...]

The events are just signals to the View to perform a specific action. You will not be dealing with Context in the ViewModel, instead, the View can do what it needs with its Context, once it receives the signal to do so.

Events can contain data, but not Android-specific data. If you want to for example pass a Drawable (or any other resource) through an event, you would pass only its resource ID, which the View can resolve into a Drawable using its Context.

E.g. the ViewModel emits a ChangePhotoEvent,

The whole flow of a user clicking, and it resulting in a Dialog being shown, would look like this.

The View handles the click, and tells the ViewModel about it:

android:onClick="@{() -> profileViewModel.onChangePhoto()}"

The ViewModel now determines what should happen, it can ask Repositories for data, check some conditions, etc. In this case, it just wants to display a photo picker to the user, so it sends an event to the View, with just enough information for it to know what is being asked of it:

The way you had OneTimeEvent implemented is worse than it has to be. Let me simplify it for you:

public interface OneShotEvent

abstract class BaseViewModel : ViewModel() {
    private val _events = MutableLiveData<OneShotEvent>()
    val events: LiveData<OneShotEvent> = _events

    fun postEvent(event: OneShotEvent) {
        _events.postValue(event)
    }
}
class MyViewModel: BaseViewModel() {
    
    data class ChangePhotoEvent(val updatePhotoUrl: String) : OneShotEvent

    // for events without parameters, define them as objects
    // object ParameterlessEvent : OneShotEvent

    fun onChangePhoto() {
        postEvent(ChangePhotoEvent(Authentication::updatePhotoUrl))
    }
}
class MyFragment : Fragment() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel.events.observe(this) { event ->
            when (event) {
                is ChangePhotoEvent -> {
                    // display the dialog, here you can use Context to do so
                    showImagePicker(event.updatePhotoUrl)
                }
                // omit 'is' if event is an object
                // ParameterlessEvent -> {}
            }
        }
    }
}

I didn't understand what exactly you were doing with Authentication::updatePhotoUrl. In onActivityResult, you should call the ViewModel again, with the result you received: viewModel.onPhotoChanged(data?.data), and the ViewModel should call Authentication.updatePhotoUrl(). All logic happens in the ViewModel, the View is just a relay of user-events.

If you need to retrieve any data from an API, the ViewModel has to do that, on a background thread, preferably using Coroutines. Then you can pass the data as a param of the Event.


You could check out a framework, like RainbowCake, which provides base classes and helpers for this kind of stuff. I've been using it for a while, you can see a full project where I'm using it here.

Related