DEV Community

loading...
Cover image for Humble Views, Proud ViewModels

Humble Views, Proud ViewModels

Marcello Galhardo
Updated on ・6 min read

The Android Community has long advocated that Activities and Fragments were views  -  but this perception has changed over time. For good. Let's dive deep into how to design views and view models, how they wire to a LifecycleOwner, and how this can positively impact your's app testability.

Sign-Up Form

To better describe how to build humble views we will be developing an elementary Sign-Up form with an email, a password text field and two buttons: a cancel that pops the user's back stack and a sign up that creates an account and moves the user to the home screen.

"Fragment is not your View" - Juliano Moraes

Heads-up: this article expects you to be familiar with Dependency Injection (but no particular framework), Coroutines, Fragment, and View Binding. I won't use Jetpack's ViewModel, but the code here is entirely compatible. I will not cover any other aspects outside humble Views and ViewModels.

Views

Views represent your UI. They can be written in code or, the most common way, using XML. Android views can not have a custom constructor as they must be created by the OS using reflection when inflating it from XML, during a configuration change, or process death.

Views should not be aware of your architecture decisions. If you use MVP, MVC, MVVM, or MVI is irrelevant for a well-designed view.

Views are hard to test and require an Instrumentation or Robolectric environment, which makes them slow. For that reason, we might want our views to be a Humble Object.

class SignUpView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : ConstraintLayout(context, attrs) {

    private val binding = SignUpViewBinding.inflate(LayoutInflater.from(context), this)

    var email: String
        get() = binding.email.text
        set(value) { binding.email.setText(value) }

    var password: String
        get() = binding.email.text
        set(value) { binding.email.setText(value) }

    var isSignUpEnabled: Boolean
        get() = binding.signUpButton.isEnabled
        set(value) { binding.signUpButton.isEnabled = value }

    var onSignUpClicked: () -> Unit = {}
    var onCancelClicked: () -> Unit = {}
    var onEmailChanged: (String) -> Unit = {}
    var onPasswordChanged: (String) -> Unit = {}

