UI layer (Views)

Concepts and Jetpack Compose implementation

The role of the UI is to display the application data on the screen and also to serve as the primary point of user interaction. Whenever the data changes, either due to user interaction (like pressing a button) or external input (like a network response), the UI should update to reflect those changes. Effectively, the UI is a visual representation of the application state as retrieved from the data layer.

However, the application data you get from the data layer is usually in a different format than the information you need to display. For example, you might only need part of the data for the UI, or you might need to merge two different data sources to present information that is relevant to the user. Regardless of the logic you apply, you need to pass the UI all the information it needs to render fully. The UI layer is the pipeline that converts application data changes to a form that the UI can present and then displays it.

Expose UI state

After you define your UI state and determine how you will manage the production of that state, the next step is to present the produced state to the UI. Because you're using UDF to manage the production of state, you can consider the produced state to be a stream—in other words, multiple versions of the state will be produced over time. As a result, you should expose the UI state in an observable data holder like LiveData or StateFlow. The reason for this is so that the UI can react to any changes made in the state without having to manually pull data directly from the ViewModel. These types also have the benefit of always having the latest version of the UI state cached, which is useful for quick state restoration after configuration changes.

class NewsViewModel(...) : ViewModel() {

    val uiState: StateFlow<NewsUiState> = 
}

A common way of creating a stream of UiState is by exposing a backing mutable stream as an immutable stream from the ViewModel—for example, exposing a MutableStateFlow<UiState> as a StateFlow<UiState>.

class NewsViewModel(...) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    ...

}

The ViewModel can then expose methods that internally mutate the state, publishing updates for the UI to consume. Take, for example, the case where an asynchronous action needs to be performed; a coroutine can be launched using the viewModelScope, and the mutable state can be updated upon completion.

class NewsViewModel(
    private val repository: NewsRepository,
    ...
) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    private var fetchJob: Job? = null

    fun fetchArticles(category: String) {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            try {
                val newsItems = repository.newsItemsForCategory(category)
                _uiState.update {
                    it.copy(newsItems = newsItems)
                }
            } catch (ioe: IOException) {
                // Handle the error and notify the UI when appropriate.
                _uiState.update {
                    val messages = getMessagesFromThrowable(ioe)
                    it.copy(userMessages = messages)
                }
            }
        }
    }
}

Consume UI state

When consuming observable data holders in the UI, make sure you take the lifecycle of the UI into consideration. This is important because the UI shouldn't be observing the UI state when the view isn't being displayed to the user. To learn more about this topic, see this blog post. When using LiveData, the LifecycleOwner implicitly takes care of lifecycle concerns. When using flows, it's best to handle this with the appropriate coroutine scope and the repeatOnLifecycle API:

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

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

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

Show in-progress operations

A simple way to represent loading states in a UiState class is with a boolean field:

data class NewsUiState(
    val isFetchingArticles: Boolean = false,
    ...
)

This flag's value represents the presence or absence of a progress bar in the UI.

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

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

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Bind the visibility of the progressBar to the state
                // of isFetchingArticles.
                viewModel.uiState
                    .map { it.isFetchingArticles }
                    .distinctUntilChanged()
                    .collect { progressBar.isVisible = it }
            }
        }
    }
}

Animations

In order to provide fluid and smooth top-level navigation transitions, you might want to wait for the second screen to load data before starting the animation. The Android view framework provides hooks to delay transitions between fragment destinations with the postponeEnterTransition() and startPostponedEnterTransition() APIs. These APIs provide a way to ensure that the UI elements on the second screen (typically an image fetched from the network) are ready to be displayed before the UI animates the transition to that screen.