Now that we've implemented the shared code, we should put it to the test. We'll start with the easier step first; that is, consuming the shared module from the Android code. This chapter will be a more concise one as teaching Android development is outside the scope of this book. With that said, I consider it important to see how that shared KMM code can be consumed by the targeted platforms.
In this chapter, we'll cover the following topics:
You can find the code files for this chapter in this book's GitHub repository at https://github.com/PacktPublishing/Simplifying-Application-Development-with-Kotlin-Multiplatform-Mobile.
Since we tested part of the shared code in Chapter 5, Writing Shared Code, we have already done most of the setup. Let's go through what we need to set up before implementing the Android app.
We'll be using Android's new UI Toolkit: Jetpack Compose. So, first, we'll need to enable it. You can find the official setup guide here: https://developer.android.com/jetpack/compose/setup#add-compose.
To enable Jetpack Compose, we'll need to add the following configurations to the build.gradle.kts file of the androidApp module, under the android{} configuration block:
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
composeOptions {
kotlinCompilerExtensionVersion =
composeVersion
}
The last step is to add all the dependencies we'll need to implement Dogify on Android.
Make sure that you add the following dependencies to the build.gradle.kts file of the androidApp module:
implementation(project(":shared"))
implementation("androidx.appcompat:appcompat:1.3.0")
// Android Lifecycle
val lifecycleVersion = "2.3.1"
implementation("androidx.lifecycle:lifecycle-
viewmodel- ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-
runtime-ktx:2.4.0-alpha02")
// Android Kotlin extensions
implementation("androidx.core:core-ktx:1.6.0")
kotlinOptions {
jvmTarget = "1.8"
}
implementation("androidx.activity:activity-
compose:1.3.0-rc02")
implementation("androidx.compose.ui:ui:$composeVersion
")
// Tooling support (Previews, etc.)
implementation("androidx.compose.ui:ui-
tooling:1.0.0-rc02")
// Foundation (Border, Background, Box, Image,
Scroll, shapes, animations, etc.)
implementation("androidx.compose.foundation:foundation
:$composeVersion")
// Material Design
implementation("androidx.compose.material:material:$co
mposeVersion")
// Material design icons
implementation("androidx.compose.material:material-
icons-core:$composeVersion")
implementation("androidx.compose.material:material-
icons-extended:$composeVersion")
val accompanistVersion = "0.13.0"
implementation("com.google.accompanist:accompanist-
coil:$accompanistVersion")
implementation("com.google.accompanist:accompanist-
swiperefresh:$accompanistVersion")
The full code is available at 06/01-android-module-setup.
Note
There is a common pattern for sharing dependency versions between the shared and Android modules. In most cases, this is done by storing it in a Kotlin file/object in buildSrc. I've refrained from this pattern for the following reasons:
• I wanted to keep the practical chapters as simple and to the point as possible.
• I'm not sure how I feel about the pattern and about tying the dependency versions for these modules together. It can be great in example projects, but production KMM apps are not that likely to live in the same repository. This is typically the case when the project is adopting KMM in already existing apps.
Now that we have all the dependencies in place, we are ready to consume the shared code.
We'll be using a simple ViewModel pattern to interact with the shared code and expose the needed data and actions to our UI, based on Android's architecture ViewModel to leverage some life cycle functionality provided by the framework.
We'll create a simple MainViewModel class in the androidApp module. Let's go through the implementation step by step.
First, let's think about what dependencies this ViewModel has:
class MainViewModel(
breedsRepository: BreedsRepository,
private val getBreeds: GetBreedsUseCase,
private val fetchBreeds: FetchBreedsUseCase,
private val onToggleFavouriteState:
ToggleFavouriteStateUseCase
) : ViewModel() {
Since we'll be communicating with the shared code, we'll make use of the three use cases for running the specific actions, and we'll listen to the stream containing the breeds from BreedsRepository.
Now, let's look at what will we expose from the ViewModel layer to View. We will do this with the help of Kotlin's Flows, exposing the whole state of the UI in multiple pieces of information:
private val _state = MutableStateFlow(State.LOADING)
val state: StateFlow<State> = _state
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing
enum class State {
LOADING,
NORMAL,
ERROR,
EMPTY
}
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events
enum class Event {
Error
}
private val _shouldFilterFavourites =
MutableStateFlow(false)
val shouldFilterFavourites: StateFlow<Boolean> =
_shouldFilterFavourites
val breeds =
breedsRepository.breeds.combine
(shouldFilterFavourites) { breeds,
shouldFilterFavourites ->
if (shouldFilterFavourites) {
breeds.filter { it.isFavourite }
} else {
breeds
}.also {
_state.value = if (it.isEmpty())
State.EMPTY else State.NORMAL
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
emptyList()
)
Now, let's see what actions we can expose for the UI in the form of functions:
fun refresh() {
loadData(true)
}
fun onToggleFavouriteFilter() {
_shouldFilterFavourites.value =
!shouldFilterFavourites.value
}
fun onFavouriteTapped(breed: Breed) {
viewModelScope.launch {
try {
onToggleFavouriteState(breed)
} catch (e: Exception) {
_events.emit(Event.Error)
}
}
}
init {
loadData()
}
private fun loadData(isForceRefresh: Boolean = false) {
val getData: suspend () -> List<Breed> =
{ if (isForceRefresh) fetchBreeds.invoke()
else getBreeds.invoke() }
if (isForceRefresh) {
_isRefreshing.value = true
} else {
_state.value = State.LOADING
}
viewModelScope.launch {
_state.value = try {
getData()
State.NORMAL
} catch (e: Exception) {
State.ERROR
}
_isRefreshing.value = false
}
}
Essentially, we're just checking if we should do a force refresh or not and calling the appropriate use case from the shared code. Based on the result of this operation, we update the "state."
This completes the implementation of our MainViewModel. The last thing we need to do is make sure that Koin knows how to inject this ViewModel. For this, we'll create an AppModule file that contains the following bean definition of our ViewModel:
val viewModelModule = module {
viewModel { MainViewModel(get(), get(), get(), get()) }
}
We also need to make sure Koin knows about this newly declared module by adding it to our initialization:
initKoin {
androidContext(this@DogifyApplication)
modules(viewModelModule)
}
The full code is available at 06/02-consuming-shared-code-android.
Now that we've tied together our Android app to the shared code, let's try it out by building the UI and testing it.
Before we start, I'd like to emphasize that I had conflicting thoughts when I was writing this chapter (as a matter of fact, the whole example project). I wanted to polish the UI as much as possible, try out the new Android 12 splash screen API, make it edge-to-edge, and so on. But at the same time, I didn't want to introduce things without explicitly talking about them in this book as well, and to do that felt out of scope.
So, consider this as me finding an excuse for why the UI looks so barebone.
Now, let's throw some Jetpack Compose code together and see how consuming the shared code can be presented on an Android UI:
@Composable
fun MainScreen(viewModel: MainViewModel) {
val state by viewModel.state.collectAsState()
val breeds by viewModel.breeds.collectAsState()
val events by
viewModel.events.collectAsState(Unit)
val isRefreshing by
viewModel.isRefreshing.collectAsState()
val shouldFilterFavourites by
viewModel.shouldFilterFavourites.collectAsState()
As you can see, first, we consume all the state-related information that our previously defined ViewModel exposes.
val scaffoldState = rememberScaffoldState()
val snackbarCoroutineScope = rememberCoroutineScope()
Scaffold(scaffoldState = scaffoldState) {
SwipeRefresh(
state =
rememberSwipeRefreshState(isRefreshing =
isRefreshing),
onRefresh = viewModel::refresh
)
Column(
Modifier
.fillMaxSize()
.padding(8.dp)
) {
Row(
Modifier
.wrapContentWidth(Alignment.End)
.padding(8.dp)) {
Text(text = "Filter favourites")
Switch(
checked = shouldFilterFavourites,
modifier = Modifier.padding
(horizontal = 8.dp),
onCheckedChange = {
viewModel.onToggleFavouriteFilter() }
)
}
when (state) {
MainViewModel.State.LOADING -> {
Spacer(Modifier.weight(1f))
CircularProgressIndicator(Modifier.align
(Alignment.CenterHorizontally))
Spacer(Modifier.weight(1f))
}
MainViewModel.State.NORMAL -> Breeds(
breeds = breeds,
onFavouriteTapped =
viewModel::onFavouriteTapped
)
MainViewModel.State.ERROR -> {
Spacer(Modifier.weight(1f))
Text(
text = "Oops something went wrong...",
modifier =
Modifier.align(Alignment.CenterHorizontally)
)
Spacer(Modifier.weight(1f))
}
MainViewModel.State.EMPTY -> {
Spacer(Modifier.weight(1f))
Text(
text = "Oops looks like there are no
${if (shouldFilterFavourites) "favourites"
else "dogs"}",
modifier =
Modifier.align(Alignment.CenterHorizontally)
)
Spacer(Modifier.weight(1f))
}
}
if (events == MainViewModel.Event.Error) {
snackbarCoroutineScope.launch {
scaffoldState.snackbarHostState.apply {
currentSnackbarData?.dismiss()
showSnackbar("Oops something went wrong...")
}
}
}
}
}
@Composable
fun Breeds(breeds: List<Breed>, onFavouriteTapped:
(Breed) -> Unit = {}) {
LazyVerticalGrid(cells = GridCells.Fixed(2)) {
items(breeds) {
Column(Modifier.padding(8.dp)) {
Image(
painter = rememberCoilPainter(request =
it.imageUrl),
contentDescription = "${it.name}-image",
modifier = Modifier
.aspectRatio(1f)
.fillMaxWidth()
.align(Alignment.CenterHorizontally),
contentScale = ContentScale.Crop
)
Row(Modifier.padding(vertical = 8.dp)) {
Text(
text = it.name,
modifier = Modifier
.align(Alignment.CenterVertically)
)
Spacer(Modifier.weight(1f))
Icon(
if (it.isFavourite)
Icons.Filled.Favorite else
Icons.Outlined.FavoriteBorder,
contentDescription = "Mark as favourite",
modifier = Modifier.clickable {
onFavouriteTapped(it)
}
)
}
}
}
}
}
class MainActivity : AppCompatActivity() {
private val viewModel by viewModel<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
MainScreen(viewModel)
}
}
}
}
Here, we're injecting the MainViewModel component we created with Koin's ViewModel functionality and setting the MainScreen composable as the content of MainActivity. If you run this code, you should be able to see the following screen:
The full code is available on branch 06/ui-android.
In this chapter, we connected our shared code to our Android application and created the Dogify UI in Jetpack Compose. We also observed that consuming the shared code was as easy as consuming a regular Android library.
In the next chapter, we'll try to do the same for iOS and see if we need to make any modifications to our shared code to make it work and easier to consume via Swift.
18.119.248.159