    init {
        binding.signUpButton.setOnClickListener { onSignUpClicked() }
        binding.onCancelClicked.setOnClickListener { onCancelClicked() }
        binding.email.doOnTextChanged { text, _, _, _ ->
            onEmailChanged(text)
        }
        binding.password.doOnTextChanged { text, _, _, _ ->
            onPasswordChanged(text)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

What is going on with this ViewBinding?

In my View's XML root, I use a <merge> tag, and ViewBinding understands that: it provides me with an inflate function that attaches the view automatically to its parent.

ViewModels

ViewModel's are a lightweight representation of your UI. The ViewModel is responsible for coordinating any user interaction to any business model required. It may offer a one or two-way binding to let those business models interact with the GUI.

class SignUpViewModel(
  private val savedStateHandle: SavedStateHandle,
  private val scope: CoroutineScope,
  private val repository: SignUpRepository,
) {

  private val _navigation = Channel<Navigation>()
  val navigation = _navigation.receiveAsFlow()

  private val _toastMessage = Channel<ToastMessage>()
  val toastMessage = _toastMessage.receiveAsFlow()

  val email = savedStateHandle.getStateFlow<String>(scope, "email", "")

  val password = savedStateHandle.getStateFlow<String>(scope, "password", "")

  private val _isSignUpEnabled = savedStateHandle.getStateFlow<Boolean>(scope, "isSignUpEnabled", false)
  val isSignUpEnabled = _isSignUpEnabled.asStateFlow()

  init {
    email.collectIn(scope) { updateSignUpEnabled() }
    password.collectIn(scope) { updateSignUpEnabled() }
  }

  fun onSignUpClicked() {
    runCatching {
      // Try to parse e-mail and validate password. If yes, save.
      val email: String = // parse e-mail.
      val password: String = // verify if password is acceptable.
      repository.signUp(email, password)
    }.fold(
      onSuccess = { _navigation.sendIn(scope, Navigation.Push(SignUpRoutes.Home)) },
      onFailure = { _toastMessage.sendIn(scope, ToastMessage(R.string.generic_error)) },
    )
  }

  fun onCancelClicked() {
    _navigation.sendIn(scope, Navigation.Pop)
  }

  private fun updateSignUpEnabled() {
    // Naive logic to enable sign up.
    _isSignUpEnabled = email.isNotBlank() && password.isNotBlank()
  }
}
Enter fullscreen mode Exit fullscreen mode

Why expose some flows as MutableStateFlow and others no?

Each field has a different requirement: two-way or one-way binding.

TextFields requires a two-way binding as we want to get updates when the user types a new content while preserving the ability to update its content whenever necessary. Exposing it as a MutableStateFlow allows us to move all sanitization logic to the view model (see onSignUpClicked) and ensure the View is as humble as possible.

The sign-up button state requires a one-way binding (see isSignUpEnabled)  -  only the ViewModel can change its state based in a validation logic.

Why not modeling my UI State as a single flow in the ViewModel?

The concepts here do not exclude a single view state, I believe they complement each other.

You can easily keep your view humble while modeling UI State as a single object on top of the ViewModel. In this case, the ViewModel would be responsible for handling the unidirectional data flow and doing the required diffs between each state emission within your bindings to enforce your UI is not being updated without demand. Therefore, let your view humble allow you to keep it agnostic of how you handle state, architecture decisions, and it enable you to test your ViewModel and classes above it as a unit very closely of how it would behave in the real world with a GUI: remember, the View is humble!

I will not cover these concepts here but for a better understanding of why you would like to model your UI State as a single sealed hierarchy and how to do it well, I highly recommend my friend's Stojan Anastasov post about the subject: Modeling UI State.

LifecycleOwner

The UI Controller. Usually, a Fragment responsible for a section of the screen. A LifecycleOwner is responsible to wire up a View to a ViewModel, delegate callbacks from the Android OS, handle configuration changes and more.

class SignUpFragment : Fragment() {

    // Retain the instance across configuration changes.
    private val viewModel by retain { entry ->
        val repository: SignUpRepository = // creates the repository
            SignUpViewModel(entry.savedStateHandle, entry.scope, repository)
    }

    // Creates your View and connects to the ViewModel.
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val view = SignUpView(requireContext())

        // Bind the View and ViewModel
        view.onSignUpClicked = viewModel::onSignUpClicked
        view.onCancelClicked = viewModel::onCancelClicked
        viewModel.email.observeIn(viewLifecycleOwner, view::email::set)
        viewModel.password.observeIn(viewLifecycleOwner, view::password::set)
        viewModel.isSignUpEnabled.observeIn(viewLifecycleOwner, view::isSignUpEnabled::set)
        // Other binds you might need with the view...
        viewModel.navigation.observeIn(viewLifecycleOwner) { navigation ->
            // Handle navigation.
        }
        viewModel.toastMessage.observeIn(viewLifecycleOwner) { message ->
            // Handle toast message.
        }

        return view
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing

We designed our View as a Humble Object to let us quickly test both View and ViewModel. As you might have perceived, the ViewModel is straightforward to write tests as we do not depend on any Android related class.

class SignUpViewModelTest {

  private val scope = TestCoroutineScope()

  // A fake version of the data source to not cross boundaries like network and database.
  private val dataSource = TestDataSource()

  private fun createSut(): SignUpViewModel {
    return SignUpViewModel(SavedStateHandle(), scope, SignUpRepository(dataSource))
  }

  // Set up, tear down, other tests, etc...

  @Test
  fun `given email and password field is not blank when signing up then navigate to the home` {
    // Arrange Phase
    val sut = createSut().apply {
      email = "my.fake.person@gmail.com"
      password = "8S#@2LAaB_.NJh(Y"
    }
    var actualNavigation: Navigation? = null
    scope.launch { sut.navigation.collect { actualNavigation = it } }

    // Act Phase
    sut.onSignUpClicked()

    // Assert Phase
    val expectedNavigation = Navigation.Push(SignUpRoutes.Home)
    assertThat(actualNavigation).isEqualTo(expectedNavigation)
  }
}
Enter fullscreen mode Exit fullscreen mode

As the view is a humble object, there is nothing much to test on it. However, we might want to ensure that the UI behaves as expected when the ViewModel changes its attributes. For example:

class SignUpViewTest {

  @Test
  fun `given sign up is disabled then the button should not be clickable`() {
    launchViewInFragment { SignUpView(requireContext()) }
      .onView { it.isSignUpEnabled = false }

    onView(withId(R.id.signUpButton))
      .check(matches(not(isEnabled())))
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

It is relatively easy to see that View's, in Android, are strongly tied to the concept of their functionality. Humble View's is a way to create a clear separation between them and the Android Framework aiming for scalability, readability, and maybe the most essential point, testing.

In this article, we applied Humble Object pattern and MVVM concepts to create the circumstances that would allow developers to test View's and ViewModel's with ease even when they are strongly tied conceptually. Additionally, understanding that the LifecycleOwner is not your View will help you to ensure your code is highly testable.

Other than testing, projects that use this approach can easily compose more complex View's, and ViewModel's, from smaller objects. Lastly, this approach fit with Jetpack's Compose: replace the View with a @Composable and reuse the ViewModel!

Happy Coding! 😎

Helper functions & credits!

I used Retained library to keep the state of the ViewModel in configuration changes. I also published all the helper functions as Github GISTs, so you can use them as you please.

Special thanks to Maria Chietera, Tiago Dávila, Rafael Araujo, Tiago Cunha, Felipe Pedroso, and Stojan Anastasov proofread review! 🔍

If you like my posts, follow me on Twitter: @marcellogalhard

Cover Photo by Francesco Ciccolella.

Discussion (4)

Collapse
_anpurnama profile image
Aditya Nanda Purnama

Hi, I've never seen an approach to Android view like this so thank you for writing this post.
And this approach also applicable for activity too?

Collapse
marcellogalhardo profile image
Marcello Galhardo Author • Edited

Hey, @_anpurnama , glad to hear that. 😄

Definitely! The same example with activities would look like the following:

class SignUpActivity : AppCompatActivity() {

    private val viewModel by TODO("ViewModel declaration")

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

        val view = SignUpView(this)

        // Bind the View and ViewModel

        setContentView(view)
    }
}
Enter fullscreen mode Exit fullscreen mode

All other aspects are the same.

Collapse
raphaelbgr profile image
Raphael Bernardo

Awesome approach, this completely isolated UI logic. Can we say this could be also a "CustomView" not for a single element, but for an entire view?

Collapse
marcellogalhardo profile image
Marcello Galhardo Author

I'm glad you like. (:

Hm, I'm not sure if I understand your question and what is your definition of "entire view" but I'd say it is a custom view, yes.