Sanet - ST KotlinMultTutorials
Sanet - ST KotlinMultTutorials
Sanet - ST KotlinMultTutorials
macOS Monterey: You’ll need macOS to compile the iOS-specific code and
run the tests targeting iOS.
2
Kotlin Multiplatform by Tutorials
https://github.com/raywenderlich/kmpf-materials/tree/editions/1.0
Forums
We’ve also set up an official forum for the book at
https://forums.raywenderlich.com/c/books/kotlin-multiplatform-by-tutorials.
This is a great place to ask questions about the book or to submit any errors you
may find.
3
Kotlin Multiplatform by Tutorials
iii Dedications
“To my wife and family for putting up with me for always being on the
computer and letting me create and learn.”
— Kevin Moore
“To Beatriz, José, and Petá for being my guiding light. To Carlos and
Maria for always giving everything and more for my happiness and
future. My most sincere and forever thank you.
To Daniela, for all the love, companionship and support. For always
finding the brightness of every scenario.
To all my friends who are always a phone call away. A special mention
to Ricardo for all those architecture discussions, to Andreia and Curto
for the lovely dinners, and to Fred and Jorge, the iOS developers who
embraced Kotlin.
— Carlos Mota
“To my wife, Leila, and my dad, who will never read this book! Their
unconditional love, patience and support made me courageous enough
to write a book.”
— Saeed Taheri
4
Kotlin Multiplatform by Tutorials
5
Kotlin Multiplatform by Tutorials About the Team
6
Kotlin Multiplatform by Tutorials
vi Introduction
If your goal is to leverage Kotlin to share code among your native apps, this is
the book for you.
You can use Kotlin Multiplatform to share code between your Android, iOS and
desktop apps but there are multiple considerations. You should be able to
develop the UI natively using the framework of your choice. Using the right
frameworks can drastically reduce the UI development time and provide you
with flexible APIs.
At the same time, you need to figure out how Kotlin Multiplatform fits in with
your current architecture and how you can access platform-specific APIs.
Choosing the right architecture can make your app testable, maintainable and
easy to work with.
Then you need to figure out which layers of your app you can migrate to a
shared module and how you can use different libraries to assist this migration.
Finally, you should be able to publish and share your shared module so that you
can use it across apps on multiple platforms.
To learn and notice every little detail, read the chapters in order. However, this
book is for advanced users, and you might want to skip some chapters. In that
case, be sure to continue from the starter project of the chapter you’re moving
to. The starter projects contain all steps implemented in the previous chapters
of a certain section.
While going through the chapter, you can type the code in Android Studio
immediately. Feel free to play with the code and investigate the references
7
Kotlin Multiplatform by Tutorials Introduction
provided in the chapter. Some of the chapters contain fun challenges for you to
expand upon the topics you learned.
In this section, you’ll learn how to add a new Gradle module to write your
business logic only once. You’ll also learn how to create the native UI for
Android, iOS and desktop apps, all while sharing the common module.
In this section, you’ll learn how to use Kotlin features to access platform-
specific APIs in your shared module and how Kotlin Multiplatform fits in with
your current architecture. You’ll also learn about dependency injection and
how you can use it to test features present in your shared modules. Finally,
you’ll learn how to use a common codebase to handle persistence on different
platforms.
In this section, you’ll learn how to use serialization to decode JSON data to
Kotlin objects. You’ll then learn how to use a common networking library that
leverages this common serialization to fetch data from the internet. To make
the networking performant, you’ll also learn about concurrency in Kotlin using
coroutines and the considerations for different platforms. Finally, you’ll learn
how to extract an existing feature to a Kotlin Multiplatform library and also
different ways of publishing this library.
8
Kotlin Multiplatform by Tutorials
1 Introduction
Written by Kevin D Moore
Congratulations! By reading this book, you’re taking the first step toward
learning how to write less code. In three sections, you’ll learn how to use Kotlin
Multiplatform (KMP) to set up and write iOS, Android and desktop apps using
the latest user interface (UI) technologies.
In this book, you’ll develop several different apps — a time zone meeting helper,
an app to track your list of things to do and an app that displays a list of all
raywenderlich.com’s books, articles and videos.
You’ll learn how to leverage KMP by sharing business logic across platforms and
creating customized native UIs in each platform. And, you’ll learn how to write
tests for all your business logic, use the popular JetBrains library Ktor to handle
network calls and, of course, use Kotlin Coroutines to handle concurrency.
This book requires some knowledge in mobile development but will walk you
through the setup for both iOS and Android, as well as for desktop apps. While
most of the book uses Kotlin, iOS developers familiar with Swift will be able to
pick up Kotlin easily.
In this chapter, you’ll learn about Kotlin Multiplatform and the history of cross-
platform frameworks. At the end of the chapter, you’ll set up your environment,
create a new project and run your project on Android and iOS.
9
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
KMP supports the following platforms:
Android
iOS
macOS
watchOS
tvOS
Windows
Linux
Web
That’s a lot of platforms. Some, like the web, are not stable at the moment.
History of cross-platform
For as long as there have been both iOS and Android devices, developers have
considered the holy grail of app development to be one codebase that could run
on both. Many frameworks have tried to achieve multiplatform development,
including:
PhoneGap: One of the earliest, PhoneGap enabled you to write mobile apps
using HTML5, CSS3 and JavaScript. It was discontinued in 2020.
10
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
Xamarin: Microsoft-owned C#-based development framework that includes
the .NET runtime. The framework is compiled for iOS — so it’s faster on iOS
than Android, which uses a just-in-time compilation.
Flutter: This is the new kid on the block, and it works on all platforms. One of
the main benefits of this framework is that you can write almost all your UI
once. Some UI will need to be different based on the platform. For example,
desktop and web don’t need a toolbar. One of the disadvantages is that it’s
written using the Dart language, which many developers don’t know. Dart
has only recently gotten null safety, and a lot of packages use code
generation, which has to be done manually.
A lot of the web-based frameworks are falling out of favor. Flutter is going
strong, but many question the use of Dart.
History of Kotlin
Kotlin has been around officially since July 2011. JetBrains released version 1.0
on February 15, 2016, and it was announced at Google I/O 2017 as a first-class
language for Android development. JetBrains developed Kotlin because most
languages didn’t have the features they were looking for. JetBrains now uses
Kotlin as its preferred development language for all current work — slowly
replacing Java.
Why Kotlin?
Why should you use Kotlin? Because it’s one of the only languages that you can
compile for both JVM and native and use on iOS as well as the desktop and web.
Kotlin is ideal for server work as well. With the Ktor library, networking is an
easy task. Writing common business logic ensures that all platforms behave the
same way and you only need to test once. It uses the same code for all platforms,
reducing the possibility of errors and speeding up development. Each team can
use as much shared code as they want. Start slowly with existing projects, or
start writing all your business logic with new projects.
iOS developers are familiar with Swift, and Kotlin is very similar — so the
learning curve should be minimal. Developers still use Swift on the UI side, but
they can also work and help out with business logic in Kotlin. Since there will
still be a lot of iOS development work needed, iOS developers will be included in
all parts of development.
11
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
How much code to share is up to the team. If you have an existing app, you can
slowly move over your business logic so you have a shared set of code that you
can test once.
KMP adds minimal extra size to an app. The standard library is small and you
only need to include the parts you use.
KMM: KMM, while newer, has become very popular. Lots of apps in the app
stores already use it. Many companies find that writing their business logic once
— instead of on both iOS and Android — saves the team a lot of time. The UIs are
native, making the mobile developers happy, and the users are happy they have
a fast experience.
Layers
Most apps consist of different layers. There’s typically a network layer, a
database layer (if needed), a repository layer that interacts with the database, a
business logic layer (not always) and a UI layer. KMP doesn’t provide a UI layer;
you’ll use the native UI instead.
Business Logic
Most companies now have teams of iOS and Android developers. Each team
12
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
Database
You can write the database layer using SQLite on mobile and desktop using the
SQLDelight library. This library is a multiplatform package designed to run on
all these platforms. Imagine having to write this set of code only once. Not only
will you write only one set of lower-level SQL database insertions, deletions and
updates, but your repository layer only needs to be written once. SQLDelight
uses SQL statements to generate code for you. You only need to test once.
UI
Since KMM doesn’t provide a UI layer, you can use whichever UI system you
want. For iOS, developers are turning to SwiftUI: a nice, declarative UI toolkit
that makes it easy to create beautiful UIs. Now that Jetpack Compose has been
released as stable, Android developers can use it. Cross-platform desktop UIs
have been neglected for quite a while. Swing has been a standard for some time,
but it’s old and unmaintained. JetBrains hopes to replace it with Compose for
Desktop. It uses a lot of Android’s Jetpack Compose underneath, with a layer of
desktop code. Currently, there’s an issue with package names with Android
using androidx and desktop using org.jetbrains. JetBrains hope to resolve this
in the future.
Is it Native?
One of the questions most often asked is: Does it use native controls? The
answer is yes. Since KMP doesn’t provide any UI layer, all UI is drawn natively.
On Android, that can be the built-in View system or the new Jetpack Compose
library. On iOS, you can use the built-in native UI or the newer SwiftUI. On the
desktop, you can use the older Java Swing or the newer Desktop Compose. For
the Mac desktop, you can also use SwiftUI or AppKit. For Android, code is
generated as Java class files, while iOS uses LLVM to produce native code and
create an Xcode framework library.
13
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
Since Android apps are built with Kotlin and have been for years, there are no
compatibility issues. iOS and macOS apps interact with frameworks built with
the KMM system. This will continue to evolve and improve but the feature is still
in beta. Desktop apps can use the same shared code as the other platforms but
use their own UI.
14
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
Download Xcode
If you want to develop for iOS or macOS, you’ll need to install Xcode from the
App Store onto your Mac. Make sure you open Xcode to install its tools as well.
15
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
Fig. 1.3 - Xcode on the App Store
Install CocoaPods
CocoaPods is a dependency manager for iOS. Since CocoaPods has been around
for a long time, it’s easy to use in Xcode and easy to add dependencies.
The command above installs cocoapods using the default Ruby installation
available on macOS.
In the New Project window, scroll down to the last entry and choose Kotlin
Multiplatform App. If you don’t see this, make sure you installed the KMM
plugin. Also, make sure to restart Android Studio after installing the plugin.
Click Next.
16
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
In the next dialog, enter the name Find Time and a package name of
com.raywenderlich.findtime or your own package name. Choose the directory
you want to store the project and press Next.
17
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
Fig. 1.6 - KMM Application Project Dialog
In the next dialog, leave everything as the default. Here, you’re naming the
Android folder androidApp, the iOS folder iosApp, and the shared folder
shared. You can use any names you want, but the rest of the book will use these
conventions.
As mentioned earlier, you’ll use the CocoaPods dependency manager for iOS.
When the Gradle files are created, Android Studio will add a section for
CocoaPods.
You’ll first see the Android file structure in the left panel, but you want to see all
the folders. Choose Project from the menu showing Android. Here, you can see
all the folders and files created for you. You have a hidden folder for Gradle and
Android Studio (.idea), and the androidApp, Gradle, iosApp and shared
folders.
18
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
Here, you can see folders for Android, common and iOS. You’ll add the shared
classes and code to commonMain/kotlin. If you need to write code that is
platform specific, you’ll write an expect function or variable in the common
folder and the actual code in both the Android and iOS folders. Open the
commonMain folder and open Platform.kt.
package com.raywenderlich.findtime
19
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
Here, you see how to use the expect keyword. This says that you expect each
platform to have an actual class named Platform that implements this class and
has a variable named platform that is a string. Now, open the Android and iOS
Platform.kt files.
Android
package com.raywenderlich.findtime
In the Android class, the new keyword actual states that this is the actual
implementation for the expected Platform class. The platform variable also
has the actual keyword and provides an implementation for the variable to
return the string “Android” and the Android SDK number.
Run the Android app from Android Studio by making sure you have the
androidApp selected in the toolbar and an emulator or phone selected. Then,
click the green Play button.
You’ll see:
20
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
Your screen now shows the words Hello, Android and the Android version.
package com.raywenderlich.findtime
import platform.UIKit.UIDevice
Notice that this class is written in Kotlin but uses iOS platform code. Wow, you
can write iOS code in Kotlin! When you build your project, the KMP plugin will
compile this class into a framework. Open Xcode. From the File menu, choose
Open and navigate to your project and into the iosApp folder. Select the
workspace file (iosApp.xcworkspace). For the project to run without errors,
you’ll need to build in Xcode. Select Product ▸ Build or press Command-B.
Once the project is built, open ContentView.swift.
21
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
import SwiftUI
import shared
This file has been generated for you and is written in SwiftUI. You’ll get a crash
course in SwiftUI in a later chapter. Hover over greeting() and press Command-
Click.
This will open the shared.h file. It’s written in Objective-C, but it allows you to
use all the code from the shared project. Scroll to the bottom of the file and you
will see:
22
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
The screen now shows the code written in Kotlin using the device name and the
device version.
Key points
KMP refers to Kotlin Multiplatform, and KMM refers to Kotlin Multiplatform
Mobile.
KMP helps write common code for networking, database and business
logic.
You can’t use KMP for UI work. You’ll need to use native frameworks
instead.
It’s easy to create a KMP project by using the KMM plugin.
23
Kotlin Multiplatform by Tutorials Chapter 1: Introduction
To see some of the top companies using KMP, go to
https://medium.com/@touchlab/top-8-mobile-apps-in-2020-built-with-
kotlin-multiplatform-3e9fea10e2af and
https://kotlinlang.org/lp/mobile/case-studies/.
In the next chapter, you’ll build on this project to create the Find Time project.
24
Kotlin Multiplatform by Tutorials
2 Getting Started
Written by Kevin D Moore
In the last chapter, you created your first KMP project. To get started, you’ll
need to understand the build system KMP uses. For Android and desktop, that’s
Gradle. For iOS, you’ll use CocoaPods in this chapter. (You’ll learn about
another method in a later chapter).
For KMP, you’ll use the Kotlin scripting version of Gradle. These files are named
build.gradle.kts. The kts extension stands for “Kotlin script.” This version uses
Kotlin for the Gradle DSL (Domain Specific Language) — which makes it much
easier to use if you already know Kotlin. This project has a number of these
build scripts in different directories. Each serves a specific purpose. Open the
starter project or the project from the last chapter in Android Studio, switch to
the Project view and follow along to learn more about these build files.
If you don’t know what a particular command does, you can click the name
while pressing the command key and Android Studio will open the class.
Build les
Open build.gradle.kts in the root folder.
// 1
buildscript {
// 2
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
// 3
dependencies {
25
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
classpath("org.jetbrains.kotlin:kotlin-gradle-
plugin:1.5.31")
classpath("com.android.tools.build:gradle:7.0.1")
}
}
1. The buildscript section describes all the information about where to get
plugins and their versions.
3. dependencies describes the plugins and versions you’ll use. Here, you’re
using the Kotlin Gradle plugin and the Android Gradle plugin.
// 1
allprojects {
// 2
repositories {
google()
mavenCentral()
}
}
tasks.register("clean", Delete::class) {
delete(rootProject.buildDir)
}
This defines a Gradle task called clean . The task deletes the root build
directory. You use it when you need a fresh build.
plugins {
id("com.android.application")
kotlin("android")
}
Android needs the application and the Android Kotlin plugins. The next section
shows which dependencies you need:
26
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
dependencies {
// 1
implementation(project(":shared"))
// 2
implementation("com.google.android.material:material:1.4.0")
implementation("androidx.appcompat:appcompat:1.3.1")
implementation("androidx.constraintlayout:constraintlayout:2.1.1")
}
1. Android depends on the shared module (where all shared business logic
will reside).
2. Android-specific libraries (Material Components & ConstraintLayout).
android {
// 1
compileSdk = 31
defaultConfig {
// 2
applicationId = "com.raywenderlich.findtime.android"
// 3
minSdk = 21
targetSdk = 31
versionCode = 1
versionName = "1.0"
}
// 4
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
}
2. applicationId is the ID for the Android App. This has to be unique for every
app on the Google Play store.
3. minSdk refers to the lowest Android version your app will run on, while
targetSdk refers to the latest Android version you support. versionCode is
the number you’ll use internally to differentiate between builds while
versionName is the version that will be displayed on the Play Store.
4. Specify debug or release settings here. For the release version, this sets
isMinifyEnabled to false, but you’ll probably want to set it to true when
you’re ready to release.
27
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
Shared build le
Open shared/build.gradle.kts. This is the build script for the shared module. If
you open the src directory, you’ll see androidMain, commonMain and iosMain
directories. These contain the shared files for Android and iOS, as well as files
that all modules share.
plugins {
kotlin("multiplatform")
kotlin("native.cocoapods")
id("com.android.library")
}
The first plugin is for KMP and defines this module as a multiplatform module.
The next plugin is for iOS and brings in CocoaPods. The last plugin is for
Android. You’ll use this to create an Android library for use in an Android app.
The Kotlin section is next. This section uses the multiplatform plugin above to
configure this module for KMP:
kotlin {
// 1
android()
// 2
iosX64()
iosArm64()
iosSimulatorArm64()
// 3
cocoapods {
summary = "Holds Time zone information"
homepage = "Link to the Shared Module homepage"
ios.deploymentTarget = "14.1"
framework {
baseName = "shared"
}
podfile = project.file("../iosApp/Podfile")
}
...
}
2. iosX64 defines a target for the iOS simulator on x86_64 platforms, while
iosArm64 defines a target for iOS on ARM64 platforms.
3. Defines the details for building the CocoaPods Podfile (it will be in the iosApp
directory). The main thing here is the baseName, which is shared, and the
path to the Podfile.
28
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
Next, you define the source sets. These use predefined variables:
sourceSets {
// 1
val commonMain by getting
val commonTest by getting {
dependencies {
implementation(kotlin("test-common"))
implementation(kotlin("test-annotations-common"))
}
}
// 2
val androidMain by getting
val androidTest by getting {
dependencies {
implementation(kotlin("test-junit"))
implementation("junit:junit:4.13.2")
}
}
// 3
val iosX64Main by getting
val iosArm64Main by getting
val iosSimulatorArm64Main by getting
val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this)
}
}
android {
compileSdk = 31
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifes
t.xml")
defaultConfig {
minSdk = 21
targetSdk = 31
}
}
This is similar to Android’s build file, except it just has the minimum
29
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
information needed.
iOS
iOS uses several different dependency management systems. One of the more
common systems is CocoaPods. If you open the iOSApp/Podfile file, you’ll see
there isn’t much to it:
target 'iosApp' do
use_frameworks!
platform :ios, '14.1'
pod 'shared', :path => '../shared'
end
The most important part is the pod 'shared', :path => '../shared' section.
This defines the shared framework that KMP will create and that you can use in
your iOS apps.
BuildSrc
One of the nice features of the newer Gradle versions is the concept of the
buildSrc module. This is a module where you can define version variables as
well as define your own plugins. To create this module, start by right-clicking
the root folder in the project view. Then, select New ▸ Directory. Enter
buildSrc. Since this will be a module, you’ll need a build file. Right-click
buildSrc and choose New ▸ File. Name the file build.gradle.kts and add the
following code to it:
repositories {
mavenCentral()
}
plugins {
`kotlin-dsl`
}
The code above includes the Kotlin DSL plugin. Now, sync your Gradle files by
clicking Sync Now at the top right corner of the window. Then, right-click
buildSrc, select New ▸ Directory and choose src/main/kotlin. Right-click the
kotlin directory and select New ▸ Kotlin Class/File. Name the file
Dependencies and choose File. This file will contain all the variables you’ll
need to define all your plugins and dependencies. The reason for creating this
file is to avoid having to maintain all the versions for your plugins and
dependencies in many different files. Defining the versions in one place makes it
easy to change later.
30
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
Define all the plugin names by adding this code to the file:
The code above defines regular Kotlin variables you can use throughout your
Gradle scripts. These aren’t required, but they make it easier to add. Next,
define the version numbers by adding the following code:
object Versions {
// 1
const val min_sdk = 24
const val target_sdk = 31
const val compile_sdk = 31
// 2
// Plugins
const val kotlin = "1.6.10"
const val kotlin_gradle_plugin = "1.6.10"
const val android_gradle_plugin = "7.0.4"
const val desktop_compose_plugin = "1.0.1"
const val compose_compiler_version= "1.1.0-rc02"
const val compose_version= "1.1.0-rc01"
// TODO: Add Other versions
}
// TODO: Add Deps
1. These define the SDK versions for Android. A min SDK version of 24 is
needed for some of the libraries. You’ll notice these versions are consistent
with the ones you saw earlier in the build.gradle.kts files.
2. These define the versions for your plugins.
Notice the two plugins with the word compose in them. You’ll write your
Android UI in Jetpack Compose and your desktop app in Desktop Compose.
Now, define the version numbers for your libraries. Replace // TODO: Add
Other versions with:
31
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
This defines version numbers for some of the libraries you’ll use. Next, replace
// TODO: Add Deps with:
object Deps {
const val android_gradle_plugin =
"com.android.tools.build:gradle:${Versions.android_gradle_plugin}"
const val kotlin_gradle_plugin = "org.jetbrains.kotlin:kotlin-
gradle-plugin:${Versions.kotlin_gradle_plugin}"
This defines variables for the plugins and for some useful libraries.
Notice how you use substitution to insert the version number. This ensures all
modules reference the same dependencies and their versions.
Next, add the Compose libraries. These are libraries for building the new
Jetpack Compose UI in Android. Replace // TODO: Add Compose with:
object Compose {
const val ui =
"androidx.compose.ui:ui:${Versions.compose_version}"
const val uiUtil = "androidx.compose.ui:ui-
util:${Versions.compose_version}"
const val tooling = "androidx.compose.ui:ui-
tooling:${Versions.compose_version}"
const val foundation =
"androidx.compose.foundation:foundation:${Versions.compose_version}"
You may not need all these, but it will be useful for other projects. You’ll learn
32
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
object Coroutines {
const val common = "org.jetbrains.kotlinx:kotlinx-coroutines-
core:${Versions.coroutines}"
const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-
android:${Versions.coroutines}"
const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-
test:${Versions.coroutines}"
}
// TODO: Add JetBrains
This provides access to the Kotlin Coroutine libraries — very useful for
asynchronous programming.
object JetBrains {
const val datetime = "org.jetbrains.kotlinx:kotlinx-
datetime:${Versions.kotlinxDateTime}"
const val uiDesktop = "org.jetbrains.compose.ui:ui-
desktop:${Versions.desktop_compose_plugin}"
const val uiUtil = "org.jetbrains.compose.ui:ui-
util:${Versions.desktop_compose_plugin}"
}
These are a few libraries from JetBrains — the developers of KMP — for
multiplatform datetime handling, as well as Desktop Compose, which will be
introduced later.
Now that you have all your variables defined, you’ll clean up your build files
with these new variables.
Shared build le
Open the shared build.gradle.kts and replace the plugins with:
kotlin(multiplatform)
id(androidLib)
kotlin(cocopods)
Notice how you didn’t need to include any files to get these variables. That’s
because Gradle can automatically read the constants defined within buildSrc.
33
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
android {
compileSdk = Versions.compile_sdk
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifes
t.xml")
defaultConfig {
minSdk = Versions.min_sdk
targetSdk = Versions.target_sdk
}
}
The code above replaces the SDK versions with the ones defined inside buildSrc.
Next, open the root build.gradle.kts and replace the classpath strings with:
classpath(Deps.android_gradle_plugin)
classpath(Deps.kotlin_gradle_plugin)
Android build le
Open androidApp’s build.gradle.kts and replace the plugins section with:
plugins {
id(androidApp)
kotlin(androidPlugin)
}
This just replaces the strings with the variables. Replace the dependencies
section with:
dependencies {
// 1
implementation(project(":shared"))
// 2
with(Deps) {
implementation(napier)
implementation(material)
}
// 3
//Compose
with(Deps.Compose) {
implementation(compiler)
implementation(runtime)
implementation(runtime_livedata)
implementation(ui)
implementation(tooling)
implementation(foundation)
implementation(foundationLayout)
implementation(material)
34
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
implementation(material_icons)
implementation(activity)
}
}
2. This defines napier , a multiplatform library for logging, and the Google
material library. Notice how you can use the Kotlin with keyword to
avoid repeating parts of the variable names.
android {
compileSdk = Versions.compile_sdk
defaultConfig {
applicationId = "com.raywenderlich.findtime.android"
minSdk = Versions.min_sdk
targetSdk = Versions.target_sdk
versionCode = 1
versionName = "1.0"
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion =
Versions.compose_compiler_version
}
}
The code above sets the Java compatibility to 1.8, enables Compose in the
project and specifies the Kotlin compiler extension version that it should use.
Do another Gradle sync to make sure everything still works. Run the app on
Android. You’ll see a screen similar to the one shown below:
35
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
You haven’t changed the UI yet, but this makes sure the Gradle files are still
working.
Find Time
Have you ever needed to schedule a meeting with colleagues who work in
different time zones? It can be a real pain. Are they awake at the hour you want
to schedule? What are good times to schedule a meeting? You’re going to write
the Find Time app to help find those hours that work best. To do that, you need
to write some time zone logic to figure out the best hours to meet. If you were to
write separate apps for iOS and Android, you would have to write that business
logic twice.
Business logic
One of the main benefits of KMP is you can share business logic among all your
platforms. You’ll write your business logic in the shared module. This module is
a multiplatform module you can use for iOS, Android, desktop and the web.
Open the shared/src folder and then androidMain, commonMain and iosMain
folders. You’ll find the Platform and Greeting classes. Delete these classes as
you won’t use them. Note that both the Android and iOS platforms won’t run
until you delete the code that called them. Android Studio will complain about
their usages. Click the View Usages button.
36
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
Run the delete again, and you’ll be able to delete the files without any problems.
Datetime calculations
You’ll use JetBrains’ kotlinx-datetime library to help with datetime
calculations. Open shared/build.gradle.kts and find val commonMain . Change
it to:
37
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
implementation(Deps.JetBrains.datetime)
// 2
implementation(Deps.napier)
}
}
Datetime library
Kotlin’s kotlinx-datetime is an easy-to-use multiplatform library that helps
with date- and time-based calculations. It uses several data types:
DayOfWeek is an enum representing all days of the week. It uses values like
MONDAY , TUESDAY , etc. instead of integers.
interface TimeZoneHelper {
fun getTimeZoneStrings(): List<String>
fun currentTime(): String
fun currentTimeZone(): String
38
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
fun hoursFromTimeZone(otherTimeZoneId: String): Double
fun getTime(timezoneId: String): String
fun getDate(timezoneId: String): String
fun search(startHour: Int, endHour: Int, timezoneStrings:
List<String>): List<Int>
}
Return a list of time zone strings. (This is a list of all time zones from the
JetBrains kotlinx-datetime library)
Search for a list of hours that start at startHour , end at endHour and are in
all the given time zone strings.
Creating an interface makes it easy to test. This chapter doesn’t cover tests, but
using an interface makes it easy to create mocked time zone helpers. Now,
create an instance of that interface. Right-click the findtime folder and create a
new Kotlin class named TimeZoneHelperImpl. This class will implement the
interface. Update the class to extend TimeZoneHelper as follows:
You’ll see a red line underneath the class because you haven’t yet implemented
the methods. Press Option-Return while keeping your cursor on
TimeZoneHelperImpl and choose Implement Members.
39
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
Start with getTimeZoneStrings . This sounds like it could be really hard, but
the kotlinx-datetime library makes it easy. Replace the TODO with:
return TimeZone.availableZoneIds.sorted()
This line returns the available time zone IDs and sorts them. TimeZone will be
red. Place your cursor on TimeZone and press Option-Return to import the
library. You can use this technique to import the required classes in the next
sections as well.
Next, you need a method to format datetime’s LocalDateTime class. Add the
following method at the bottom of the class:
40
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
stringBuilder.append(":")
// 5
if (minute < 10) {
stringBuilder.append('0')
}
stringBuilder.append(minute.toString())
stringBuilder.append(amPm)
// 6
return stringBuilder.toString()
}
3. Since you want a string with am/pm, check if the hour is greater than noon
(12).
41
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
// 3
val dateTime: LocalDateTime =
currentMoment.toLocalDateTime(timezone)
// 4
return formatDateTime(dateTime)
}
This takes the different parts of the DateTime to create a string like: “Monday,
October 4.”
The currentTimeZone method is pretty easy. Return the current time zone as a
string:
The hoursFromTimeZone method is a bit tricky. You want to return the number
of hours from the given time zone:
42
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
// 3
val otherTimeZone = TimeZone.of(otherTimeZoneId)
// 4
val currentDateTime: LocalDateTime =
currentUTCInstant.toLocalDateTime(currentTimeZone)
// 5
val currentOtherDateTime: LocalDateTime =
currentUTCInstant.toLocalDateTime(otherTimeZone)
// 6
return abs((currentDateTime.hour - currentOtherDateTime.hour) *
1.0)
}
5. Convert the current time in another time zone into a LocalDateTime class.
6. Return the absolute difference between the hours (shouldn’t be negative),
making sure the result is a double.
Searching
Searching is a bit harder. Given a starting hour (like 8 a.m.), an ending hour (say
5 p.m.) and the list of time zones that everyone is in, you want to return a list of
integers that represent the hours (0-23) that fit in everyone’s time zones. So, if
you pass in 8 a.m. - 5 p.m. for Los Angeles and New York, you would get a list of
hours:
[8,9,10,11,12,13,14]
All these hours for Los Angeles also work for New York. Los Angeles can go up
to 2 p.m. (14), while New York will start at 11 a.m. and go until 5 p.m. To see if an
hour is valid, add the isValid method after the search method:
43
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
This method takes a time range (like 8..17), the given hour to check, the current
time zone for the user and the other time zone that you’re checking against.
The first check verifies if the hour is in the time range. If not, it isn’t valid.
// 1
val currentUTCInstant: Instant = Clock.System.now()
// 2
val currentOtherDateTime: LocalDateTime =
currentUTCInstant.toLocalDateTime(otherTimeZone)
// 3
val otherDateTimeWithHour = LocalDateTime(
currentOtherDateTime.year,
currentOtherDateTime.monthNumber,
currentOtherDateTime.dayOfMonth,
hour,
0,
0,
0
)
// TODO: Add Conversions
2. Convert the instant into another time zone with toLocalDateTime , passing
in the other time zone.
// 1
val localInstant = otherDateTimeWithHour.toInstant(currentTimeZone)
// 2
val convertedTime = localInstant.toLocalDateTime(otherTimeZone)
Napier.d("Hour $hour in Time Range ${otherTimeZone.id} is
${convertedTime.hour}")
// 3
return convertedTime.hour in timeRange
Napier is the logging library and needs to be imported. Place your cursor on
Napier and press Option-Return on it to import the library.
44
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
Now that you have the isValid method, the search method won’t be as hard.
You just need to go through all the given time zones and hours and check if they
are valid. Update the search method with:
// 1
val goodHours = mutableListOf<Int>()
// 2
val timeRange = IntRange(max(0, startHour), min(23, endHour))
// 3
val currentTimeZone = TimeZone.currentSystemDefault()
// 4
for (hour in timeRange) {
var isGoodHour = false
// 5
for (zone in timezoneStrings) {
val timezone = TimeZone.of(zone)
// 6
if (timezone == currentTimeZone) {
continue
}
// 7
if (!isValid(
timeRange = timeRange,
hour = hour,
currentTimeZone = currentTimeZone,
otherTimeZone = timezone
)
) {
Napier.d("Hour $hour is not valid for time range")
isGoodHour = false
break
} else {
Napier.d("Hour $hour is Valid for time range")
isGoodHour = true
}
}
// 8
if (isGoodHour) {
goodHours.add(hour)
}
}
// 9
return goodHours
45
Kotlin Multiplatform by Tutorials Chapter 2: Getting Started
6. If it’s the same time zone as the current one, then you know it’s good.
Import the min and max methods. You’ve now written the business logic for
the Time Finder app! Android, iOS, desktop and web platforms can all share it.
Build the app to make sure it still compiles. You can try to run both Android and
iOS again. You’ll see the iOS build fail with the error shown below:
Remember that you removed the greet method. How would you fix this?
Challenge
The iOS app no longer works. Figure out how to find the problem, fix the error
and get the iOS App working again.
Key points
Gradle is the build system for most of KMP projects.
Gradle: https://gradle.org/
buildSrc:
https://docs.gradle.org/current/userguide/organizing_gradle_projects.html#sec:build_source
In the next chapter, you’ll start building the UI for the Find Time project.
47
Kotlin Multiplatform by Tutorials
In the last chapter, you learned about the KMP build system. In this chapter,
you’ll learn about a new UI toolkit that you can use on Android. That UI toolkit is
Jetpack Compose. This won’t be an extensive discussion on Jetpack Compose,
but it will teach you the basics. Open the starter project from this chapter
because it has some starter code.
UI frameworks
KMP doesn’t provide a framework for developing a UI, so you’ll need to use a
different framework for each platform. In this chapter, you’ll learn about
writing the UI for Android with Jetpack Compose, which also works on desktop.
In the next chapter, you’ll learn about building the UI for iOS using SwiftUI,
which also works on macOS.
Current UI system
On Android, you typically use an XML layout system for building your UIs. While
Android Studio does provide a UI layout editor, it still uses XML underneath.
This means that Android will have to parse XML files to build its view classes to
then build the UI. What if you could just build your UI in code?
Jetpack Compose
That’s the idea behind Jetpack Compose (JC). JC is a declarative UI system that
uses functions to create all or part of your UI. The developers at Google realized
the Android View system was getting older and had many flaws. So, they decided
to come up with a whole new framework that would use a library instead of the
built-in framework — allowing app developers to continue to provide the most
up-to-date version of the framework regardless of the version of Android.
One of the main tenants of Compose is that it takes less code to do the same
things as the old View system. For example, to create a modified button, you
don’t have to subclass Button — instead, just add modifiers to an existing
Compose component.
Compose components are also easily reusable. You can use Compose with new
48
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
projects, and you can use it with existing projects that just use Compose in new
screens. Compose can preview your UI in Android Studio, so you don’t have to
run the app to see what your components will look like. In a declarative UI, the
UI will be drawn with the current state. If that state changes, the areas of the
screen that have changed will be rerendered. This makes your code much
simpler because you only have to draw what’s in your current state and don’t
have to listen for changes.
setContent
To start converting your app to use Compose, open MainActivity. Delete the line
containing setContentView and add the following:
setContent {
Text("Test")
}
import androidx.activity.compose.setContent
import androidx.compose.material.Text
Run the app and you’ll see a small “Test” in the top left corner.
49
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
If you look at the source of setContent , you’ll see that it’s an extension
method on ComponentActivity . The last parameter in this method is your UI.
This method is of type @Composable , which is a special annotation that you’ll
need to use on all of your Compose functions. A Compose function will look
something like this:
@Composable
fun showName(text: String) {
Text(text)
}
The most important part is the @Composable annotation. This tells JC this is a
function that can be drawn on the screen. No Composable function returns a
value. Importantly, you want most of your functions to be stateless. This means
that you pass in the data you want to show, and the function doesn’t store that
data. This makes the function very fast to draw. See the Where to go from here
section at the end of this chapter to learn more about how Compose works.
Time nder
You’re going to develop a multiplatform app that will allow the user to select
50
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
multiple time zones and find the best meeting times that work for all people in
those time zones. Here’s what the first screen looks like:
Here, you see the local time zone, time and date. Two different time zones are
below that: New York and London. Your user is trying to find a meeting time in
all three locations.
Note: This is just the raw time zone string code. If you’re interested, you can
challenge yourself to replace the string codes with more readable strings.
When the user wants to add a time zone, they will tap the Floating Action Button
(FAB) and a dialog will appear to allow them to select all the time zones they
want:
51
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
Next up is the search screen, which allows the user to select the start and end
times for their day and includes a search button to show the hours available.
52
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
53
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
Note: While this chapter goes into some detail about Jetpack Compose, it’s
not intended to be a thorough examination of how to use it. For a deeper
understanding of Jetpack Compose, check out the books at
https://www.raywenderlich.com/android/books.
Themes
One of the first Compose functions you need to learn about is the theme. This is
the color scheme you’ll use for your app. In Android, you would normally have
a style.xml or theme.xml file with specifications for colors, fonts and other
areas of UI styling. In Compose, you use a theme function. Since you have
included the Material Compose library, you can use the MaterialTheme class
as a starting point for setting colors, fonts and shapes. Compose can also tell you
if the system is using the dark theme. Start by creating a new package in the
androidApp module on the same level as MainActivity and name it theme.
54
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
Next, create a new file in that package named Colors.kt. Add the following:
import androidx.compose.ui.graphics.Color
This defines some primary and secondary colors. You can see the colors in the
left margin. Change them if you want a different color scheme. Next, right-click
on the theme directory and create a new Kotlin file named Typography.kt. Add
the following:
import androidx.compose.material.Typography
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// 1
val typography = Typography(
55
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
// 2
h1 = TextStyle(
// 3
fontFamily = FontFamily.SansSerif,
// 4
fontSize = 24.sp,
// 5
fontWeight = FontWeight.Bold,
// 6
color = Color.White
),
h2 = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 20.sp,
color = Color.White
),
h3 = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 12.sp,
color = Color.White
),
h4 = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 10.sp,
color = Color.White
)
)
3. Define the font family to use. You’ll use the SansSerif family.
You can also set the letter spacing and many other values defined in
TextStyle . Here, you define h1-h4 styles. There are other styles like body,
buttons, captions and subtitles.
Next, create a new file in that package named AppTheme.kt. Create a new
function named AppTheme by adding the following code:
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
56
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content:
@Composable () -> Unit) {
// TODO: Add Colors
}
This function will take an optional parameter to set the dark theme. If nothing is
passed in, it will check what the system setting is. The last parameter is the
composable function to show. Next, define the light and dark colors. Replace the
// TODO: Add Colors with the following:
This sets the colors variable with either light or dark colors. The two functions
darkColors and lightColors return a specific Colors class, which you can
make a copy of and change a few colors. Investigate the Colors class to see
what colors you can change. Next, replace // TODO: Add Theme with the
following:
MaterialTheme(
colors = colors,
typography = typography,
content = content
)
This applies the MaterialTheme with your colors and typography and passes in
the given content.
57
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
Types
Before you get to the main screen, you’ll need a few types that will be used
throughout the app. In the ui folder, create a new file named Types.kt. Add the
following:
import androidx.compose.runtime.Composable
// 1
typealias OnAddType = (List<String>) -> Unit
// 2
typealias onDismissType = () -> Unit
// 3
typealias composeFun = @Composable () -> Unit
// 4
typealias topBarFun = @Composable (Int) -> Unit
// 5
@Composable
fun emptyComposable() {
}
1. Define an alias named OnAddType that takes a list of strings and doesn’t
return anything.
5. Define an empty composable function (as a default variable for the Top Bar).
Now that you have your colors and text styles set up, it’s time to create your first
screen.
Main screen
In the androidApp module in the ui folder, create a new Kotlin file named
MainView.kt. You’ll start by creating some helper classes and variables. First,
add the imports you’ll need (this saves some time importing):
import androidx.compose.foundation.layout.padding
import androidx.compose.material.BottomNavigation
import androidx.compose.material.BottomNavigationItem
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.Scaffold
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Place
58
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.raywenderlich.findtime.android.theme.AppTheme
Notice that you’re importing the material icons you’ll use and a few other
compose classes.
To keep track of your two screens, create a new sealed class named Screen:
This defines a route, icon for that route and a content description. Next, create
a variable with two items:
This uses the material icons and the titles from the screen class. Now, create the
MainView composable:
// 1
@Composable
// 2
fun MainView(actionBarFun: topBarFun = { emptyComposable() }) {
59
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
// 3
val showAddDialog = remember { mutableStateOf(false) }
// 4
val currentTimezoneStrings = remember { SnapshotStateList<String>
() }
// 5
val selectedIndex = remember { mutableStateOf(0)}
// 6
AppTheme {
// TODO: Add Scaffold
}
}
2. This function takes a function that can provide a top bar (toolbar on
Android) and defaults to an empty composable.
State
State is any value that can change over time. Compose uses a few functions for
handling state. The most important one is remember . This stores the variable so
that it’s remembered between redraws of the screen. When the user selects
between the two bottom buttons, you want to save which screen is showing. A
MutableState is a value holder that tells the Compose engine to redraw
whenever the state changes.
1. remember : Remembers the variable and retains its value between redraws.
Scaffold
60
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
Compose uses a function named scaffold that uses the Material Design layout
structure with an app bar (toolbar) and an optional floating action button. By
using this function, your screen will be laid out properly. Start by replacing //
TODO: Add Scaffold with:
Scaffold(
topBar = {
// TODO: Add Toolbar
},
floatingActionButton = {
// TODO: Add Floating action button
},
bottomBar = {
// TODO: Add bottom bar
}
) {
// TODO: Replace with Dialog
// TODO: Replace with screens
}
As you can see, there are places to add composable functions inside the topBar,
floatingActionButton and bottomBar parameters.
TopAppBar
The TopAppBar is Compose’s function for a toolbar. Since every platform
handles a toolbar differently — macOS displays menu items in the system
toolbar, whereas Windows uses a separate toolbar — this section is optional. If
the platform passes in a function that creates one, it will use that. Replace //
TODO: Add Toolbar with:
actionBarFun(selectedIndex.value)
This calls the passed-in function with the currently selected bottom bar index,
whose value is stored in the selectedIndex state variable. Since
actionBarFun gets set to an empty function by default, nothing will happen
unless a function is passed in. You’ll do this later for the Android app. Now add
the code to show a floating action button if you’re on the first screen but not on
the second screen. Replace // TODO: Add Floating action button with:
if (selectedIndex.value == 0) {
// 1
FloatingActionButton(
// 2
modifier = Modifier
.padding(16.dp),
// 3
onClick = {
showAddDialog.value = true
61
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
}
) {
// 4
Icon(
imageVector = Icons.Default.Add,
contentDescription = null
)
}
}
Bottom navigation
Compose has a BottomNavigation function that creates a bottom bar with
icons. Underneath, it’s a Compose Row class that you fill with your content.
Replace // TODO: Add bottom bar with:
// 1
BottomNavigation {
// 2
bottomNavigationItems.forEachIndexed { i, bottomNavigationItem ->
// 3
BottomNavigationItem(
// 4
icon = {
Icon(
bottomNavigationItem.icon,
contentDescription =
bottomNavigationItem.iconContentDescription
)
},
// 5
selected = selectedIndex.value == i,
// 6
onClick = {
selectedIndex.value = i
}
)
}
}
62
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
import androidx.compose.material.TopAppBar
import androidx.compose.ui.res.stringResource
import com.raywenderlich.findtime.android.ui.MainView
import io.github.aakira.napier.DebugAntilog
import io.github.aakira.napier.Napier
// 1
Napier.base(DebugAntilog())
setContent {
// 2
MainView {
// 3
TopAppBar(title = {
// 4
when (it) {
0 -> Text(text =
stringResource(R.string.world_clocks))
else -> Text(text =
stringResource(R.string.findmeeting))
}
})
}
}
1. Initialize the Napier logging library. (Be sure to include needed imports.)
Build and run the app on a device or emulator. Here’s what you’ll see:
63
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
Now you have a working app that displays a title bar, floating action button and a
bottom navigation bar. Try switching between the two icons. What happens?
In the ui folder, create a new Kotlin file named LocalTimeCard.kt. Add the
following code:
import androidx.compose.foundation.BorderStroke
64
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.raywenderlich.findtime.android.theme.primaryColor
import com.raywenderlich.findtime.android.theme.primaryDarkColor
import com.raywenderlich.findtime.android.theme.typography
@Composable
// 1
fun LocalTimeCard(city: String, time: String, date: String) {
// 2
Box(
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.background(Color.White)
.padding(8.dp)
) {
// 3
Card(
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, Color.Black),
elevation = 4.dp,
modifier = Modifier
.fillMaxWidth()
)
{
// TODO: Add body
}
}
}
2. Use a Box function that fills the current width and has a height of 140 dp
and white background. Box is a container that draws elements on top of one
another.
3. Use a Card with rounded corners and a border. It also fills the width.
// 1
65
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
Box(
modifier = Modifier
.background(
brush = Brush.horizontalGradient(
colors = listOf(
primaryColor,
primaryDarkColor,
)
)
)
.padding(8.dp)
) {
// 2
Row(
modifier = Modifier
.fillMaxWidth()
) {
// 3
Column(
horizontalAlignment = Alignment.Start
) {
// 4
Spacer(modifier = Modifier.weight(1.0f))
Text(
"Your Location", style = typography.h4
)
Spacer(Modifier.height(8.dp))
// 5
Text(
city, style = typography.h2
)
Spacer(Modifier.height(8.dp))
}
// 6
Spacer(modifier = Modifier.weight(1.0f))
// 7
Column(
horizontalAlignment = Alignment.End
) {
Spacer(modifier = Modifier.weight(1.0f))
// 8
Text(
time, style = typography.h1
)
Spacer(Modifier.height(8.dp))
// 9
Text(
date, style = typography.h3
)
Spacer(Modifier.height(8.dp))
}
}
}
66
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
4. Use a spacer with a weight modifier to push the text to the bottom.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.raywenderlich.findtime.TimeZoneHelper
import com.raywenderlich.findtime.TimeZoneHelperImpl
import kotlinx.coroutines.delay
@Composable
fun TimeZoneScreen(
currentTimezoneStrings: SnapshotStateList<String>
) {
// 1
val timezoneHelper: TimeZoneHelper = TimeZoneHelperImpl()
// 2
val listState = rememberLazyListState()
// 3
Column(
modifier = Modifier
.fillMaxSize()
) {
// TODO: Add Content
}
}
67
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
// 1
var time by remember { mutableStateOf(timezoneHelper.currentTime())
}
// 2
LaunchedEffect(0) {
while (true) {
time = timezoneHelper.currentTime()
delay(timeMillis) // Every minute
}
}
// 3
LocalTimeCard(
city = timezoneHelper.currentTimeZone(),
time = time, date =
timezoneHelper.getDate(timezoneHelper.currentTimeZone())
)
Spacer(modifier = Modifier.size(16.dp))
when (selectedIndex.value) {
0 -> TimeZoneScreen(currentTimezoneStrings)
// 1 -> FindMeetingScreen(currentTimezoneStrings)
}
If the index is 0, show the Time Zone screen, otherwise show the Find Meeting
68
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
screen. The Find Meeting screen is commented out until you write it.
Time card
The time card will look like this:
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
69
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
// 1
fun TimeCard(timezone: String, hours: Double, time: String, date:
String) {
// 2
Box(
modifier = Modifier
.fillMaxSize()
.height(120.dp)
.background(Color.White)
.padding(8.dp)
) {
// 3
Card(
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, Color.Gray),
elevation = 4.dp,
modifier = Modifier
.fillMaxWidth()
)
{
// TODO: Add Content
}
}
}
Now that you have the card, add a few rows and columns. Replace // TODO:
Add Content with:
// 1
Box(
modifier = Modifier
.background(
color = Color.White
)
70
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
.padding(16.dp)
) {
// 2
Row(
modifier = Modifier
.fillMaxWidth()
) {
// 3
Column(
horizontalAlignment = Alignment.Start
) {
// 4
Text(
timezone, style = TextStyle(
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
)
Spacer(modifier = Modifier.weight(1.0f))
// 5
Row {
// 6
Text(
hours.toString(), style = TextStyle(
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
)
// 7
Text(
" hours from local", style = TextStyle(
color = Color.Black,
fontSize = 14.sp
)
)
}
}
Spacer(modifier = Modifier.weight(1.0f))
// 8
Column(
horizontalAlignment = Alignment.End
) {
// 9
Text(
time, style = TextStyle(
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 24.sp
)
)
Spacer(modifier = Modifier.weight(1.0f))
// 10
Text(
date, style = TextStyle(
color = Color.Black,
fontSize = 12.sp
)
)
}
71
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
}
}
Notice how you’re building up the screen section by section. You can’t quite use
these cards yet, as you need a way to add a new time zone. You’ll do this by
creating a dialog that will allow the user to pick many time zones to add.
You can add code to use the new time card. The following code will go through
the list of current time zone strings, wrap the item in an
AnimatedSwipeDismiss to allow the user to swipe and delete the card and then
use the new time card. Return to TimezoneScreen and replace // TODO: Add
Timezone items with:
// 1
LazyColumn(
state = listState,
) {
// 2
items(currentTimezoneStrings,
// 3
key = { timezone ->
timezone
}) { timezoneString ->
// 4
AnimatedSwipeDismiss(
item = timezoneString,
// 5
background = { _ ->
Box(
modifier = Modifier
.fillMaxSize()
.height(50.dp)
.background(Color.Red)
.padding(
start = 20.dp,
end = 20.dp
)
72
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
) {
val alpha = 1f
Icon(
Icons.Filled.Delete,
contentDescription = "Delete",
modifier = Modifier
.align(Alignment.CenterEnd),
tint = Color.White.copy(alpha = alpha)
)
}
},
content = {
// 6
TimeCard(
timezone = timezoneString,
hours =
timezoneHelper.hoursFromTimeZone(timezoneString),
time = timezoneHelper.getTime(timezoneString),
date = timezoneHelper.getDate(timezoneString)
)
},
// 7
onDismiss = { zone ->
if (currentTimezoneStrings.contains(zone)) {
currentTimezoneStrings.remove(zone)
}
}
)
}
}
3. Use the key field to set the unique key for each row. This is important if you
need to delete items.
4. Use the included AnimatedSwipeDismiss class to handle swiping away a row.
7. When the row is swiped away, remove the time zone string from your list.
Return to MainView. Now you want to show the Add Timezone Dialog when the
showAddDialog Boolean is true. When that value is true, pass in lambdas for
adding and dismissing the dialog. Replace // TODO: Replace with Dialog
with:
// 1
if (showAddDialog.value) {
AddTimeZoneDialog(
// 2
onAdd = { newTimezones ->
showAddDialog.value = false
73
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
for (zone in newTimezones) {
// 3
if (!currentTimezoneStrings.contains(zone)) {
currentTimezoneStrings.add(zone)
}
}
},
onDismiss = {
// 4
showAddDialog.value = false
},
)
}
Build and run the app again. Click the FAB. You’ll see the dialog as follows:
Search for a time zone and select it. Hit the clear button, search for another
74
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
time zone, and select it. Finally, press the add button. If you selected Los
Angeles and New York, you would see something like:
Since a composable is made up of many parts, you’ll use the included number
picker composable that will look like this:
75
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
This has a text field on the left, an up arrow, a number and a down arrow. You’ll
use this for both the start and end hours.
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
// 1
@Composable
fun NumberTimeCard(label: String, hour: MutableState<Int>) {
// 2
Card(
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, Color.White),
elevation = 4.dp,
) {
// 3
Row(
modifier = Modifier
.padding(16.dp)
) {
76
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
// 4
Text(
modifier = Modifier
.align(Alignment.CenterVertically),
text = label,
style = MaterialTheme.typography.body1
)
Spacer(modifier = Modifier.size(16.dp))
// 5
NumberPicker(hour = hour, range = IntRange(0, 23),
onStateChanged = {
hour.value = it
})
}
}
}
This creates a card with a text field on the left and a number picker on the right.
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Checkbox
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
77
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.raywenderlich.findtime.TimeZoneHelper
import com.raywenderlich.findtime.TimeZoneHelperImpl
// 1
@Composable
fun FindMeetingScreen(
timezoneStrings: List<String>
) {
val listState = rememberLazyListState()
// 2
// 8am
val startTime = remember {
mutableStateOf(8)
}
// 5pm
val endTime = remember {
mutableStateOf(17)
}
// 3
val selectedTimeZones = remember {
val selected = SnapshotStateMap<Int, Boolean>()
for (i in 0..timezoneStrings.size-1) selected[i] = true
selected
}
// 4
val timezoneHelper: TimeZoneHelper = TimeZoneHelperImpl()
val showMeetingDialog = remember { mutableStateOf(false) }
val meetingHours = remember { SnapshotStateList<Int>() }
// 5
if (showMeetingDialog.value) {
MeetingDialog(
hours = meetingHours,
onDismiss = {
showMeetingDialog.value = false
}
)
}
// TODO: Add Content
}
Here, you’ve set up all of your variables and put in a small bit of code to show
the Add Meeting Dialog when the variable is true. Now, replace // TODO: Add
getSelectedTimeZones with:
78
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
fun getSelectedTimeZones(
timezoneStrings: List<String>,
selectedStates: Map<Int, Boolean>
): List<String> {
val selectedTimezones = mutableListOf<String>()
selectedStates.keys.map {
val timezone = timezoneStrings[it]
if (isSelected(selectedStates, it) &&
!selectedTimezones.contains(timezone)) {
selectedTimezones.add(timezone)
}
}
return selectedTimezones
}
This is a helper function that will return a list of selected time zones based on
the selected state map. Now, add the contents. Replace // TODO: Add Content
with:
// 1
Column(
modifier = Modifier
.fillMaxSize()
) {
Spacer(modifier = Modifier.size(16.dp))
// 2
Text(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally),
text = "Time Range",
style = MaterialTheme.typography.h6
)
Spacer(modifier = Modifier.size(16.dp))
// 3
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 4.dp, end = 4.dp)
.wrapContentWidth(Alignment.CenterHorizontally),
) {
// 4
Spacer(modifier = Modifier.size(16.dp))
NumberTimeCard("Start", startTime)
Spacer(modifier = Modifier.size(32.dp))
NumberTimeCard("End", endTime)
}
Spacer(modifier = Modifier.size(16.dp))
// 5
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 4.dp, end = 4.dp)
) {
Text(
79
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally),
text = "Time Zones",
style = MaterialTheme.typography.h6
)
}
Spacer(modifier = Modifier.size(16.dp))
// TODO: Add LazyColumn
}
This creates a column with a text field, start & end hour picker, and another text
field. Next replace // TODO: Add LazyColumn with:
// 1
LazyColumn(
modifier = Modifier
.weight(0.6F)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
state = listState,
) {
// 2
itemsIndexed(timezoneStrings) { i, timezone ->
Surface(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth(),
) {
// 3
Checkbox(checked = isSelected(selectedTimeZones,
i),
onCheckedChange = {
selectedTimeZones[i] = it
})
Text(timezone, modifier =
Modifier.align(Alignment.CenterVertically))
}
}
}
}
Spacer(Modifier.weight(0.1f))
Row(
modifier = Modifier
80
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
.fillMaxWidth()
.weight(0.2F)
.wrapContentWidth(Alignment.CenterHorizontally)
.padding(start = 4.dp, end = 4.dp)
) {
// 4
OutlinedButton(onClick = {
meetingHours.clear()
meetingHours.addAll(
timezoneHelper.search(
startTime.value,
endTime.value,
getSelectedTimeZones(timezoneStrings,
selectedTimeZones)
)
)
showMeetingDialog.value = true
}) {
Text("Search")
}
}
Spacer(Modifier.size(16.dp))
1. Add a LazyColumn for the list of selected time zones. Give it a weight and
padding.
2. For each selected time zone, create a surface and row.
Remember that LazyColumn is used for lists. You use the items or
itemsIndexed functions to show an item in a list. Each row will have a
checkbox and text with the time zone name. At the bottom will be a button that
will start the search process, get all the meeting hours and then show the
meeting dialog.
Wow, that was a lot of work, but you now have a working Meeting Finder app in
Android using Jetpack Compose!
Key points
In Android, you can create your UI in both traditional XML layouts or in the
new Jetpack Compose framework.
Jetpack Compose is made up of composable functions.
81
Kotlin Multiplatform by Tutorials Chapter 3: Developing UI: Android Jetpack Compose
You can create a theme for your app that includes colors and typography.
82
Kotlin Multiplatform by Tutorials
As you learned in the last chapter, KMP does not provide a framework for
developing UI. You’ll need to use a different framework for each platform. In
this chapter, you’ll learn about writing the UI for iOS with SwiftUI. SwiftUI is a
declarative UI toolkit which works on iOS, macOS, watchOS and tvOS. This won’t
be an extensive discussion on SwiftUI, but it will teach you the basics.
Open the starter project from this chapter. It has a few extra files. This chapter
assumes you’re working on a Mac with the Xcode app from Apple. If you’re not
on a Mac, feel free to skip this chapter. If you don’t have Xcode, you can open
any Swift files in Android Studio.
IDE
Xcode is Apple’s IDE for iOS, iPadOS, watchOS, macOS and tvOS development. In
this chapter, you can edit your Swift files in either Xcode or Android Studio.
Android Studio has a good editor, but Xcode has the ability to preview your
SwiftUI Views for you. The choice of the IDE is up to you and this section will
walk you through using both the IDEs.
Android Studio
Open the starter project in Android Studio and select the iOS configuration. You
might see a red x in the icon.
83
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
Select a phone and a target, such as iPhone 13 | iOS 15.0 and click OK.
Now, click the hammer icon (or press Command-F9) to build. This will create
the shared framework needed for iOS.
Xcode
Launch Xcode and open the iosApp directory under the starter project for this
chapter. You don’t have to select the xcodeproj or the xcworkspace file. Click
Open.
84
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
Once the project is open, you’ll see the two iosApp folders on the left:
Current UI system
On iOS, you would typically use storyboards or create your UI in code if you
were developing in UIKit to design your UI. Underneath those storyboards is a
complex XML file. While the layout editor in Xcode is nice, it still takes quite a bit
of work to design and then hook up to code. SwiftUI is a declarative UI system
that’s written entirely in code. No layouts or storyboards. It’s a lot simpler to use
and allows a lot of code reuse with smaller views. Xcode provides previews so
85
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
that you can build small components and view them next to the editor.
App
Open iOSApp.swift:
The starting point in a SwiftUI app is a struct that’s marked with the @main
attribute above the struct. This struct usually implements the App protocol.
The App protocol requires you to create a variable named body that returns a
Scene . A Scene is a container for the root view of a view hierarchy. A
WindowGroup is a Scene and is also a container for your views. On iOS, this will
contain only one window, but on macOS and iPadOS, it can contain multiple
windows. Since it’s a single expression, a return isn’t required. None of the
names of these files are special — the only important piece is to instruct the
compiler where to start the app, and you do that with the @main tag.
Since iOSApp doesn’t describe what your app does, rename the file to
TimezoneApp by selecting it in the left sidebar and pressing Return. Type
TimezoneApp and press return again. Next, change struct iOSApp: App to
struct TimezoneApp: App .
Next, add the following code before var body to change the color of the tab bar
86
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
to a nice shade of blue:
init() {
let tabBarItemAppearance = UITabBarItemAppearance()
tabBarItemAppearance.configureWithDefault(for: .stacked)
tabBarItemAppearance.normal.titleTextAttributes =
[.foregroundColor: UIColor.black]
tabBarItemAppearance.selected.titleTextAttributes =
[.foregroundColor: UIColor.white]
tabBarItemAppearance.normal.iconColor = .black
tabBarItemAppearance.selected.iconColor = .white
UITabBar.appearance().standardAppearance = appearance
if #available(iOS 15.0, *) {
UITabBar.appearance().scrollEdgeAppearance = appearance
}
}
87
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
You can also run the app in Android Studio. In Android Studio, make sure
iOSApp is selected from the configuration menu:
ContentView
Open ContentView.swift. Delete Text(“Hello”) . Add the following as the first
line in the struct:
88
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
TabView
TabView is the SwiftUI equivalent of Jetpack Compose’s BottomNavigation .
You can use it to display a tab bar at the bottom of the screen and lets the user
switch between different views of the app.
2. The first tab will be the TimezoneView that you’ll create next.
3. Apply the tabItem with a system network icon and the word Time Zones.
4. The second tab will be the FindMeeting view that you haven’t created yet.
(It’s commented out for now.)
There are several ways to pass objects around to different views. Here, you pass
timezoneItems via an Environment Object. The users of this object i.e, any
child view, will declare an @EnvironmentObject variable that will receive that
object.
89
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
90
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
Inside the file, first add the import for the shared library:
import shared
// 1
@EnvironmentObject private var timezoneItems: TimezoneItems
// 2
private var timezoneHelper = TimeZoneHelperImpl()
// 3
@State private var currentDate = Date()
// 4
let timer = Timer.publish(every: 1000, on: .main, in:
.common).autoconnect()
// 5
@State private var showTimezoneDialog = false
@State is used with simple struct types, and its state is saved between redraws.
Any @State property wrapper means the current view owns this data. SwiftUI
keeps track of when this @State variable changes and redraws the view when
its value changes.
@StateObject is used with classes. You’ll mostly see @State used as SwiftUI
views are struct s.
91
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
// 1
NavigationView {
// 2
VStack {
// 3
TimeCard(timezone: timezoneHelper.currentTimeZone(),
time: DateFormatter.short.string(from: currentDate),
date: DateFormatter.long.string(from: currentDate))
Spacer()
// TODO: Add List
} // VStack
// 4
.onReceive(timer) { input in
currentDate = input
}
.navigationTitle("World Clocks")
// TODO: Add toolbar
} // NavigationView
1. A NavigationView allows you to display new screens with a title and will
animate the view.
3. Call the TimeCard class to show the time zone in a nice card format. Use the
short and long DateFormatter extensions from the Utils class.
4. Use your timer. Every time the timer changes, update the date, which will
then update the other elements.
If you look at the Utils.swift file, you’ll see the definition of the short and
long DateFormatter extension fields. Go ahead and run the app. Here’s what
it will look like:
92
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
Fig. 4.15 - World Clocks Screen
// 1
List {
// 2
ForEach(Array(timezoneItems.selectedTimezones), id: \.self) {
timezone in
// 3
NumberTimeCard(timezone: timezone,
time: timezoneHelper.getTime(timezoneId:
timezone),
hours: "\
(timezoneHelper.hoursFromTimeZone(otherTimeZoneId: timezone)) hours
from local",
date: timezoneHelper.getDate(timezoneId:
timezone))
.withListModifier()
} // ForEach
// 4
.onDelete(perform: deleteItems)
} // List
// 5
.listStyle(.plain)
Spacer()
2. Create an array of selected time zones, and create a card for each one.
3. Show the time zone in a nice time card. Use a custom list modifier to remove
the row separator and insets. (See ListModifier.swift.)
4. Add the ability to swipe to delete. You’ll define the deleteItems method
later.
5. Make the list style plain.
The ForEach is a special SwiftUI view struct and can be returned as a View ,
unlike a regular forEach() function.
// 1
.toolbar {
// 2
ToolbarItem(placement: .navigationBarTrailing) {
// 3
Button(action: {
showTimezoneDialog = true
}) {
Image(systemName: "plus")
93
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
.frame(alignment: .trailing)
.foregroundColor(.black)
}
} // ToolbarItem
} // toolbar
2. Place it on the trailing edge (right side for languages that read left to right).
3. Create a Button with a plus sign that will set the showTimezoneDialog
variable to true.
.fullScreenCover(isPresented: $showTimezoneDialog) {
TimezoneDialog()
.environmentObject(timezoneItems)
}
The button in the toolbar sets the showTimezoneDialog variable to true, which
is a state variable managed by SwiftUI. When this value changes, the full screen
modal is shown.
Next, add the deleteItems method after the var body code:
The code above goes through the indices in the IndexSet , finds the time zone
selected, and removes it from your selected list. Build and run the app. Click the
+ button at the top.
94
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
Try searching for your favorite time zones, select the time zone and then search
again.
When you search for New York, here’s what you’ll see:
95
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
96
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
97
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
Hour sheet
You’ll want to show the hours that are available to meet. You can do that by
showing the hours in a sheet, which in this case, is a modal dialog). This is a
simple view with a list of hours and a dismiss button. Create a new SwiftUI View
named HourSheet.swift in the iosApp folder. Remove the Text view, and then
add the following two variables right at the beginning of the view:
The first variable is an array of hours the caller will pass in. The second one is
the showHoursDialog Boolean . This will hide the dialog by setting this variable
to false. Add the following inside body :
// 1
NavigationView {
// 2
VStack {
// 3
List {
// 4
ForEach(hours, id: \.self) { hour in
Text("\(hour)")
}
} // List
} // VStack
.navigationTitle("Found Meeting Hours")
// 5
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
presentationMode.wrappedValue.dismiss()
}) {
Text("Dismiss")
.frame(alignment: .trailing)
.foregroundColor(.black)
}
} // ToolbarItem
} // toolbar
} // NavigationView
98
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
This creates a list for each hour and shows it in a Text view. To get the preview
to work, change the HourSheet() constructor inside HourSheet_Previews to:
Find meeting
The next screen is the find meeting screen. This is the screen where you can
choose the hours you want to search for meetings and then find the hours that
work for everyone. Create a new SwiftUI View file named FindMeeting.swift.
import shared
// 1
@EnvironmentObject private var timezoneItems: TimezoneItems
// 2
private var timezoneHelper = TimeZoneHelperImpl()
// 3
@State private var meetingHours: [Int] = []
@State private var showHoursDialog = false
// 4
@State private var startDate = Calendar.current.date(bySettingHour:
8, minute: 0, second: 0, of: Date())!
@State private var endDate = Calendar.current.date(bySettingHour:
17, minute: 0, second: 0, of: Date())!
This gives us all the variables you’ll need for you screen. Now you can start work
on the body . Add the following code inside body :
NavigationView {
VStack {
Spacer()
.frame(height: 8)
// TODO: Add Form
} // VStack
.navigationTitle("Find Meeting Time")
// TODO: Add sheet
99
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
} // NavigationView
This will be a vertical stack with a navigation view, which has a title and some
spacers around the title. Now, add the form that has two sections: a time range
with the start and end time pickers and the list of time zones selected. Replace
TODO: Add Form with:
Form {
Section(header: Text("Time Range")) {
// 1
DatePicker("Start Time", selection: $startDate,
displayedComponents: .hourAndMinute)
// 2
DatePicker("End Time", selection: $endDate,
displayedComponents: .hourAndMinute)
}
Section(header: Text("Time Zones")) {
// 3
ForEach(Array(timezoneItems.selectedTimezones), id: \.self) {
timezone in
HStack {
Text(timezone)
Spacer()
}
}
}
} // Form
// TODO: Add Button
Now comes the button that does the time zone calculation. It will call the
shared library’s search method. Replace // TODO: Add Button with:
Spacer()
Button(action: {
// 1
meetingHours.removeAll()
// 2
let startHour = Calendar.current.component(.hour, from:
startDate)
let endHour = Calendar.current.component(.hour, from: endDate)
// 3
let hours = timezoneHelper.search(
startHour: Int32(startHour),
endHour: Int32(endHour),
timezoneStrings: Array(timezoneItems.selectedTimezones))
// 4
let hourInts = hours.map { kotinHour in
Int(truncating: kotinHour)
}
100
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
meetingHours += hourInts
// 5
showHoursDialog = true
}, label: {
Text("Search")
.foregroundColor(Color.black)
})
Spacer()
.frame(height: 8)
3. Call the shared library search method, converting the hours to ints.
4. Create another array of ints from the hours returned. Convert to iOS ints.
Notice that there is a bit of conversion going on. You need to convert the Swift
Int to 32bit Int for Kotlin. Then, when you get the value back from the shared
library, you need to convert the values back to Swift Int. Now that the button
sets the flag to show the hours dialog, you need a way of showing that dialog.
You’ll use a sheet — a type of dialog that shows up at the bottom of the screen.
Replace // TODO: Add sheet with:
.sheet(isPresented: $showHoursDialog) {
HourSheet(hours: $meetingHours)
}
You are almost there. Finally, you need to add the Find Meeting tab to the
TabView.
ContentView
Return to ContentView and un-comment-out the FindMeeting section.
Build and run the app. Try to add several time zones. Go to the FindMeeting
page, tap the Search button and see if any hours show up. If you have problems
and don’t see any hours, start with one time zone and work your way up to
more. It’s quite possible that there are no compatible hours. Try increasing your
end time to 17 or 19. That will increase the range. Here’s an example of hours
between Los Angeles and New York time zones:
101
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
Congratulations! You now have both an Android and iOS app that you can show
off to your friends.
Key points
SwiftUI is a new declarative way to create UIs for Apple platforms.
You can use Xcode or Android Studio to develop your SwiftUI code.
Use @State , @StateObject , @ObservedObject and @EnvironmentObject
for holding state.
Use SwiftUI views like VStack , HStack , NavigationView and Text to
build your UIs.
Xcode: https://developer.apple.com/xcode/
102
Kotlin Multiplatform by Tutorials Chapter 4: Developing UI: iOS SwiftUI
SwiftUI:
Congratulations! You’ve written a SwiftUI app that uses a shared library for the
business logic. Now that you have both the Android and the iOS apps written,
the next chapter will show you how to create a macOS app.
103
Kotlin Multiplatform by Tutorials
If you come from a mobile background, it’s exciting to know that you can build
desktop apps with the knowledge you gained from learning Jetpack Compose
(JC). JetBrains, the maker of the technology behind Android Studio and IntelliJ,
have worked with Google to create Compose Multiplatform (CM). This uses
some of the same code from Jetpack Compose but extends it to be used for
multiple platforms. This chapter will focus on the desktop, but CM will work on
the web as well.
Differences in desktop
Unlike mobile, the desktop has features like menus, multiple windows and
system notifications. Menus can have shortcuts and windows will have different
sizes and positions on the screen. The desktop doesn’t usually use app bars like
mobile apps. You’ll usually use menus to handle actions.
4. Create some wrappers so that Android and desktop can have unique
functionality.
104
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
starter project in Android Studio and open the main build.gradle.kts. Under
allprojects and at the end of repositories add:
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
This adds the repository for the Compose Multiplatform library. Open the
shared build.gradle.kts file and add the following after kotlin -> android():
jvm("desktop"){
compilations.all {
kotlinOptions.jvmTarget = "11"
}
}
The code above creates a new JVM target with the name desktop and sets the
JDK version to 11.
Desktop module
There isn’t an easy way to create a desktop module, except by hand. At the time
of writing, JetBrains is working to improve this but it isn’t that hard to do
manually. Right-click the top-level folder in the project window and choose New
▸ Directory:
Name the directory desktop. Next, right-click on the desktop folder and
choose New ▸ File. Name the file build.gradle.kts. This build file is similar to
the shared module’s build file. Add the following:
import org.jetbrains.compose.compose
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
// 1
plugins {
kotlin(multiplatform)
id(composePlugin) version Versions.desktop_compose_plugin
}
// 2
group = "com.raywenderlich.findtime"
105
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
version = "1.0.0"
// 3
kotlin {
// TODO: Add Kotlin
}
// 1
jvm {
withJava()
compilations.all {
kotlinOptions.jvmTarget = "11"
}
}
// 2
sourceSets {
val jvmMain by getting {
// 3
kotlin.srcDirs("src/jvmMain/kotlin")
dependencies {
// 4
implementation(compose.desktop.currentOs)
// 5
api(compose.runtime)
api(compose.foundation)
api(compose.material)
api(compose.ui)
api(compose.materialIconsExtended)
implementation(Deps.napier)
// Coroutines
implementation(Deps.Coroutines.common)
// 6
implementation(project(":shared"))
// implementation(project(":shared-ui"))
}
}
}
106
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
5. Bring in the compose libraries. (Pre-defined variables).
6. Import your shared libraries. Leave shared-ui commented out until you
create it.
There’s a lot here, but the desktop Gradle setup is a bit more complex. This sets
up the libraries needed for the desktop module.
// 1
compose.desktop {
// 2
application {
// 3
mainClass = "MainKt"
// 4
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi,
TargetFormat.Deb)
packageName = "FindTime"
macOS {
bundleID = "com.raywenderlich.findtime"
}
}
}
}
2. Define an application.
Click Sync Now from the top right portion of the window. Open
settings.gradle.kts from the root directory and add the new project at the end
of the file:
include(":desktop")
Do another sync.
Next, right-click on the desktop folder and choose New ▸ Directory. Use
src/jvmMain/kotlin. This will create three folders: src, jvmMain, and kotlin
under that. Next, right-click on the kotlin folder and chose New ▸ Kotlin
Class/File. Type Main and choose file.
107
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
// 1
fun main() {
// 2
application {
// 3
val windowState = rememberWindowState()
// 4
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "TimeZone"
) {
// 5
Surface(modifier = Modifier.fillMaxSize()) {
// TODO: Add Theme
// TODO: Add MainView
}
}
}
}
1. Entry point to the application. Just like in Kotlin or Java programs, the
starting function is main.
2. Create a new application.
3. Remember the current default window state. Change this if you want the
window positioned in a different position or size.
4. Create a new window with the window state. If the user closes the window,
exit the application.
5. Set up a Surface .
Other than the commented TODOs, this is the extent of the desktop code. The
next task is to create a shared-ui module where you will move the compose
files.
Shared UI
You created the Android Compose files earlier. You put a lot of work into those
files. You could duplicate those files for the desktop, but why not share them?
That’s the idea behind the shared-ui module. You’ll move the Android files over
and make a few modifications to allow them to be used for both Android and the
desktop.
108
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
From the project window, right-click on the top-level folder and choose New ▸
Directory. Name the directory shared-ui. Next, right-click on the shared-ui
folder and choose New ▸ File. Name the file build.gradle.kts. Add the
following:
import org.jetbrains.compose.compose
plugins {
kotlin(multiplatform)
id(androidLib)
id(composePlugin) version Versions.desktop_compose_plugin
}
android {
// TODO: Add Android Info
}
kotlin {
// TODO: Add Desktop Info
}
This adds the multiplatform, Android and Compose plugins. Now replace //
TODO: Add Android Info with:
compileSdk = Versions.compile_sdk
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifes
t.xml")
defaultConfig {
minSdk = Versions.min_sdk
targetSdk = Versions.target_sdk
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
This is the minimum info needed for Android. For the desktop, replace //
TODO: Add Desktop Info with:
// 1
android()
// 2
jvm("desktop") {
compilations.all {
kotlinOptions.jvmTarget = "11"
}
}
sourceSets {
// 3
val commonMain by getting {
dependencies {
implementation(project(":shared"))
109
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
api(compose.foundation)
api(compose.runtime)
api(compose.foundation)
api(compose.material)
api(compose.materialIconsExtended)
api(compose.ui)
api(compose.uiTooling)
}
}
val commonTest by getting
val androidMain by getting {
dependencies {
implementation("androidx.appcompat:appcompat:1.3.1")
}
}
val desktopMain by getting
}
3. Define the common main sources. This includes the shared library and
Desktop Compose.
One of the nice features of CM is that it can be used with both Android, desktop
and web. For the shared-ui folder you’ll need three different source directories.
One for Android, one for a common source and one for desktop. Right-click on
shared-ui and choose New ▸ Directory.
Type src/androidMain/kotlin/com/raywenderlich/compose/ui.
This will create several folders. Next, do the same for commonMain. Select the
src directory you just created and create a new directory named
commonMain/kotlin/com/raywenderlich/compose.
This will create three main directories. The first for Android, the second for all
common code and the third for the desktop.
Open settings.gradle.kts from the root directory and add the new project:
include(":shared-ui")
Click Sync Now. Now comes the fun part. Instead of recreating all of the
Compose UI for the desktop, you’ll steal it from Android. From
androidApp/src/main/java/com/raywenderlich/findtime/android, select the
theme and ui folders. Drag the two folders to the shared-
110
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
ui/src/commonMain/kotlin/com/raywenderlich/compose folder. You’ll get
some warnings but continue. You’ll fix these problems now.
AddTimeZoneDialog
Open AddTimeZoneDialog.kt from the
commonMain/kotlin/com/raywenderlich/compose/ui folder inside the
shared-ui module. You’ll see several errors for the following imports:
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog
import com.raywenderlich.findtime.android.R
These three imports don’t exist for the shared-ui module. Remove them. After
the imports add:
@Composable
expect fun AddTimeDialogWrapper(onDismiss: onDismissType, content:
@Composable () -> Unit)
This is a Composable function that uses KMM’s expect keyword. This means
that each target this module uses needs to implement this function. Now,
change the signature for the AddTimeZoneDialog function and the code up to
the first Column with the following code:
fun AddTimeZoneDialog(
onAdd: OnAddType,
onDismiss: onDismissType
) {
val timezoneHelper: TimeZoneHelper = TimeZoneHelperImpl()
AddTimeDialogWrapper(onDismiss) {
This just uses the AddTimeDialogWrapper function to wrap the existing code.
The AddTimeDialogWrapper function will handle platform-specific code.
Android will handle the dialog one way, and the desktop another way. Go to the
end of this function and add an ending } .
One issue in using Desktop Compose is resource handling. That’s beyond the
scope of this chapter. For now, just change the string resources to hard-coded
strings. Change:
stringResource(id = R.string.cancel)
111
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
to:
"Cancel"
Change:
stringResource(id = R.string.add)
to:
"Add"
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Dialog
import com.raywenderlich.compose.ui.onDismissType
@Composable
actual fun AddTimeDialogWrapper(onDismiss: onDismissType, content:
@Composable () -> Unit) {
Dialog(
onDismissRequest = onDismiss) {
content()
}
}
This creates a function that takes a dismiss callback and the content for the
dialog. The reason you need this is that Dialog is specific to JC and not CM.
Make sure that when the file is added, it’s part of the
com.raywenderlich.compose.ui package, if it isn’t already. Right-click on
desktopMain/kotlin/com/raywenderlich/compose/ui and create the same
class AddTimeDialogWrapper.kt. Add:
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogState
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberDialogState
112
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
@Composable
actual fun AddTimeDialogWrapper(onDismiss: onDismissType, content:
@Composable () -> Unit) {
Dialog(onCloseRequest = { onDismiss() },
state = rememberDialogState(
position = WindowPosition(Alignment.Center),
),
title = "Add Timezones",
content = {
content()
})
}
This class is similar, and you may be asking why you need to create this wrapper
at all. They both refer to import androidx.compose.ui.window.Dialog . But if
you command-click on each of these imports, you’ll see they go to two different
files. The CM plugin does some substitution with packages that makes the two
libraries work together, but some of the code has to be different. Dialogs are
one such case. Here this Dialog takes a dismiss callback, a state, title and
content. Luckily this is not that much code. The bulk of the Compose code is in
AddTimeZoneDialog.
MeetingDialog
Much like AddTimeZoneDialog , you need to change MeetingDialog . Open
MeetingDialog.kt and remove the imports that show up in red. Add another
wrapper:
@Composable
expect fun MeetingDialogWrapper(onDismiss: onDismissType, content:
@Composable () -> Unit)
This is just like the other dialog wrapper. Now change the MeetingDialog
method up to Column with the following:
fun MeetingDialog(
hours: List<Int>,
onDismiss: onDismissType
) {
MeetingDialogWrapper(onDismiss) {
This adds a wrapper around the dialog. Make sure to add a closing } like
before.
Change:
stringResource(id = R.string.done)
113
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
to:
"Done"
Add:
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Dialog
@Composable
actual fun MeetingDialogWrapper(onDismiss: onDismissType, content:
@Composable () -> Unit) {
Dialog(
onDismissRequest = onDismiss) {
content()
}
}
This creates a function that takes a dismiss callback and the content for the
dialog. Right-click on desktopMain/kotlin/com/raywenderlich/compose/ui
and create the same MeetingDialogWrapper.kt class. Add:
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.rememberDialogState
@Composable
actual fun MeetingDialogWrapper(onDismiss: onDismissType, content:
@Composable () -> Unit) {
Dialog(
onCloseRequest = { onDismiss() },
title = "Meetings",
state = rememberDialogState(),
content = {
content()
})
}
This adds a close handler, a title of “Meetings”, the dialog state and the content.
To run your new desktop app, you’ll need to create a new configuration. From
114
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
the configuration dropdown, choose Edit Configurations:
Click OK.
115
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
The good news is the app ran. The bad news is there isn’t any content. Do you
know why? Right — you never added any content to Main.kt. Go back to Main.kt
in the desktop module. Inside of Surface, add:
AppTheme {
MainView()
}
116
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
This shows errors. Any ideas? Take a look at the desktop build.gradle.kts file.
Looks like you need to uncomment out the shared-ui project. You’ll have to
stop running the desktop to do any other Gradle tasks. Hit the red stop button,
uncomment the shared-ui project and resync Gradle.
Now, add the missing imports to Main.kt and run the app again.
Much better. Try using the app and see if you’re missing anything.
Note: The window background can vary depending on whether you are using
Dark Theme on your computer or not.
117
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
Window sizes
If you bring up the Add Timezones dialog, you’ll see the buttons get cut off:
How can you fix that? Dialogs have a DialogState class that allows you to set
the position and size. To fix this dialog, open AddTimeDialogWrapper inside
desktopMain and add to the rememberDialogState method so that it looks like
this:
state = rememberDialogState(
position = WindowPosition(Alignment.Center),
size = DpSize(width = 400.dp, height = Dp.Unspecified),
),
This sets a fixed width of 400dp and an unspecified height. This will allow the
height to expand to a good size.
118
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
Build and run the desktop app. You’ll see that the buttons are no longer
cropped:
Windows
Your app can have a single window or multiple windows. If you just have one
window, you can use singleWindowApplication instead of application . For
multiple windows, you need to call the Window function for each window.
@OptIn(ExperimentalComposeUiApi::class)
Add any imports needed. The WindowInfo class just holds the window name
and the window state.
119
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
above creates three variables:
Then, it adds the first window entry (only once). This will be the first window to
show up.
// 1
windowList.forEachIndexed { i, window ->
Window(
onCloseRequest = {
// 2
windowList.removeAt(i)
},
state = windowList[i].windowState,
// 3
title = windowList[i].windowName
)
Then, add an ending } at the end of application . With the above code, you
can now have multiple windows of your desktop application. You’ll see this in
action in the next section.
Menus
If you look at the menu bar on macOS, you’ll notice that your app doesn’t have
any menus as a regular app would:
120
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
You’ll now add a few menu items — like a File and Edit menu, as well as an exit
menu option underneath the File menu to let the user exit the app.
Before the Surface function, add the code for a MenuBar as follows:
// 1
MenuBar {
// 2
Menu("File", mnemonic = 'F') {
val nextWindowState = rememberWindowState()
// 3
Item(
"New", onClick = {
// 4
windowCount++
windowList.add(
WindowInfo(
"Timezone-${windowCount}",
nextWindowState
)
)
}, shortcut = KeyShortcut(
Key.N, ctrl = true
)
)
Item("Open", onClick = { }, shortcut = KeyShortcut(Key.O,
ctrl = true))
// 5
Item("Close", onClick = {
windowList.removeAt(i)
121
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
)
}
Menu("Edit", mnemonic = 'E') {
Item(
"Cut", onClick = { }, shortcut = KeyShortcut(
Key.X, ctrl = true
)
)
Item(
"Copy", onClick = { }, shortcut = KeyShortcut(
Key.C, ctrl = true
)
)
Item("Paste", onClick = { }, shortcut = KeyShortcut(Key.V,
ctrl = true))
}
}
7. Add the exit menu. This clears the window list, which will cause the app to
close.
Add any needed imports. Most of these menus don’t do anything. The File menu
item will increment the window count, the close menu will remove the window
from the window list and the exit menu will clear the list (causing the app to
quit). Run the app. Here’s what you’ll see:
Try creating new windows and closing them. See what happens when you close
122
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
the last window.
When writing your app, you may want to create your own menu file that handles
menus. It could create a different menu system based on application state.
Distribution
When you’re finally satisfied with your app, you’ll want to distribute it to your
users. The first step is to package it up into a distributable file. There isn’t cross-
compilation support available at the moment, so the formats can only be built
using your current machine.
To create a dmg installer for the Mac, you need to run the package Gradle task.
You can run it from Android Studio:
Or, run the following from the command line (in the project directory):
./gradlew :desktop:package
123
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
Windows
On Windows, the process is almost the same. Make sure you build the desktop
package from Android Studio and then from the command line type:
.\gradlew.bat :desktop:package
Or, run the package task from the compose desktop task folder. Once this
finishes, you’ll find the FindTime-1.0.0.msi file in the
desktop/build/compose/binaries/main/msi folder.
124
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
implementation(project(":shared-ui"))
Key points
Compose Multiplatform is a framework that uses most of the Jetpack
Compose framework for displaying UIs.
125
Kotlin Multiplatform by Tutorials Chapter 5: Developing UI: Compose Multiplatform
Congratulations! You’ve written a Compose Multiplatform app that uses a
shared library for the business logic. Looks like you’re on your way to mastering
all the different platforms that KMM has to offer.
126
Kotlin Multiplatform by Tutorials
6 Connect to Platform-
Speci c API
Written by Saeed Taheri
When you write a program in a high-level language such as C or Java, you have
to compile it to run on a platform like Windows or Linux. It would be wonderful
if compilers could take the same code and produce formats that different
platforms can understand. However, this is easier said than done.
Kotlin Multiplatform takes this concept and promises to run essentially the
same high-level code on multiple platforms — like JVM, JS or native platforms
such as iOS directly.
In this chapter, you’re going to learn how to structure your code according to
KMP’s suggested approach on handling platform-specific tidbits.
In Chapter 1, you got acquainted with those two new keywords. Now, you’re
going to dive deeper into this concept.
Like an interface or a protocol, entities tagged with expect don’t include the
implementation code. That’s where the actual comes in.
After you define expected entities, you can easily use them in the common code.
127
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
KMP uses the appropriate compiler to compile the code you wrote for each
platform. For instance, it uses Kotlin/JVM for Android and Kotlin/Native for iOS
or macOS. Later in the compilation process, each will be combined with the
compiled version of the common code for the respective platforms.
You may ask why you need this in the first place. Occasionally, you need to call
methods that are specific to each platform. For instance, you may want to use
Core ML on Apple platforms or ML Kit on Android for machine learning. You
could define certain expect classes, methods and properties in the common
code and provide the actual implementation differently for each platform.
The expect/actual mechanism lets you call into native libraries of each
platform using Kotlin. How cool is that!
As with many apps you use every day, Organize has a page that shows you the
device information the app is running on. If you’ve ever faced a bug in your
apps, you know how valuable this information can be when debugging.
Open the starter project for this section. It’s mostly a newly created project
using the KMM plugin on Android Studio, with extra platforms and
dependencies already set up. The main difference from the project you created
in Section 1 is that this time, you’re going to use Regular framework instead of
Cocoapods for iOS framework distribution. Learning a new thing is always
welcome, after all.
Long before Apple introduced Swift Package Manager, iOS developers used
different approaches for managing dependencies. While Cocoapods was — or
still is — somehow the de facto way of managing dependencies, some people
tend to use other approaches for many reasons.
128
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
It usually leads to longer build times since every time you build your project,
it makes Xcode build all your dependencies.
If you ever decide to create the new project yourself, you can change the iOS
framework distribution option on the following page.
Fig. 6.1 - Select Regular framework option for iOS framework distribution
For that matter, most of your work in this chapter relates to the Platform
class.
Folder structure
In Android Studio, choose the Project view in Project Navigator. Inside the
129
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
shared module, check out the directory structure.
There’s already a file called Platform.kt inside the project; or to be more exact,
there are four of them — one for each platform you support, plus one. For
making the expect/actual mechanism work, you’ll need to define the expect
and actual entities in exactly the same package in each platform.
In the image above, the expect class for Platform is inside the
com.raywenderlich.organize package under the commonMain directory. The
actual implementations for iOS, Android and desktop are inside the same
package under iosMain, androidMain and desktopMain, respectively.
130
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
fun logSystemInfo()
}
By writing this, you’re making a promise to KMP that you’re going to provide
this information. As you see, there’s no implementation for anything here. You
define what you want, just like an interface or protocol .
You may be surprised that you didn’t define Platform or ScreenInfo as a data
class; after all, these classes seem a perfect fit for a data class since they’re
essentially data holders.
You also can’t define nested classes inside an expect class . Hence, you
defined the ScreenInfo class outside the Platform definition. You can also
create a new file if you desire. Doing it in the same file would work, too.
In the code gutter, click the yellow rhombus with the letter A in it. This lets you
navigate to the actual implementation file for the platforms you defined in the
project.
131
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
If the files were not already in their respective places, or you haven’t
implemented the actual definition yet, you can put the cursor on the expect
class name and press Alt+Enter on the keyboard. Android Studio will help ease
the process. This is the case for ScreenInfo , for instance:
You’ll see that Android Studio has already started nagging you to fulfill the
promise. After all, KMP is in its infancy, and you know how toddlers are!
132
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
//1
actual class Platform actual constructor() {
//2
actual val osName = "Android"
//3
actual val osVersion = "${Build.VERSION.SDK_INT}"
//4
actual val deviceModel = "${Build.MANUFACTURER} ${Build.MODEL}"
//5
actual val cpuType = Build.SUPPORTED_ABIS.firstOrNull() ?: "---"
//6
actual val screen: ScreenInfo? = ScreenInfo()
//7
actual fun logSystemInfo() {
Log.d(
"Platform",
"($osName; $osVersion; $deviceModel;
${screen!!.width}x${screen!!.height}@${screen!!.density}x;
$cpuType)"
)
}
}
// 8
actual class ScreenInfo actual constructor() {
//9
private val metrics = Resources.getSystem().displayMetrics
//10
actual val width = metrics.widthPixels
actual val height = metrics.heightPixels
actual val density = round(metrics.density).toInt()
}
1. You provide the actual implementation for the Platform as well as its
default constructor. Here, you can’t use the shorthand notation as you did in
the expect file. You need to explicitly put an actual keyword before the
constructor .
2. For the operating system name, you provided the value "Android" because
you know this code will be compiled for the Android part of the shared
module.
3. For the operating system version, you used the SDK version from the Build
class in Android. Make sure to let the Android Studio import the needed
package: android.os.Build . Since you’re inside the Android part of the
shared module, you can freely use any Android-specific API.
4. For the device model, you used static properties of MANUFACTURER and
133
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
5. Thankfully, Build can give you the CPU type of the device using the
SUPPORTED_ABIS property. Since the result may be null on some older
versions of Android, provide a default value as well.
8. You provide the actual implementation for the ScreenInfo as well as its
default constructor.
10. You get the screen width, height and density using the metrics property you
defined earlier. Import kotlin.math.round to be able to use the round
function.
The basics of the code you’re going to add is the same as before. This time,
though, you’re calling into iOS-specific frameworks such as UIKit ,
Foundation and CoreGraphics to fetch the needed information.
134
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
actual val osName = when
(UIDevice.currentDevice.userInterfaceIdiom) {
UIUserInterfaceIdiomPhone -> "iOS"
UIUserInterfaceIdiomPad -> "iPadOS"
else -> kotlin.native.Platform.osFamily.name
}
//2
actual val osVersion = UIDevice.currentDevice.systemVersion
//3
actual val deviceModel: String
get() {
memScoped {
val systemInfo: utsname = alloc()
uname(systemInfo.ptr)
return NSString.stringWithCString(systemInfo.machine,
encoding = NSUTF8StringEncoding)
?: "---"
}
}
//4
actual val cpuType = kotlin.native.Platform.cpuArchitecture.name
//5
actual val screen: ScreenInfo? = ScreenInfo()
//6
actual fun logSystemInfo() {
NSLog(
"($osName; $osVersion; $deviceModel;
${screen!!.width}x${screen!!.height}@${screen!!.density}x;
$cpuType)"
)
}
}
1. There’s a class in UIKit called UIDevice from which you can query
information about the currentDevice . In this code, you’re asking for the
interface idiom to differentiate between iOS and iPadOS. The
UIUserInterfaceIdiom enum has a couple more cases. For brevity, you
used the Kotlin/Native Platform class to find information in the else block.
3. This is by far the clunkiest piece of code you’ll see in this book. But don’t
worry: KMP isn’t usually like this. It’s here to show you where things can go
wild. Objective-C at its core is C. There are some older APIs in Apple
135
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
platforms that go way back to the 1990s. Because they are low-level and
people don’t use them often, Apple never upgraded them to have a nicer
interface. One of them is a C struct called utsname . Unix fans, rejoice! Here,
you’re calling a C function through Kotlin/Native. How crazy is that? In this
block of code, you’re allocating memory using the memScoped block and the
alloc() function call. Then, you pass a pointer to the allocated memory
space to the uname function, which gets the operating system information
and fills it inside systemInfo . You then convert the C String filled with the
machine name to NSString and return it. The cast from NSString to
Kotlin String is automatic. Phew!
4. For getting the CPU type, you can once again dig into C code, or like here, just
use the Kotlin/Native Platform class.
5. You initialize an instance of ScreenInfo and store it in screen property. As
you did on Android, make sure to write the type explicitly.
These may look a bit weird for Kotlin and Swift developers. The reason is that
you’re using the Objective-C nomenclature for entities. This is how KMP works
for Apple platforms. The interoperability is thereby creating a bridge between
Kotlin and Objective-C.
The block of code in Section 3 is odd, even for Swift developers. If you were to
write this section using Swift, there would also be some travels to the C world:
Why Objective-C and not Swift, you may ask. Although Swift interoperability is
in the works by KMP creators, they chose to go with Objective-C for a couple of
reasons:
136
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
1. Many of the iOS frameworks themselves are built with Objective-C. Even
when you write Swift code, you’re using a bridge.
2. Objective-C has a more flexible and dynamic runtime than Swift. Apparently,
Swift’s stricter type-safety features would have made the creation of KMP
interoperability with Apple technologies more difficult.
Using Objective-C instead of Swift has some issues, though. For instance, you
can’t use some newly introduced frameworks such as Combine as they’re
Swift-only. Furthermore, you can’t use Swift-only extension functions or
properties — or even Swift enum cases — either. You have no choice other than
to use the verbose naming of entities in Objective-C.
//2
actual val osVersion = System.getProperty("os.version") ?: "---"
//3
actual val deviceModel = "Desktop"
//4
actual val cpuType = System.getProperty("os.arch") ?: "---"
//5
actual val screen: ScreenInfo? = null
//6
actual fun logSystemInfo() {
print("($osName; $osVersion; $deviceModel; $cpuType)")
}
}
1. The desktop app is based on JVM. As a result, you can use JDK classes and
methods to get information about the device. There’s a class in Java called
System . You can get the operating system name by using the static
137
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
getProperty method with the "os.name" parameter. Since this method
may return null, you provided a default result.
2. You use the same method as before, but this time with the "os.version"
parameter.
3. You hard-code the value "Desktop" . JVM doesn’t provide a way to know
anything about the manufacturer and model.
4. Once again, System class to the rescue! Use "os.arch" as the parameter.
5. You provide null as the value for ScreenInfo . You’ll soon know why.
6. Next, you use Kotlin’s print function to output the usual info to the
console. This time, though, there’s no information about the screen .
You did a couple of weird things here. Most importantly, you didn’t provide a
useful actual implementation for ScreenInfo .
Java doesn’t have a UI toolkit in itself. If a platform owner wants to use JVM and
provide developers with a way to develop user interfaces, it creates a UI toolkit
or uses one already available.
You may have heard about Swing or Abstract Window Toolkit (AWT). Jetpack
Compose for Desktop uses Swing internally to make window-based desktop
applications. As of writing this book, Jetpack Compose for Desktop doesn’t
provide a way to query screen information outside its composable methods.
You can’t use Swing or AWT methods directly in the desktop section of the
shared module, either. The problem is that this module is using Android
Gradle Plugin. Because Android has its own UI toolkit and because of the way
Android uses Java, there’s no way to have the java plugin alongside Android
Gradle Plugin. Therefore, you can’t use AWT or Swing methods here.
This issue has long been listed in both Google Issue Tracker and Jetbrains
YouTrack, and unfortunately, there’s no solution for it yet.
Now it makes sense why you defined the screen property as optional and
there’s no value for it on desktop.
Note: Since the Android Gradle Plugin is applied to the whole module, the
IDE allows you to import Android-related classes such as Log or
DisplayMetrics even inside the Desktop folder. However, using them will
make your desktop app crash, since they’re not available at runtime.
138
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
Open Platform.kt inside commonMain folder. As you know, you can’t add
implementation to the properties or functions you defined here. However, no
one said you can’t use Kotlin extension functions.
You’re making the same string, this time based on the fact that screen may be
null.
Now go back to the actual files and use this property inside logSystemInfo
functions.
With this technique, you’re able to share code between the actual
139
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
implementations.
Updating the UI
Now that the Platform class is ready, you’ve finished your job inside the shared
module. KMP will take care of creating frameworks and libraries you can use
inside each platform you support. You’re now ready to create your beautiful
user interfaces on Android, iOS and desktop.
Android
You’ll do all of your tasks inside the androidApp module. The basic structure of
the app is ready for you. Some important files need explaining. These will help
you in the coming chapters as well. Here’s what it looks like:
Inside the root folder, there are AppScaffold.kt and AppNavHost.kt. These two
files set up the screens of the app and make the navigation between them work
as intended. Please don’t hesitate to take a look if you’re interested.
The app has two main screens: RemindersView, which shows a simple “Hello
World” for now, and the AboutView, which you’re going to set up in this
chapter. Go ahead and open it.
@Composable
private fun ContentView() {
val items = makeItems()
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
140
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
items(items) { row ->
RowView(title = row.first, subtitle = row.second)
}
}
}
Here, you get the items you’d like to show out of the function makeItems and
put them inside a LazyColumn , which is basically a list view. Import Modifier ,
LazyColumn and fillMaxSize from the Compose library. Add the following
import for the items method:
import androidx.compose.foundation.lazy.items
//2
val items = mutableListOf(
Pair("Operating System", "${platform.osName}
${platform.osVersion}"),
Pair("Device", platform.deviceModel),
Pair("CPU", platform.cpuType)
)
//3
platform.screen?.let {
val max = max(it.width, it.height)
val min = min(it.width, it.height)
return items
}
1. First, you initialize an instance of the Platform class you created earlier.
2. Next, you create pairs of data with titles and info from the platform and
store them in a mutable list.
3. Although you know the screen property isn’t null on Android, it’s better to
be safe than sorry when facing nullable properties.
Import max and min from kotlin.math . And for the final piece of this app,
add the RowView composable function as follows:
@Composable
141
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
private fun RowView(
title: String,
subtitle: String,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(8.dp)) {
Text(
text = title,
style = MaterialTheme.typography.caption,
color = Color.Gray,
)
Text(
text = subtitle,
style = MaterialTheme.typography.body1,
)
}
Divider()
}
}
This is a simple vertical stack of text items that show a title and subtitle. You can
use predefined material typography values to polish things up. These are similar
to the predefined text styles in iOS Dynamic Type feature.
That’s the end of your journey on Android in this chapter. Build and run the
app, and check out the result.
142
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
iOS
Although no one can stop you from using Android Studio for editing Swift files,
it would be smarter to open Xcode.
Inside the iosApp folder in the project’s root directory, open the Xcode project
by double-clicking iosApp.xcodeproj.
The ContentView.swift file is the starting page of the application. It’s already
there for you. Take a look if you’d like.
Open AboutView.swift and replace the line where it has Text("Hello World")
with this:
AboutListView()
143
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
import shared
This is the framework KMP created for you. If it gives you an error stating that
it’s not found, don’t worry. Building the project will resolve the issue.
Second, add an inner struct to hold the data you’re going to show:
//2
var result: [RowItem] = [
.init(
title: "Operating System",
144
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
subtitle: "\(platform.osName) \(platform.osVersion)"
),
.init(
title: "Device",
subtitle: platform.deviceModel
),
.init(
title: "CPU",
subtitle: platform.cpuType
)
]
//3
if let screen = platform.screen {
let width = min(screen.width, screen.height)
let height = max(screen.width, screen.height)
result.append(
.init(
title: "Display",
subtitle: "\(width)×\(height) @\(screen.density)x"
)
)
}
//4
return result
}()
4. At the end, you return the array you’d like to show in the page.
145
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
This is a very basic list in SwiftUI. For each item inside the items property, you
show a vertical stack of text elements consisting of the title and the subtitle. You
also apply a bunch of formatting modifiers such as font and
foregroundColor to make it more pleasing to the eye.
Build and run. Then, tap the About button to see the page you created.
Desktop
In Section 1, you learned how to share your UI code between Android and
desktop. To show that this isn’t necessary, you’ll follow a different approach for
146
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
Organize: You go back to the tried-and-true copy and pasting!
The setup for the desktop app is a bit different from the Android app, though
they both use Jetpack Compose. One difference is that you don’t use Jetpack
Navigation Component on the desktop app. You also open the About Device
page in a new window to be more in line with desktop conventions.
Except for a few nuances in design in the About page, like showing each data
item in a Row instead of a Column, the code is the same. It’s there for you in the
starter project. Open AboutView.kt , locate //2 and //3 and uncomment
the code below them.
There are multiple ways to run the desktop app. You can open the Gradle menu
on the side under desktopApp ▸ compose desktop and click run.
The app runs, and it looks pretty much like the Android app.
147
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
Challenge
Here’s a challenge for you to practice what you learned. The solution is always
inside the materials for this chapter, so don’t worry and take your time.
148
Kotlin Multiplatform by Tutorials Chapter 6: Connect to Platform-Specific API
Refactor these calls into a new class called Logger . As a bonus, you can add log
levels to your implementation.
Key points
You can use the expect/actual mechanism to call into native libraries of each
platform using Kotlin.
Expect entities behave so much like an interface or protocol .
149
Kotlin Multiplatform by Tutorials
7 App Architecture
Written by Saeed Taheri
In the previous chapter, you started creating the Organize app. However, you
didn’t make it to the organization part. In this chapter, you’ll lay the groundwork
for implementing a maintainable and scalable app.
Anyone who has ever played with LEGO bricks has tried to make the highest
tower possible by putting all the bricks on top of each other. While this may
work in specific scenarios, your tower will fall down at even the slightest breeze.
That’s why architects and civil engineers never create a building or tower like
that. They plan extensively so their creations stay stable for decades. The same
applies to the software world.
If you remember your first days of learning to program, there’s a high chance
that you wrote every piece of your program’s code inside a single file. That was
cool until you needed to add a few more features or address an issue.
Design patterns
The broad heading of software architecture consists of numerous subtopics.
One of these is architectural styles — otherwise known as software design
patterns. This topic is so substantial that many people use software design
patterns to refer to the software architecture itself.
Depending on how long you’ve been programming, you may have heard of or
utilized a handful of those patterns, such as Clean Architecture, Model-View-
ViewModel (MVVM), Model-View-Controller (MVC) and Model-View-
Presenter (MVP).
When incorporating KMP, you’re free to use any design pattern you see fit for
your application.
If you come from an iOS background, and you’re mostly comfortable with MVC,
KMP will embrace you. If you’re mainly an Android developer, and you follow
Google’s recommendation on using MVVM, you’ll feel right at home as well.
150
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
There is no best or worst way to do it.
Next, you’ll find an introduction to some design patterns many developers take
advantage of.
Model-View-Controller
The MVC pattern’s history goes back to the 1970s. Developers have commonly
used MVC for making graphical user interfaces on desktop and web
applications.
In the mobile world, Apple made MVC mainstream when it introduced the
iPhone SDK in 2008. If you did iOS development before SwiftUI, you may have
noticed that one of the base components was a UIViewController . It speaks for
itself how Apple heavily invested in this pattern.
For long years before Google became opinionated about Android development
patterns and architectures, developers used Model-View-Presenter, or MVP,
which is a close deviation of MVC.
The diagram below shows the relationship between the different partitions:
controller
update
user action
151
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
Model-View-ViewModel
As the name implies, MVVM is a great fit for applications with views or user
interfaces. Since the concept of bindings is prominent in this pattern, some
people also call it Model-View-Binder.
It’s much newer than MVC. John Gossman, one of Microsoft’s engineers,
announced MVVM in his blog in 2005. Microsoft embraced MVVM in .NET
frameworks and made this pattern very popular.
Google introduced the architecture components in Google I/O 2017. This was
the first time Google decided to recommend a design pattern for developing
Android applications. Throughout the years, Google also introduced different
tools and components around the concept of MVVM. Nowadays, MVVM is the
first choice for most Android developers when developing an app.
Model: It’s much like the model layer of MVC. It represents the app data and
rules.
View: Pretty much similar to the component with the same name in MVC. It
represents the model, receives input from the user and forwards the
handling of the input to the ViewModel via a link between View and
ViewModel. People usually call this link a Binding.
Clean Architecture
In 2012, Robert C. Martin, also known as Uncle Bob, published a post in his blog
explaining the details of a new design pattern he came up with based on
Hexagonal Architecture, Onion Architecture and many more.
152
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
components. However, because of its extensibility and its ability to handle
various problems in software development, it’s very popular among
professionals — especially when they want to create large applications.
Devices Web
Controllers
Use Cases
Entities
Ga
s
ter
tew
en
ays
es
Pr
DB UI
External
interfaces
The circles above represent different levels of software in an app. There are two
principles to bear in mind about this graph:
1. The center circle is the most abstract, and the outer circle is the most
concrete. This is called the Abstraction Principle. The Abstraction Principle
specifies that inner circles should contain business logic, and outer circles
should contain implementation details. In other words, the closer you are to
the center, the less dependency on a specific platform you have.
2. Another principle of Clean Architecture is the Dependency Rule. This rule
specifies that each circle can depend solely on the nearest inward circle —
this is what makes the architecture work. This makes code based on Clean
Architecture pretty decoupled and hence testable.
153
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
Controllers or Presenters: This is the layer you used to have in MVC as
Controller or ViewModel in MVVM. They receive input from the outer layer
and pass them to the next layer. You can combine MVVM and MVC with Clean
Architecture. It’s also a good thing to do since the responsibilities of your
controllers or ViewModels will decrease.
Use Cases or Interactors: This layer defines the actions the user can trigger.
The objects in the previous layer have access to use cases and can only call
into the defined interactions. In the original definition of Clean Architecture,
this is the layer you put your business logic in. As you’re free to add your
layers, you can delegate this responsibility to inner layers as well.
Entities: Abstract definitions of all the data sources. It can contain some
business logic.
While creating the Organize app, you’re going to use the MVVM design pattern.
You’re free to choose any other pattern you like better for your applications.
Creating ViewModels
Open the starter project in Android Studio. It’s mostly the final project of the
previous chapter.
You’re familiar with this line. This time, though, it’s defining an abstract class,
which all our app’s ViewModels would extend. Next, you’re going to implement
the actual implementations of this class on all three platforms.
Put the cursor in the middle of the class name and press Alt+Enter. Android
Studio will help you create all the needed actual files. Repeat the process until
the red line below the class name showing the error for absent files disappears.
154
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
Note: If Android Studio failed to automatically create the needed actual files
for you, don’t worry. Create a file in the same package with the same name
inside the missing platform’s folder.
Open the Android version of BaseViewModel.kt and replace the content with
this line:
import androidx.lifecycle.ViewModel
For iOS and desktop, you don’t need to extend anything. What Android Studio
did for the actual files on those platforms is more than enough. They look like
this:
155
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
Creating AboutViewModel
Now that you have a base viewmodel, it’s time to create the concrete versions.
Start by creating a file named AboutViewModel.kt in the commonMain folder
inside the presentation directory.
Define the class and subclass from the BaseViewModel you created earlier.
Inside the class, create an instance of the Platform class and press Alt + Enter
to import it.
Define a data class inside the AboutViewModel class to hold the data you show
in each row of the About page:
Next, create a function that generates the items for the About page. You wrote
the same logic three times — once for each platform — in the previous chapter.
You’ll remove them all later.
156
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
In the end, create an instance property to store the result of this function to
avoid recreating the data. After all, these data would never change and are
rather static.
Android
Open AboutView.kt inside the androidApp module.
Next, edit the definition of AboutView method as follows to account for the
viewmodel:
@Composable
fun AboutView(
viewModel: AboutViewModel = AboutViewModel(),
onUpButtonClick: () -> Unit
)
For now, provide a default value for the viewModel parameter. In later
chapters, you’ll introduce dependency injection to your code and improve this
instantiation.
@Composable
private fun ContentView(items: List<AboutViewModel.RowItem>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(items) { row ->
RowView(title = row.title, subtitle = row.subtitle)
}
}
}
You’ll inject the items this function needs for rendering, as a parameter. You
also removed the now-unnecessary makeItems method invocation.
157
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
ContentView(items = viewModel.items)
Build and run the Android app. It works just like before, but this time it uses a
viewmodel.
Fig. 7.4 - The About Device page of Organize on Android built using ViewModel.
iOS
Open the Xcode project and switch to AboutView.swift.
At the top of the file, make sure to import the shared module by adding this
line:
import shared
Next, inside the AboutView struct, add a property for the viewmodel:
You annotate the property with @StateObject directive to make SwiftUI create
158
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
and hold an instance of AboutViewModel for the lifetime of AboutView .
You’ll get a compiler error telling you that AboutViewModel should conform to
ObservableObject protocol so that you could annotate it with @StateObject .
Don’t worry — it’s pretty easy to fix. Add the conformance to this protocol by
adding this block to the end of the file:
Note: In Swift, you can conform to protocols anywhere. Contrary to Kotlin, you
don’t need to do it when defining the type.
In the code above, you are using a hardcoded row item to fix the UI preview
inside Xcode.
AboutListView(items: viewModel.items)
Build and run to see the result of refactoring you just did.
159
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
Fig. 7.5 - The About Device page of Organize on iOS built using ViewModel.
Desktop
You’re now familiar with the process. Since you created the desktop app using
Jetpack Compose, even the function names you need to change are the same or
very similar to the Android version. Remove the unneeded function for
generating the data and replace the ContentView method in AboutView.kt in
the desktopApp module.
@Composable
fun AboutView(viewModel: AboutViewModel = AboutViewModel()) {
ContentView(items = viewModel.items)
}
@Composable
private fun ContentView(items: List<AboutViewModel.RowItem>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(items) { row ->
RowView(title = row.title, subtitle = row.subtitle)
}
}
}
160
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
Build and run the desktop app and see the changes…or the lack thereof!
Fig. 7.6 - The About Device page of Organize on Desktop built using ViewModel.
Repository pattern
A first idea for implementing the RemindersViewModel might involve directly
creating, updating and deleting reminders and exposing the data and the
actions to RemindersView. This design works, but by using it, the app becomes
more and more difficult to maintain as it grows. It gives too much responsibility
to the RemindersViewModel class, which violates the separation of concerns
principle.
For instance, when you start integrating a database into the app in later
chapters, you would need to update many things in the viewmodel.
161
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
it’s a remote server, a local database or even a cache in memory.
You’ll get a compiler error stating that the Reminder type is unresolved.
Reminder will be a data model for our app. To keep things more organized,
you’re going to create the Reminder class inside a directory called domain,
which is another sibling of presentation and data. If you pay close attention,
you’ll notice that there are some cues from the Clean Architecture here. But
don’t worry — you’ll only use some naming conventions and won’t dig deeper
than that.
Each reminder will have an identifier, a title and a value for whether it’s
completed or not.
162
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
mechanism. It’s already there in the starter project. Take a look at its
implementation if you’re interested.
It first checks if an item with the id exists. If the answer is yes, it updates the
isCompleted value.
In the end, create a public getter property for all the reminders. Later, you’ll
change this to a Kotlin Flow to be able to propagate live changes to the
viewmodel and view. Since using Flows on iOS is a bit tricky, you’ll stick to plain
properties for now.
Creating RemindersViewModel
Inside the presentation directory of commonMain module, create a new file
and name it RemindersViewModel.kt. Update it with the following:
//2
private val reminders: List<Reminder>
get() = repository.reminders
//3
var onRemindersUpdated: ((List<Reminder>) -> Unit)? = null
set(value) {
field = value
onRemindersUpdated?.invoke(reminders)
}
//4
fun createReminder(title: String) {
val trimmed = title.trim()
if (trimmed.isNotEmpty()) {
repository.createReminder(title = trimmed)
163
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
onRemindersUpdated?.invoke(reminders)
}
}
//5
fun markReminder(id: String, isCompleted: Boolean) {
repository.markReminder(id = id, isCompleted = isCompleted)
onRemindersUpdated?.invoke(reminders)
}
}
3. Views can connect to this property to find out about changes in reminders.
For now, it’s the link or the binding component of MVVM. You make sure to
call the lambda with the current state of reminders at its setter block.
@Composable
fun RemindersView(
viewModel: RemindersViewModel = RemindersViewModel(),
onAboutButtonClick: () -> Unit,
) {
Column {
Toolbar(onAboutButtonClick = onAboutButtonClick)
ContentView(viewModel = viewModel)
}
}
You’ll give the ContentView function a massive upgrade. First, remove the
existing code in the function. Next, change the function signature to accept a
parameter of type RemindersViewModel .
@Composable
private fun ContentView(viewModel: RemindersViewModel) {
}
164
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
Add a variable for remembering the state of reminders. Jetpack Compose re-
renders, or recomposes the function whenever this state changes.
In the code above, by is a delegate syntax. Doing so delegates the get() and
set() methods to the remember method. Add the following imports to resolve
the IDE warning:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
viewModel.onRemindersUpdated = {
reminders = it
}
Whenever the viewmodel calls the onRemindersUpdated lambda, you set the
new value of the reminders list to the reminders state variable. This makes the
component react to changes. The policy you set in an earlier step will make
sure the recomposition always happens, regardless of the equality status of new
and old values.
LazyColumn(modifier = Modifier.fillMaxSize()) {
//1
items(items = reminders) { item ->
//2
val onItemClick = {
viewModel.markReminder(id = item.id, isCompleted =
!item.isCompleted)
}
//3
ReminderItem(
title = item.title,
isCompleted = item.isCompleted,
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = true, onClick = onItemClick)
.padding(horizontal = 16.dp, vertical = 4.dp)
)
165
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
}
}
1. Using the items composable function, you provide the reminders state
variable to the LazyColumn function. LazyColumn is an efficient version of
List that renders only the subset of items that can be displayed on the screen.
3. Use the already provided ReminderItem function for each row of the list.
You are welcome to take a look at its implementation.
After the items block, you add an item that will contain the text field to add
new reminders.
item {
//1
val onSubmit = {
viewModel.createReminder(title = textFieldValue)
textFieldValue = ""
}
//2
NewReminderTextField(
value = textFieldValue,
onValueChange = { textFieldValue = it },
onSubmit = onSubmit,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
When the user presses the Return or the Done key on their phone’s keyboard,
the system calls the onSubmit lambda, which creates a new reminder and
clears the text field.
Finally, add the textFieldValue state variable at the top of the function as
follows:
Build and run the app. Add a couple of reminders and mark a few of them as
done.
166
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
One way to address the issue is to create a wrapper around the viewmodel and
expose a published property for SwiftUI to use.
Open the iosApp.xcodeproj and create a new Swift file by pressing Command-
N. Name it RemindersViewModelWrapper.swift and place it in the Reminders
directory.
//1
import Combine
167
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
import shared
//2
final class RemindersViewModelWrapper: ObservableObject {
//3
let viewModel = RemindersViewModel()
//4
@Published private(set) var reminders: [Reminder] = []
init() {
//5
viewModel.onRemindersUpdated = { [weak self] items in
self?.reminders = items
}
}
}
2. Make the wrapper conform to ObservableObject . You did the same for
AboutViewModel .
4. You expose a @Published property out of this class. SwiftUI will re-render
the body of the view when this property changes. @Published property
wrappers are a part of Combine framework.
Open RemindersView.swift and replace the struct content with the following
code. It’s rather long, but it looks and behaves a lot like what you did with
Jetpack Compose:
//2
@State private var textFieldValue = ""
168
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
//6
withAnimation {
viewModelWrapper.viewModel.markReminder(
id: item.id,
isCompleted: !item.isCompleted
)
}
}
}
}
}
//7
Section {
NewReminderTextField(text: $textFieldValue) {
withAnimation {
viewModelWrapper.viewModel.createReminder(title:
textFieldValue)
textFieldValue = ""
}
}
}
}
.navigationTitle("Reminders")
}
}
5. For each row of the list in the first section, you use an instance of
ReminderItem that’s inside the starter project.
6. When the user taps on each row, you call into viewModel to mark the
reminder as completed or vice versa. The withAnimaton function makes the
transition look smooth.
7. This section is there to create a text field for adding new items. You bind it to
textFieldValue property.
Build and run, and check out the Reminders page in all its glory!
169
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
Note: You may see errors in your code stating that Android Studio can’t
access androidx.lifecycle.ViewModel , which is a supertype of
com.raywenderlich.organize.presentation.RemindersViewModel . You can
safely ignore this error, as the app will build and run successfully. This seems
like a bug in KMM plugin on Android Studio.
170
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
One point to mention is that the apps forget your reminders whenever you
relaunch them or navigate to another page and come back. This is because
you’re storing the reminders in a property inside the repository. In later
chapters, when you integrate a database, you’ll fix this.
In the final project, there are a couple of touches for improving keyboard
support — such as focus switch. For brevity’s sake, they weren’t in this chapter.
In the next chapter, you’re going to add tests to the project. Since all the
business logic now resides in a single place, you’re going to write a single set of
tests. Hence, you’ll write fewer test codes — which means you’re secretly
rejoicing!
You might have thought it was a little weird to copy and paste code between
Android and desktop. And, you might be thinking of a way to share these pretty
similar pieces of code. Since you’ve been using Jetpack Compose for both of
these platforms, there are a couple of ways to share these codes. You’ll learn
more about one option in Appendix 3.
One thing to notice is that sharing UI code may not always be a good decision for
a couple of reasons:
You may have noticed that the desktop app looks a bit weird aesthetically. It’s
adhering to Material Design guidelines, which isn’t a typical approach on
171
Kotlin Multiplatform by Tutorials Chapter 7: App Architecture
desktop. It doesn’t look like other native apps on Windows or macOS, either.
Many would prefer to stick to the native UI toolkit of each platform instead of
using Jetpack Compose, which uses Java Swing under the hood. For that
matter, many developers wouldn’t create their desktop app using the
approach you saw in this book. If you don’t do that, you won’t have Jetpack
Compose for Desktop, and therefore, no code to share between Android and
desktop.
Each platform has its differences. A desktop app would usually need a
different design than the Android app. For instance, it doesn’t need to have
large touch targets, it doesn’t have multitouch, it mostly uses mouse and
keyboard instead of touch as input, etc. If you want to create a great app for
each platform, you need to take these into consideration.
All these explanations apply to UI testing as well. If you somehow share UI code,
you can have shared UI tests. If you don’t, you’ll need to create UI tests for each
platform separately.
Challenge
Here’s a challenge for you to see if you mastered this chapter. The solution is
waiting for you inside the materials for this chapter.
Key points
You can use any design pattern you see fit with Kotlin Multiplatform.
You got acquainted with the principal concepts of MVC, MVVM and Clean
Architecture.
Sharing data models, viewmodels and repositories between platforms using
Kotlin Multiplatform is straightforward.
You can share business logic tests using Kotlin Multiplatform.
Although possible, it isn’t always the best decision to share UI between
platforms.
172
Kotlin Multiplatform by Tutorials
8 Testing
Written by Saeed Taheri
Here it comes — that phase in software development that makes you want to
procrastinate, no matter how important you know it really is.
Whether you like it or not, having a good set of tests — both automated and
manual — ensures the quality of your software. When using Kotlin
Multiplatform, you’ll have enough tools at your hand to write tests. So if you’re
thinking of letting it slide this time, you’ll have to come up with another excuse.
:]
From the starter project, open the build.gradle.kts inside the shared module.
In the sourceSets block, add a block for commonTest source set after val
commonMain by getting :
You’re adding two modules from the kotlin.test library. This library provides
annotations to mark test functions and a set of utility functions needed for
assertions in tests — independent of the test framework you’re using. The -
common in the name shows that you can use these inside your common
multiplatform code. Do a Gradle sync.
As you declared above, your test codes will be inside the commonTest folder.
Create it as a sibling directory to commonMain by right-clicking the src folder
inside the shared module and choosing New ▸ Directory. Once you start typing
commonTest, Android Studio will provide you with autocompletion. Choose
commonTest/kotlin.
173
Kotlin Multiplatform by Tutorials Chapter 8: Testing
Note: Although not necessary, it’s a good practice to have your test files in the
same package structure as your main code. If you want to do that, type
commonTest/kotlin/com/raywenderlich/organize/presentation in the
previous step, or create the nested directories manually afterward.
Now it’s time to create the very first test function for the app. Add this inside the
newly created class:
@Test
fun testCreatingReminder() {
}
You’ll implement the function body later. The point to notice is the @Test
annotation. It comes from the kotlin.test library you previously added as a
dependency. Make sure to import the needed package at the top of the file if
Android Studio didn’t do it automatically for you: import kotlin.test.Test .
As soon as you add a function with @Test annotation to the class, Android
Studio shows run buttons in the code gutter to make it easier for you to run the
tests.
174
Kotlin Multiplatform by Tutorials Chapter 8: Testing
You can run the tests by clicking on those buttons, using commands in the
terminal, or by pressing the keyboard shortcut Control-Shift-R on Mac or
Control-Shift-F10 on Windows and Linux.
If you read the logs carefully, you’ll notice that the compiler was unable to
resolve the references to Test . Here’s why this happened:
175
Kotlin Multiplatform by Tutorials Chapter 8: Testing
//1
val iosX64Test by getting
val iosArm64Test by getting
val iosSimulatorArm64Test by getting
val iosTest by creating {
dependsOn(commonTest)
iosX64Test.dependsOn(this)
iosArm64Test.dependsOn(this)
iosSimulatorArm64Test.dependsOn(this)
}
//2
val androidTest by getting {
dependencies {
implementation(kotlin("test-junit"))
implementation("junit:junit:4.13.2")
}
}
//3
val desktopTest by getting {
dependencies {
implementation(kotlin("test-junit"))
implementation("junit:junit:4.13.2")
}
}
1. You create a source set for the iOS platform named iosTest by combining
the platform’s various architectures. iOS doesn’t need any specific
dependencies for testing. The needed libraries are already there in the
system.
2. For Android, you add a source set with dependencies to junit . This will
make sure there’s a concrete implementation for provided annotations by
kotlin.test library.
3. Since desktop uses JVM like Android does, you add the same set of
dependencies as Android.
Do a Gradle sync to download the dependencies. Now run the test again for
Android. It’ll pass, and the system won’t throw any errors.
176
Kotlin Multiplatform by Tutorials Chapter 8: Testing
property in the class for this matter as follows:
Next, you need to somehow initialize this property. When writing tests, you can
tag a function with @BeforeTest annotation. This will make sure that the
specific function runs before every test in the class. That seems a good place to
set up the viewModel. Add this function to the class:
@BeforeTest
fun setup() {
viewModel = RemindersViewModel()
}
@Test
fun testCreatingReminder() {
//1
val title = "New Title"
//2
viewModel.createReminder(title)
//3
val count = viewModel.reminders.count {
it.title == title
}
//4
assertTrue(
actual = count == 1,
message = "Reminder with title: $title wasn't created.",
)
}
177
Kotlin Multiplatform by Tutorials Chapter 8: Testing
4. kotlin.test library includes several assert functions, which you can take
advantage of. Here, you’re using assertTrue to check if count equals 1. If
that’s true, it means the creation process was successful. If not, you show a
message in the console.
Now it’s time to run the test. To run the tests on all platforms at once, you can
try either of these actions:
Choose allTests from the list of tasks in Gradle pane in Android Studio.
178
Kotlin Multiplatform by Tutorials Chapter 8: Testing
Whatever option you chose, you will have a successful test for all platforms.
Hooray!
To address this matter, you need to have multiple test suites. Those would follow
the source sets pattern you saw in previous steps. Create androidTest, iosTest
and desktopTest directories in the shared module. Don’t forget to add
com/raywenderlich/organize directories.
You have two choices: Either you use the same expect/actual mechanism for
your test class, or you create the test classes independent of each other in each
source set. In both methods, the system will run all the functions annotated
with @Test . However, since expect/actual will force you to fulfill the expected
test functions, it’s a safer choice from a structural standpoint.
179
Kotlin Multiplatform by Tutorials Chapter 8: Testing
brevity, this is the only Platform test function you’ll see in this chapter.
You’ve heard a lot about how to create actual classes. If you aren’t yet
comfortable enough with the process, go back and take a look at Chapter 6.
Android
Create PlatformTest.kt inside the directories you created earlier in
androidTest and update as follows:
@Test
actual fun testOperatingSystemName() {
assertEquals(
expected = "Android",
actual = platform.osName,
message = "The OS name should be Android."
)
}
}
Pretty straightforward, isn’t it? You assert that the operating system name
should be “Android”.
iOS
@Test
actual fun testOperatingSystemName() {
assertTrue(
actual = platform.osName.equals("iOS", ignoreCase = true)
|| platform.osName == "iPadOS",
message = "The OS name should either be iOS or iPadOS."
)
}
}
Desktop
@Test
180
Kotlin Multiplatform by Tutorials Chapter 8: Testing
actual fun testOperatingSystemName() {
assertTrue(
actual = platform.osName.contains("Mac", ignoreCase = true)
|| platform.osName.contains("Windows", ignoreCase = true)
|| platform.osName.contains("Linux", ignoreCase = true)
|| platform.osName == "Desktop",
message = "Non-supported operating system"
)
}
}
This is a bit difficult to test properly. For now, you can check if the reported OS
name contains the app’s supported platforms. If not, let the test fail.
If you run the allTests Gradle task as before, the system will run these tests as
well. Try it to see a new batch of successful tests.
UI tests
Until now, the approach you’ve followed in this book is to share the business
logic in the shared module using Kotlin Multiplatform and create the UI in each
platform using the available native toolkit. Consequently, you’ve been able to
share the tests for the business logic inside the shared module as well.
For testing UI, you can safely assume that there’s no KMP in place. You test
Android and desktop UIs using Jetpack Compose Tests, and iOS UI using
XCUITest.
Android
You created the UI for Organize entirely using Jetpack Compose. Testing
Compose layouts are different from testing a View-based UI. The View-based UI
toolkit defines what properties a View has, such as the rectangle it’s occupying,
its properties and so forth. In Compose, some composables may emit UI into the
hierarchy. Hence, you need a new matching mechanism for UI elements.
Fortunately, the creators of Jetpack Compose had this in mind and provided
necessary tools to test layouts.
androidTestImplementation(
"androidx.compose.ui:ui-test-
junit4:${rootProject.extra["composeVersion"]}"
)
debugImplementation(
"androidx.compose.ui:ui-test-
181
Kotlin Multiplatform by Tutorials Chapter 8: Testing
manifest:${rootProject.extra["composeVersion"]}"
)
androidTestImplementation("androidx.fragment:fragment-
testing:1.4.0")
androidTestImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:runner:1.4.0")
In the defaultConfig section of android block, add this to tell the system
how to run the tests:
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
Go ahead and sync your project now. Next, it’s time to create packages and files.
Inside the src directory, create these nested folders:
androidTest/java/com/raywenderlich/organize/android/
You’re going to replicate the same package structure of the main directory.
Next, create a Kotlin file called AppUITest.kt and define a class in it with the
same name.
class AppUITest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
}
The very first test you’ll write is to check for the existence of the About button.
As you remember, the button is in the top right corner of the app and has an i
icon. The way you can match that element in your tests is through a mechanism
called Semantics.
Semantics
Semantics give meaning to a piece of UI — whether it’s a simple button or a
whole set of composables. The semantics framework is primarily there for
accessibility purposes. However, tests can take advantage of the information
exposed by semantics about the UI hierarchy.
182
Kotlin Multiplatform by Tutorials Chapter 8: Testing
IconButton(
onClick = onAboutButtonClick,
modifier = Modifier.semantics { contentDescription =
"aboutButton" },
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = "About Device Button",
)
}
Go back to AppUITest.kt and complete the test function you were about to
write:
@Test
fun testAboutButtonExistence() {
composeTestRule
.onNodeWithContentDescription("aboutButton")
.assertIsDisplayed()
}
Using the composeTestRule you defined, you query a node with the content
description you set, and assert if it’s displayed.
Run the test using the run button in the code gutter. You’ll see that the emulator
or the connected device runs your app for an instance and then closes it. If
everything goes well, you should see the report for a passed test.
Next up is testing whether the About page opens and closes successfuly. Add the
following function to test that:
183
Kotlin Multiplatform by Tutorials Chapter 8: Testing
@Test
fun testOpeningAndClosingAboutPage() {
//1
composeTestRule
.onNodeWithContentDescription("aboutButton")
.performClick()
//2
composeTestRule
.onNodeWithText("About Device")
.assertIsDisplayed()
//3
composeTestRule
.onNodeWithContentDescription("Up Button")
.performClick()
//4
composeTestRule
.onNodeWithText("Reminders")
.assertIsDisplayed()
}
1. You find the About button using the semantics you defined and simulate
performing a click on it.
2. Check if there’s a text on the screen with About Device content. The About
page has this title, and it’s only there if that page is onscreen. This is not a
good way to do it, though. This test will fail if you localize your app in another
language. Using semantics is always a better choice.
3. If you’d set content description on buttons as you did with the Up Button in
the toolbar, you can use that without setting and querying semantics. You
find the button and perform a click on it.
4. When you close the About page, the app should be in the Reminders page.
Check for the page title if this is the case.
Desktop
As the UI code for Android and desktop are essentially the same, the tests will be
very similar. The setup is a bit different, though. The code is already there for
you in the starter project. These are the differences you should consider:
named("jvmTest") {
184
Kotlin Multiplatform by Tutorials Chapter 8: Testing
dependencies {
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.uiTestJUnit4)
implementation(compose.desktop.currentOs)
}
}
@Before
fun setUp() {
composeTestRule.setContent {
var screenState by remember { mutableStateOf(Screen.Reminders)
}
when (screenState) {
Screen.Reminders ->
RemindersView(
onAboutButtonClick = { screenState = Screen.AboutDevice }
)
Screen.AboutDevice -> AboutView()
}
}
}
Since there are no windows in this test suite, the second test function will be
like this:
@Test
fun testOpeningAboutPage() {
//1
composeTestRule
.onNodeWithText("Reminders")
.assertExists()
//2
composeTestRule
.onNodeWithContentDescription("aboutButton")
.performClick()
//3
composeTestRule.waitForIdle()
185
Kotlin Multiplatform by Tutorials Chapter 8: Testing
//4
composeTestRule
.onNodeWithContentDescription("aboutView")
.assertExists()
}
1. First, you check if you’re in the Reminders page by asserting the existence
of the Reminders title.
2. You simulate a click on the About button.
3. Next, wait for the recomposition to finish. When the Compose test rule was
an Activity, it did this automatically. Now, it’s your job to make your tests
wait.
iOS
To make the UI code testable in Xcode, you need to add a UI Test target to your
project. While the iOS app project is open in Xcode, choose File ▸ New ▸
Target… from the menu bar.
Click Next. The default values for the target name and other options are usually
fine. Click Finish to let Xcode create the UI test target for you.
Have a look at the file navigator. Xcode has created a folder with two test classes
for you.
186
Kotlin Multiplatform by Tutorials Chapter 8: Testing
Second, override the setUp function. The system calls this method before
running each test. It’s similar to when you tagged a test function in Kotlin using
@BeforeTest .
This function will prevent the continuation of tests should any errors occur, and
then launch the app so you can run your tests.
func testAboutButtonExistence() {
XCTAssert(app.buttons["About"].exists)
}
The assert functions in Xcode test frameworks usually start with XCTAssert .
This is the simplest one you could use, and it needs a Boolean parameter. Query
all the buttons of the app and look for one with About title.
Note: Xcode test functions should start with test... , otherwise Xcode won’t
identify them as test functions, and so won’t run them.
187
Kotlin Multiplatform by Tutorials Chapter 8: Testing
As with Android Studio, you can run the tests using the button in the code
gutter. You can also choose the Test button from the Product menu or press
Command-U.
You could improve this code a bit. Imagine you’ve localized your app in French.
When you run the test above in French, the button title wouldn’t be About, so
the test will fail. You can easily resolve this.
.accessibilityIdentifier("aboutButton")
Button {
shouldOpenAbout = true
} label: {
Label("About", systemImage: "info.circle")
.labelStyle(.titleAndIcon)
}
.accessibilityIdentifier("aboutButton")
.padding(8)
.popover(isPresented: $shouldOpenAbout) {
AboutView()
.frame(
idealWidth: 350,
idealHeight: 450
)
}
From now on, you can refer to this specific button using aboutButton
regardless of what its title is.
XCTAssert(app.buttons["aboutButton"].exists)
This is similar to the semantics modifier in Jetpack Compose. Run your test
again to confirm nothing has changed in behavior and result.
Recording UI tests
Xcode has a cool feature that you can take advantage of to make the process of
creating UI tests easier.
188
Kotlin Multiplatform by Tutorials Chapter 8: Testing
Create a new test function and put the cursor in the empty body.
func testOpeningAndClosingAboutPage() {
// Put the cursor here
}
At the bottom of the page, a Record button would appear. Click on it. The app
will run on the simulator, and Xcode will turn whatever action you do in the app
into code.
Take a look at the test function. Xcode has added code for you. It will be
something like this.
func testOpeningAndClosingAboutPage() {
let app = XCUIApplication()
app.toolbars["Toolbar"].buttons["aboutButton"].tap()
app.navigationBars["About Device"].buttons["Done"].tap()
}
If that’s all you had in mind, you’re good to go! Otherwise, this gives you a
starting point for writing your tests. You can also learn from this feature how to
find elements on screen and act on them.
Another thing to take note of is that Xcode automatically goes for the
accessibilityIdentifier if you’d set any. If not, it uses the static title to query
elements. It’s a great practice to always set this modifier on elements.
That said, you can take cues from Xcode’s automatic test recording system and
have this test function:
func testOpeningAndClosingAboutPage() {
//1
app.buttons["aboutButton"].tap()
//2
let aboutPageTitle = app.staticTexts["About Device"]
189
Kotlin Multiplatform by Tutorials Chapter 8: Testing
XCTAssertTrue(aboutPageTitle.exists)
//3
app.navigationBars["About Device"].buttons["Done"].tap()
//4
let remindersPageTitle = app.staticTexts["Reminders"]
XCTAssertTrue(remindersPageTitle.exists)
}
3. Find the Done button in one of the app’s navigation bars with an About
Device title and try tapping on it.
4. When you close the About page, the app should be in the Reminders page.
Check for the page title if this is the case.
Run all the tests in iosAppUITests class by putting your cursor in the middle of
its name and pressing Command-U.
Browse through the results in the Xcode console, or see the green checkmarks
in the code gutter and the Test Navigator and rejoice!
190
Kotlin Multiplatform by Tutorials Chapter 8: Testing
Challenge
Here is a challenge for you to see if you’ve got the idea. The solution is inside the
materials for this chapter.
Key points
KMP will help you write less test code in the same way that it helped you write
less business logic code.
You can write tests for your common code as well as for platform-specific
code — all in Kotlin.
Declaring dependencies to a testing library for each platform is necessary.
KMP will run your tests via the provided environment — such as JUnit on
JVM.
Using expect/actual mechanisms in test codes is possible.
191
Kotlin Multiplatform by Tutorials Chapter 8: Testing
For UI tests, you consult each platform’s provided solution: Jetpack Compose
Tests for UIs created with Jetpack Compose and XCUITest for UIs created
with UIKit or SwiftUI.
If you want to learn more about testing in general, there are great resources out
there, such as screencasts and articles, as well as these two books from
raywenderlich.com:
192
Kotlin Multiplatform by Tutorials
9 Dependency Injection
Written by Saeed Taheri
Imagine an assembly line in a car factory. They don’t create the engines and the
wheels on the assembly line. Car manufacturers outsource many of the parts to
other companies. In the end, they bring them all to the assembly line, inject each
part into the making-in-progress and a shiny new car appears. The car is
dependent on other objects. The same applies to the software world.
If you were to model the Car into a class, one of its dependencies would be the
Engine . The car object shouldn’t be responsible for creating the engine. You
should inject the engine from outside into the assembly line — or in
programming nomenclature, constructor, or initializer.
Reusability: Going back to the car factory example, you’re able to reuse the
same model of wheels for many cars the factory manufactures. Loosely
coupled code will let you reuse many parts of your code in different ways.
Ease of refactoring: There may come a time in the lifetime of your app when
you need to apply a change to your codebase. The less coupled your classes
are, the easier the process will be. Imagine you needed to change the engine
if you wanted to have new headlights!
193
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
Ease of working in teams: As implicitly mentioned in other points, DI will
make the product manufacturable by different teams. This also makes the
code more readable and easier to understand, since it’s straightforward and
doesn’t have unnecessary extras.
Remove the repository definition and pass it in via the constructor like this:
class RemindersViewModel(
private val repository: RemindersRepository
) : BaseViewModel() {// ...
}
Build the project by going to the Build menu and clicking Make Project. You’ll
immediately see there are compile issues in both RemindersView.kt files on
Android and desktop. The same error is also there for RemindersView.swift,
which Android Studio can’t catch.
Note: One implementation which won’t show up in the output above but still
needs to be updated is the viewModel definition in
RemindersViewModelTest.kt. Pass RemindersRepository() into the
RemindersViewModel() class.
194
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
You should go to each of these files and provide an instance of
RemindersRepository . What if the repository has its own dependencies?
And what if those dependencies have their dependencies as well? This is a rabbit
hole you want to avoid getting into!
You can provide all the dependencies yourself and no one can prevent you from
doing so. Off the record, iOS developers usually do all this and write all the
boilerplates by themselves, since there’s not a popular library or methodology
which everyone agrees on.
However, in the Android world, some libraries that solve this problem by
automating the process of creating and providing dependencies. They fall into
two categories:
The most famous library for the first category is Dagger. Recently, Google
introduced Hilt, which they built on top of the foundations of Dagger. Google
recommends Hilt as part of their app architecture suggestions.
The catch is that neither Dagger nor Hilt is available for KMP. Thus, the
approach you can take is to do manual DI or use the most famous library of the
second category: Koin.
Many would call libraries like Koin — which resolve dependencies at runtime —
Service Locators. Those who favor static DI libraries will seriously object if you
call Koin a DI library. However, here you’re free to call it whatever you like.
Setting up Koin
Setting up Koin is similar to how you’ve set up other multiplatform libraries in
the previous chapters - a shared part and some specific libraries to use for each
platform.
Open build.gradle.kts for the project and add a top-level constant for the Koin
version. As of writing, the latest version of Koin is 3.1.5.
195
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
implementation("io.insert-koin:koin-
core:${rootProject.extra["koinVersion"]}")
While you’re here, add a test dependency to commonTest as well. You’re going
to need it later in the chapter.
implementation("io.insert-koin:koin-
test:${rootProject.ext["koinVersion"]}")
Next, open build.gradle.kts for the androidApp and add these two
dependencies. The second one is necessary because the app is using Jetpack
Compose.
implementation("io.insert-koin:koin-
android:${rootProject.ext["koinVersion"]}")
implementation("io.insert-koin:koin-androidx-
compose:${rootProject.ext["koinVersion"]}")
Last but not least, open build.gradle.kts for the desktopApp and add this
dependency to jvmMain :
implementation("io.insert-koin:koin-
core:${rootProject.ext["koinVersion"]}")
1. Declare your modules: Modules are entities that Koin later injects into
different parts of your app as needed. You can have as many modules as you
want.
3. Perform the injection: Using some special keywords provided by Koin lets
you inject object instances at will.
Inside the shared module, in the commonMain directory, create a sibling file to
Platform.kt named KoinCommon.kt. You’re going to write Koin setup codes
there.
196
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
First, create an object in which you can hold a reference to the modules.
object Modules {
val repositories = module {
factory { RemindersRepository() }
}
}
Define a module using the module block. A factory is a definition that will
give you a new instance each time you ask for this object type. If you want to
have a single instance or a singleton across the lifetime of your app, use the
single keyword. It’s most suited for things like databases and network
managers.
Second, add a constant for the ViewModel’s module inside the Modules object.
The new kid in town is the get() function. It’s a generic function that will
resolve a component dependency. When you use this function, Koin looks up in
the declaration you provided and finds a matching call. As you remember,
RemindersViewModel needs an instance of a RemindersRepository in its
constructor, and you just defined it as a module.
So, Koin is good to go! Bear in mind that Koin resolves this dependency at
runtime. Hence, if you use get() without a matching declaration, your app
will most likely crash.
Finally, create a global function below Modules , which you’ll call from each
platform.
fun initKoin(
appModule: Module = module { },
repositoriesModule: Module = Modules.repositories,
viewModelsModule: Module = Modules.viewModels,
): KoinApplication = startKoin {
modules(
appModule,
repositoriesModule,
viewModelsModule,
)
}
197
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
The first one is the appModule . You can use this in later chapters for injecting
app-level dependencies. Since those dependencies come from each platform,
you’re making the ability to pass them from outside.
The second and third parameters are for repositories and viewModels with
default values. You’ll see later on that you need to pass those in certain
scenarios.
Android
Open OrganizeApp.kt in the androidApp module. Add the onCreate function
below inside the class and start Koin there.
initKoin(
viewModelsModule = module {
viewModel {
RemindersViewModel(get())
}
}
)
}
Call the initKoin method you defined earlier. You’re using the viewModel
block, which comes from org.koin.androidx.viewmodel.dsl.viewModel
package, to declare an Android viewModel. The difference between Android
viewModels and others is that they will live through the Android configuration
changes, such as device rotation. This needs a special kind of initialization,
which Koin for Android does for you.
@Composable
fun RemindersView(
viewModel: RemindersViewModel = getViewModel(),
198
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
onAboutButtonClick: () -> Unit,
) {
// ...
}
Calling the getViewModel() extension function, which Koin provides, does all
the creation and injection process.
Build and run the Android app. The app should behave as you’re familiar with —
this time with DI, though.
iOS
Koin is a Kotlin library. Lots of bridging occurs, should you want to use it with
Swift and Objective-C files and classes. To make things easier, you’d better
create some helper classes and functions.
Create a function inside an object for initializing Koin on iOS. Swift doesn’t
bridge Kotlin functions with default parameters. This function is to compensate
for that limitation.
199
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
object KoinIOS {
fun initialize(): KoinApplication = initKoin()
}
Here, you’re passing null for qualifier and parameter . If you find yourself
in need of passing parameters when asking for a dependency, you could add this
extension function as well:
Note: On Mac devices with Apple silicon, you may find that Android Studio
fails to import the needed packages for Objective-C interoperability. Make
sure to add the import directives like this:
import kotlinx.cinterop.ObjCClass
import kotlinx.cinterop.getOriginalKotlinClass
Next, open Xcode and go to Koin.swift and write the class as follows:
import shared
//2
static let instance = Koin()
//3
static func start() {
if instance.core == nil {
let app = KoinIOS.shared.initialize()
instance.core = app.koin
}
if instance.core == nil {
200
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
fatalError("Can't initialize Koin.")
}
}
//4
private init() {
}
//5
func get<T: AnyObject>() -> T {
guard let core = core else {
fatalError("You should call `start()` before using \
(#function)")
}
return result
}
}
1. Store a reference to the Koin core type. This will make it possible to ask for
objects.
2. Create a static property for the newly created class to use it as a singleton.
3. Call this function when the app starts. Here, you’re calling into Kotlin to
initialize Koin. KoinIOS.shared is the way Kotlin exposes the object you
created earlier. If for any reason this procedure fails, you’ll make the app
crash.
4. Mark the initializer for this class as private. This will prevent people from
accidentally initializing the Swift Koin class apart from the way you
intended.
5. This method uses the get extension methods you wrote on Koin in Kotlin.
It first checks if core isn’t nil . Then it tries casting from Any to the
generic type T . This will make this function type-safe at the call site.
@main
struct iOSApp: App {
init() {
Koin.start()
}
// ...
}
201
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
Finally, open RemindersViewModelWrapper.swift and initialize viewModel
as follows:
Desktop
This is the easiest of all platforms. First, open Main.kt and add a reference to
the Koin object. Initialize it in the main function.
fun main() {
koin = initKoin().koin
202
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
@Composable
fun RemindersView(
viewModel: RemindersViewModel = koin.get(),
onAboutButtonClick: () -> Unit,
) {
// ...
}
Use the koin instance you created and take advantage of the get() function.
Updating AboutViewModel
You’re now familiar with the process, it’s time to update AboutViewModel to use
DI. Put the book down and see if you can do it all by yourself.
class AboutViewModel(
platform: Platform
) : BaseViewModel() {
// ...
}
203
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
object Modules {
val core = module {
factory { Platform() }
}
// ...
}
Last but not least in this file, update the definition of initKoin to accept the
new modules you defined:
fun initKoin(
appModule: Module = module { },
coreModule: Module = Modules.core,
repositoriesModule: Module = Modules.repositories,
viewModelsModule: Module = Modules.viewModels,
): KoinApplication = startKoin {
modules(
appModule,
coreModule,
repositoriesModule,
viewModelsModule,
)
}
To follow the usual approach, you’ll next update the codes for the platforms in
order.
Android
Open AndroidView.kt in the androidApp module and change the AboutView
composable definition to this:
fun AboutView(
viewModel: AboutViewModel = getViewModel(),
onUpButtonClick: () -> Unit
) {
// ...
}
204
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
You’re once again using the getViewModel() method from Koin Android
library.
Finally, open OrganizeApp.kt for the Android app and delare the module for
AboutViewModel as well:
initKoin(
viewModelsModule = module {
viewModel {
RemindersViewModel(get())
}
viewModel {
AboutViewModel(get())
}
}
)
Build and run the app to make sure everything is still working as expected.
iOS
It’s pretty straightforward. Open AboutView.swift and change the definition of
viewModel as follows:
And that’s it! Build and run the iOS app. Open the About page to ensure DI is
working correctly.
Desktop
This is also a piece of cake. Open AboutView.kt in desktopApp module and
change the AboutView composable function definition:
fun AboutView(
viewModel: AboutViewModel = koin.get()
) {
ContentView(items = viewModel.items)
}
Testing
205
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
Because of the changes you made in this chapter, the tests you wrote in the
previous chapter wouldn’t compile anymore. Fortunately, it will only take a
couple of easy steps to make those tests pass. You’ll also learn a few more tricks
for testing your code along the way.
Create a class named DITest , and add this test function inside it:
class DITest {
@Test
fun testAllModules() {
koinApplication {
modules(
Modules.viewModels,
)
}.checkModules()
}
}
The test failed, and the reason is pretty obvious. By only providing the
viewModels module, Koin can’t create an instance of RemindersViewModel or
206
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
AboutViewModel . To fix this, just add Modules.repositories and
Modules.core to the list of modules in the test function above. Therefore, the
modules you’re passing will be these:
modules(
Modules.core,
Modules.repositories,
Modules.viewModels,
)
Note: The test only passes on desktop and iOS. It fails on Android. The reason
for that lies in the creation process of ScreenInfo . If you take a look at the
actual Android implementation of that class, you’ll see that it’s calling
Resources.getSystem() . When running tests, this call would fail since the
Android system isn’t available while unit testing. Resolving this issue needs
mocking the Resources class, which is beyond the scope of this chapter.
A good citizen doesn’t litter, and neither does a good developer when testing
Koin. Whenever you create an instance of KoinApplication or call
startKoin , make sure to stop it after you don’t need it anymore. As you know,
a good place to do so is to create a function with the @AfterTest annotation.
Add this function to DITest class, which uses the Koin provided stopKoin
method to do the cleanup.
@AfterTest
fun tearDown() {
stopKoin()
}
Updating RemindersViewModelTest
Open RemindersViewModelTest.kt. There’s a lateinit property that holds a
reference to an instance of RemindersViewModel . In the setup method, you’re
initializing this property like this:
viewModel = RemindersViewModel(RemindersRepository())
Yuck! No one likes this anymore. It’s better to summon Koin to do the job!
There are a few steps you need to take to integrate Koin into tests.
207
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
First, make RemindersViewModelTest conform to KoinTest . This
conformance will make the test class a KoinComponent . The KoinComponent
interface is here to help you retrieve instances directly from Koin using some
special keywords.
//...
}
The last piece is to initialize Koin before the test, and stop it after the test —
pretty much like when you tested the Koin integrity.
@BeforeTest
fun setup() {
initKoin()
}
@AfterTest
fun tearDown() {
stopKoin()
}
In the setup method, you don’t need to initialize the viewModel property
yourself anymore. You just call the initKoin method, which you called at the
application’s launch. It provides the default values for the modules. The
tearDown function is exactly the same as in the DITest class.
Run the tests for the RemindersViewModelTest class, and they will pass as they
used to do.
Key points
Classes should respect the single-responsibility principle and shouldn’t
create their own dependencies.
208
Kotlin Multiplatform by Tutorials Chapter 9: Dependency Injection
Dependency Injection is a necessary step to take for having a maintainable,
scalable and testable codebase.
You can inject dependencies into classes manually, or use a library to do all
the boilerplate codes for you.
Koin is a popular and declarative library for DI, and it supports Kotlin
Multiplatform.
You declare the dependencies as modules, and Koin resolves them at
runtime when needed.
When testing your code, you can take advantage of Koin to do the injections.
You can test if Koin can resolve the dependencies at runtime using
checkModules method.
To learn more about Koin in particular, the best source may be the official
documentation, which is rather concise and self-explanatory, while covering
numerous scenarios.
If you aren’t targeting Multiplatform, you can consult Hilt and Dagger on
Android. In the iOS world, there isn’t a go-to library. However, Resolver has
recently gained a bit of traction.
For testing in particular, Koin also provides you with a way to mock or stub
different objects.
209
Kotlin Multiplatform by Tutorials
10 Data Persistence
Written by Saeed Taheri
The big elephant in the room of the Organize app is that it doesn’t remember
anything you add into it. As soon as you close the app or stop the debugger,
every TODO item disappears for good.
The reason for this issue is that it’s storing everything in memory and —
surprisingly enough — computer memory, or to be more exact the RAM, may
remind people of Dory the fish!
Apps can persist their data if they store them on non-volatile storage. Examples
of this type of storage are HDD, or Hard Disk Drive, SSD, or Solid-State Storage,
and Flash Storage.
Putting aside the details of how computers work and going more high level, you
can mostly persist data using three different mechanisms:
1. Key-Value Storage
2. Database
3. File system
In this chapter, you’ll learn about the first two options, which are more
structured and more straightforward than working with file systems directly.
Key-Value storage
One of the most common use cases when persisting data is to store bits of
information in a dictionary or map style.
Since the setup process for using each of these classes is platform-specific, you
could use the old and sweet expect/actual mechanism to create a single
interface for accessing key-value storage on each platform. Although you
completely know how to do this manually, it’s a lot of boilerplate code to write.
210
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
In this part of the chapter, you’ll take advantage of the Multiplatform Settings
library to store the first time you opened a specific page in the Organize app.
The other way is using the no-argument module. By using this, you’ll have a
faster and easier setup at the expense of customizability. For educational
purposes, you’ll use the long way here.
Open build.gradle.kts in the shared module and add this dependency for the
commonMain source set:
implementation("com.russhwolf:multiplatform-
settings:${rootProject.extra["settingsVersion"]}")
Next, open build.gradle.kts of the androidApp module and add the same line
to its dependencies as well.
As it’s now second nature, you should provide the actual implementation for
this module on all platforms. However, before doing that, make sure to pass this
module when starting Koin in initKoin function.
startKoin {
modules(
appModule,
coreModule,
211
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
repositoriesModule,
viewModelsModule,
platformModule, // Don't forget to add this module
)
}
Android
Still in the shared module, open KoinAndroid.kt from androidMain and add
this block of code:
module {
//1
single<Context> { this@OrganizeApp }
//2
single<SharedPreferences> {
get<Context>().getSharedPreferences(
"OrganizeApp",
Context.MODE_PRIVATE
)
}
}
212
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
You’re declaring to Koin that it can use the application instance as the
singleton context.
2. You use the get() function to get an instance of Context and create a
private SharedPreferences named OrganizeApp.
iOS
Open KoinIOS.kt from iosMain and add the actual implementation of
platformModule constant:
fun initialize(
userDefaults: NSUserDefaults,
): KoinApplication = initKoin(
appModule = module {
single<Settings> {
AppleSettings(userDefaults)
}
}
)
Note: On Mac devices with Apple silicon, you may find that Android Studio
fails to import the packages for AppleSettings and NSUserDefaults . Make
sure these import directives are there:
import com.russhwolf.settings.AppleSettings
import platform.Foundation.NSUserDefaults
Next, open the starter project in Xcode and go to Koin.swift. Inside the Koin
class, change the line in start where you initialized KoinIOS to account for
the changes you made to initialize :
213
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
Desktop
Open KoinDesktop.kt and add the actual implementation for platformModule .
@ExperimentalSettingsImplementation
actual val platformModule = module {
//1
single {
Preferences.userRoot()
}
//2
single<Settings> {
JvmPreferencesSettings(get())
}
}
1. As you turned over to JVM when constructing the Platform class in earlier
chapters, you need to do the same here as well. JVM has a Preferences
class, and you can take advantage of it for storing key-value pairs. There are
two predefined containers for Preferences : one for user values and one for
system values. You need to use the userRoot .
2. Having an instance of JVM’s Preferences object, you can declare your need
for Settings instance to Koin and instruct it to use
JvmPreferencesSettings to create one.
Build and run all the apps to make sure there aren’t any compile-time or
runtime issues.
214
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
class AboutViewModel(
platform: Platform,
settings: Settings,
) : BaseViewModel() {
// ...
}
Add a property to store the formatted timestamp of the first time this page is
opened:
init {
//1
val timestampKey = "FIRST_OPENING_TIMESTAMP"
//2
val savedValue = settings.getLongOrNull(timestampKey)
//3
firstOpening = if (savedValue == null) {
val time = Clock.System.now().epochSeconds - 1
settings.putLong(timestampKey, time)
DateFormatter.formatEpoch(time)
} else {
DateFormatter.formatEpoch(savedValue)
}
}
1. This is the key with which you’ll store the timestamp in settings .
3. If the fetched value is null , you get current time using the Clock object in
kotlinx-datetime library and store it in settings . If the value isn’t null ,
you use the savedValue . In either case, you format the saved date and store
the user-facing string in the property. The DateFormatter object is already
available for you in this chapter’s materials.
Android
First, open OrganizeApp.kt and update the creation of AboutViewModel in the
215
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
viewModel {
AboutViewModel(get(), get())
}
@Composable
private fun ContentView(
items: List<AboutViewModel.RowItem>,
footer: String?,
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.semantics { contentDescription = "aboutView" },
) {
items(items) { row ->
RowView(title = row.title, subtitle = row.subtitle)
}
footer?.let {
item {
Text(
text = it,
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
)
}
}
}
}
This code is pretty straightforward. It looks rather long, but it’s mostly styling
and what’s already been there.
@Composable
fun AboutView(
viewModel: AboutViewModel = getViewModel(),
onUpButtonClick: () -> Unit
216
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
) {
Column {
Toolbar(onUpButtonClick = onUpButtonClick)
ContentView(
items = viewModel.items,
footer = "This page was first
opened:\n${viewModel.firstOpening}"
)
}
}
Build and run. Open the About Device page by tapping on the i button.
iOS
Open iosApp.xcodeproj and go to AboutListView.swift. First, add a property
for the footer as you did for the Android counterpart:
Next, update the body computed property to show the footer in the
Section .
217
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
// ...
}
}
}
}
AboutListView(
items: viewModel.items,
footer: "This page was first opened on \(viewModel.firstOpening)"
)
Build and run the app. Open the About Device page by tapping on the About
button in the bottom toolbar.
Desktop
218
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
Open AboutView.kt in the desktopApp module. Change the ContentView
composable function to accept a footer, and then show it at the bottom of row
items. It’s the same definition of the ContentView in the androidApp module.
You can look back at the implementation above.
@Composable
fun AboutView(viewModel: AboutViewModel = koin.get()) {
ContentView(
items = viewModel.items,
footer = "This page was first
opened:\n${viewModel.firstOpening}"
)
}
Build and run, then check out the About Device window.
Database
A database is an organized collection of data. Whenever you’re dealing with a
structured set of data that you need to access in a certain way, it’s a good choice
to use a database over directly messing with the file system.
Although databases have many kinds and models, relationally based ones such
as SQLite have been the most popular among mobile developers.
For instance, Core Data, which is a framework for managing an object graph on
iOS, uses SQLite as its persistent store.
219
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
Also in the Android world, Google’s recommendation for a database has been
Room for a while, and it’s a typesafe wrapper over SQLite with numerous extra
features.
Unfortunately, none of those two popular options are available for KMP.
However, there’s a great library named SQLDelight, which generates typesafe
Kotlin APIs from your SQL statements and works with KMP with a fairly easy
setup.
SQL
SQL is a database querying language, and you shouldn’t mistake it for the
database itself. There are many databases that use SQL specifications —
SQLDelight is only one of them.
SQLDelight can work with SQLite, MySQL, or even PostgresSQL. However, the
version which works in KMP uses SQLite under the hood.
You’ve already defined some actions in the Organize app, such as showing all
reminders, creating a new reminder and marking reminders as done. You’re
going to define these actions using SQL so SQLDelight can understand them.
sqldelight/com/raywenderlich/organize/db
Inside the db directory, which is short for database, create a file named
Table.sq.
Note: sq is usually the file extension for SQLDelight. Android Studio may
suggest installing a plugin for this matter. It’ll help you with autocompletion
when writing SQL statements. If it didn’t recommend you install the plugin,
you can manually search for it in the Plugins Marketplace of Android Studio.
Relational databases represent data in Tables. A table will help you structure
your data in the way you want. Start by adding this block to the file you created:
220
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
title
isCompleted
As is clear from the code, you specify TEXT or INTEGER for the types and NOT
NULL to specify non-nullability. The DEFAULT keyword will let you provide a
default value for an entity. The UNIQUE keyword prevents you from adding a
new item with the same title as an existing item.
After you define the table and the specifications of data you store in it, it’s time
to define actions you want to do on the data. Add the following in the same file:
selectAll:
SELECT * FROM ReminderDb;
You’re defining an action named selectAll , which runs the next line when
you call it. It selects all items in the RemindersDb table you defined earlier. The
asterisk means all in SQL.
insertReminder:
INSERT OR IGNORE INTO ReminderDb(id, title)
VALUES (?,?);
This statement will let you insert a new item in ReminderDb table. The IGNORE
keyword will make the database ignore values that cause any potential errors.
For example, you defined the title to be unique, so the system will ignore a
duplicated value should you try to insert one.
Question marks are placeholders, meaning that real values will be available
later.
Last but not least, you need an action for marking a reminder as done.
221
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
updateIsCompleted:
UPDATE ReminderDb SET isCompleted = ? WHERE id = ?;
Using the WHERE keyword, you can find an item in the table with a certain id
and set its isCompleted field to a certain value.
Setting up SQLDelight
You need to apply the SQLDelight Gradle plugin in your project.
First, open build.gradle.kts of the project and add this classpath statement to
the dependecies block of buildscript :
classpath("com.squareup.sqldelight:gradle-plugin:1.5.3")
Next, open build.gradle.kts of the shared module and add this in plugins
block:
id("com.squareup.sqldelight")
To make it possible for the SQLDelight Gradle plugin to read the Table.sq file,
you need to define the database. In the same file, add this block at the bottom:
sqldelight {
database("OrganizeDb") {
packageName = "com.raywenderlich.organize"
schemaOutputDirectory =
file("src/commonMain/sqldelight/com/raywenderlich/organize/db")
}
}
The code above creates a database named OrganizeDb , sets the package name
you use this database in and sets a schema output directory — which is
necessary for database migrations.
222
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
}
}
Database Helper
To make executing database actions easier, it’s a good practice to create a
common interface that abstracts the database you’re using. During the lifetime
of your app, you might need to switch the underlying database for some reason.
class DatabaseHelper(
sqlDriver: SqlDriver,
) {
}
223
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
If Android Studio fails to resolve OrganizeDb , try building the project once so
the code generation happens.
You can also add an extension function that returns the isCompleted status of
each reminder as Boolean . It’ll help you later. Add it outside the class:
//1
class RemindersRepository(
private val databaseHelper: DatabaseHelper
) {
224
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
//2
val reminders: List<Reminder>
get() = databaseHelper.fetchAllItems().map(ReminderDb::map)
//3
fun createReminder(title: String) {
databaseHelper.insertReminder(
id = UUID().toString(),
title = title,
)
}
//4
fun markReminder(id: String, isCompleted: Boolean) {
databaseHelper.updateIsCompleted(id, isCompleted)
}
}
By applying the changes above, you made the database the single source of truth
for reminders. You don’t need to store reminders in properties and sync
properties manually.
At the end of this file outside the class, add the extension function for mapping
from ReminderDb to Reminder .
225
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
}
By adding a simple get() call, you can silence the errors of Android Studio.
However, you shouldn’t forget to provide an instance of DatabaseHelper
through Koin.
Since database is one of the core functionalities of the app, add a module to the
core property inside the Modules object:
Android
Open KoinAndroid.kt and add a singleton definition underneath the Settings
declaration as follows:
single<SqlDriver> {
AndroidSqliteDriver(OrganizeDb.Schema, get(), "OrganizeDb")
}
Build and run the app, add a few reminders and check some of them off the list.
Then kill the app and launch it again. Everything is there because it should have
always been this way.
iOS
Open KoinIOS.kt and set this as the platformModule actual property:
226
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
import com.squareup.sqldelight.drivers.native.NativeSqliteDriver
Build and run the app. Verify that the reminders are persisted across app
sessions.
Desktop
Open KoinDesktop.kt, and add the SqlDriver module definition as follows:
single<SqlDriver> {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
OrganizeDb.Schema.create(driver)
driver
}
This will make the app build and run. However, you’ll see that it still doesn’t
persist data between launches. This is because of the IN_MEMORY flag you’re
passing in. You then create a schema using the driver. When you use the
create function, it assumes there are no data available.
For you to see this in action, you can set up the driver as follows for the first
launch:
single<SqlDriver> {
val driver = JdbcSqliteDriver("jdbc:sqlite:OrganizeDb.db")
OrganizeDb.Schema.create(driver)
driver
}
You’re creating a file named OrganizeDb.db inside the directory where the
app’s codes are. You then create the schema using the driver. After doing this,
try running the app. It’ll most likely crash. Don’t worry and continue.
Next, remove the line where you create the schema and run the app again. This
time, the app uses the database file and persists everything.
227
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
In a production app, you can write custom logic to handle this dance.
Keep this in mind — in-memory databases are a good choice when writing tests.
Migration
Imagine one day you decide to add a new feature to the app: setting due dates on
each reminder. This means you need to update many things throughout your
code. One of the most important parts is the database schema. Although
delicate, it’s pretty straightforward to do.
Second, open Table.sq and update the table-creation statements, as well as any
other actions you desire. This file should always reflect the current state of the
database.
setDueDate:
228
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
UPDATE ReminderDb SET dueDate = ? WHERE id = ?;
1. You add a new column called dueDate to the table. It can be null, so you
don’t add the NOT NULL keyword. Since there’s no Date type in SQLite,
you’ll store the timestamp as INTEGER .
2. Next, you write an update statement that will let you set a due date on a
reminder.
Third, create a file called 1.sqm in the same directory to write the migration
statements. You must always name this file using this pattern: <version to
upgrade from>.sqm .
You’re telling the system to alter the ReminderDb table and add a new column
for dueDate .
This will consider 1.db, 1.sqm and Table.sq to check the validity of the SQL
statements you wrote.
229
Kotlin Multiplatform by Tutorials Chapter 10: Data Persistence
This chapter doesn’t help you with adding the UI for setting due dates on
reminders. Set a due date for yourself to add due date support to Organize! :]
Adding Coroutines
Take a look at how you set up RemindersViewModel , and you’ll remember that
you needed to invoke the onRemindersUpdated lambda to notify users of the
ViewModel of potential changes.
This gets the job done; however, you can achieve the same result as well as
many additional features by using a more robust solution, such as a Kotlin Flow.
Kotlin Flow lets you observe streams of data. They’re sequential and can emit
individual values for an observer to process.
Kotlin Coroutines are the building blocks of Flows. You can’t collect values out
of a Flow without using Coroutines. In other words, you use Flows when you
want to observe multiple asynchronously computed values. The asynchronous
keyword in Kotlin will immediately bring up the suspend functions concept,
for which you need to be acquainted with Coroutines to work on.
SQLDelight will let you consume a database query as a Flow. For this to work,
you need to use some extension methods defined in the Coroutines Extensions
library of SQLDelight. You should set up your app to work with Coroutines in the
first place.
Recently, JetBrains has been touting a new memory model for Kotlin Native,
which promises to simplify working with Coroutines on native platforms as well.
You’re going to get acquainted with that in the coming chapters.
Hence, for brevity, this chapter doesn’t talk about using SQLDelight with Kotlin
Flows.
Challenge
Databases have four basic operations: Create, Read, Update and Delete, a.k.a.
CRUD. In Organize, you used three of those operations. Implementing the only
remaining one — Delete — is a good candidate for a challenge.
Add a feature to Organize that lets the user delete reminders individually. For
the UI part, you may take advantage of swipe gestures on Android and iOS. On
desktop, you can use a context menu that’s displayed when the user right-clicks
on any reminder.
Key points
There are three major ways of persisting data on device: Key-Value storage,
database and working directly with the file system.
You can use databases to store structured data and access them in a certain
way.
SQLDelight is a relationally based database that generates typesafe Kotlin API
based on the SQL statements you write. When used in KMP, it uses SQLite
under the hood.
Migrating databases is a delicate and important step when you want to
change your database schema.
SQLDelight has an extension library that lets you observe database changes
using Kotlin Flows.
Here are a couple of suggestions for you if you want to learn more:
Getting acquainted with SQL will let you write more performant queries.
Consulting the SQLDelight documentation, which is available here, will let
you explore more of its features.
Testing is an essential aspect of development. As mentioned earlier, you can
take advantage of in-memory databases in your tests. Both Multiplatform
Settings and SQLDelight offer testing artifacts which you can exploit.
231
Kotlin Multiplatform by Tutorials
11 Serialization
Written by Carlos Mota
Great job on completing the first two sections of the book! You’re doing great.
Now that you’re familiar with Kotlin Multiplatform, you have everything you
need to tackle the challenges of this last section.
Here, you’ll start making a new app called learn. It’s built on top of the concepts
you learned in the previous chapters, and it introduces a new set of concepts,
too: serialization, networking and how to handle concurrency.
learn uses the raywenderlich.com RSS feeds to show the latest tutorials for
Android, iOS, Unity and Flutter. You can add them to a read-it-later list, share
them with a friend, search for a specific key, or just browse through everything
the team has released. It will have the same look and feel you’re already used to
from the raywenderlich.com website.
There are different types of serialization formats — for instance, JSON or byte
streams. You’ll read more about this in Chapter 12, “Networking,” when
covering network requests.
Android uses this concept to share data across activities, services or receivers —
either in the same application or to third-party apps. The difference is that
instead of relying on Serializable to send data from custom types, the OS
requires you to implement Parcelable to send these objects.
Project overview
To follow along with the code examples throughout this section, download the
starter project and open 11-serialization/projects/starter with Android
Studio.
232
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
Compose 1.1.0 with Kotlin 1.6.10. To build and run the app successfully, use
Android Studio Bumblebee 2021.1.1 Patch 2 or a newer version.
Starter has the skeleton of the app you’ll build, and final gives you something to
compare your code to when you’re done.
After the project synchronizes, you’ll see a set of subfolders and other
important files:
The next section explains the project tree hierarchy in detail. The folder names
are self-explanatory and correspond to either the platform they are used for or
the functionality itself. You’re welcome to skip it and go directly to the
Application features section.
Android app
Located inside androidApp, the Android app contains the Gradle configuration
files, the app source code and its resources. It’s the same structure that you’re
already used to from your Android apps, and you can use any library or
component as you typically do:
233
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
Fig. 11.2 - Android starter project running. Empty screen with no data.
This is your app skeleton. It doesn’t look much, but that’s because there’s no
data to show. You’ll load the app data in the next sections.
iOS app
Your iOS app is inside the iOSApp folder. Navigate to this folder and in the root
directory and enter:
pod install
Once done, it’s time to open the project. Use Xcode or AppCode to open the file
iosApp.xcworkspace located inside the iosApp folder.
You don’t need any additional steps to run the app. Open the iosApp target,
navigate to the Build Phases tab, and then select the Run Script dropdown.
Here you have:
234
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode
This task automatically compiles the SharedKit framework and adds it to your
project when necessary.
After the project is synchronized, compile and run the app. You’ll see a screen
like this:
Fig. 11.3 - iOS starter project running. Empty screen with no data.
Desktop application
The desktop application is similar to the Android app. The code was copied from
one project to the other with just a couple of small changes — namely, on the
libraries used that weren’t available for the JVM target:
Although these libraries are available at the above links, they’re not updated to
the latest versions of Kotlin or Compose. That’s why they’re included in this
project. You can find more information about this in Appendix C.
To run the desktop application, go to the command line and in the project root
folder, enter:
235
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
./gradlew desktopApp:run
After it finishes, a new window will open with the app. You’ll see a screen like
this:
Fig. 11.4 - Desktop starter project running. Empty screen with no data.
Shared module
This contains the entire business logic of learn. It’s the multiplatform code
that’s shared across Android, iOS and desktop.
You’ll find:
236
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
Common code
When you open the shared module, you’ll see two things inside commonMain:
This module follows the clean architecture paradigm, grouping files according
to their responsibility in the business logic. Open the kotlin directory and you’ll
see:
data: Networking layer of the shared module. Fetches the RW feeds and
defines the data model of each RSS entry.
domain: Deserializes the response and creates a list of feeds that can later be
consumed by the UI. Saves data into the database and defines the callbacks
that are going to be used to notify when new data is available.
presentation: This layer makes the bridge between the UI and the app logic.
In the next section, you’ll see what learn will look like after you implement all
the required features.
Application features
Before starting to write code, have a look first at the app concept and its
features:
237
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
Don’t worry about the details on each screen. You’ll have a chance to see them
more closely during the next chapters.
learn has four different screens that you can navigate to from the app’s bottom
bar:
Home
This is the app’s default screen. It shows a horizontal list with all the
raywenderlich.com topics and a list of the latest articles published.
These topics work as a filter. Clicking any item redirects the user to the latest
screen where they can see the most recent articles written, read or shared, then
add them to the bookmarks list or remove them once they’re done.
Bookmarks
238
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
This screen shows all the articles that you’ve saved. Is the list getting big? Pick
one and start reading it. Afterward, you can remove it from this list by clicking
the three dots on the card and selecting Remove from bookmarks.
Latest
This features a more graphical interface with the sections and covers of the
latest articles. You can either scroll horizontally to see its content or vertically to
switch across different topics.
Search
There’s a lot of content that you can browse. Here, you can filter by a specific
keyword and finally find that article you’ve been looking for.
Now that you’re familiar with the app and the project, it’s time to start learning
how to implement these features.
Time to import this library into the app. Open Android Studio and wait for the
project to finish synchronizing. In the project root folder, there’s a
build.gradle.kts file; open it and add the following classpath :
classpath("org.jetbrains.kotlin:kotlin-serialization:1.6.10")
With this declaration, the build system knows where it should search and fetch
239
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
the serialization library.
Now, open the build.gradle.kts file inside shared and load the serialization
plugin by adding the following code inside the plugins block:
kotlin("plugin.serialization")
Synchronize and wait for this process to finish. Once ready, the system will load
the library and add it to the project.
240
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
in configuration files (kotlinx-serialization-hocon).
Note: There are also two community-maintained libraries for YAML and
Apache Avro.
During the scope of this book, you’ll only need to add kotlinx-serialization-
json. Navigate to shared, open the build.gradle.kts file and search for the
commonMain field. There’s already a set of dependencies on the project. At the
end of this list, add:
implementation("org.jetbrains.kotlinx:kotlinx-serialization-
json:1.3.2")
Synchronize the project, and now you’re ready to use serialization for JSON
format.
A good example of this is on the RWContent.kt data class, inside the shared
module. The platform field is of type PLATFORM , an enum created to identify
which section the article belongs to.
In the commonMain package inside the shared module, go over to data and
create a RWSerializer.kt file.
241
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
return PLATFORM.values().find { it.value == key } ?: default
}
This will receive a key and return the corresponding value in the enum
PLATFORM .
Now that you’ve added the mapping function, create the RWSerializer object.
In the same file, add:
//1
@OptIn(ExperimentalSerializationApi::class)
//2
@Serializer(forClass = PLATFORM::class)
//3
object RWSerializer : KSerializer<PLATFORM> {
//4
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("PLATFORM", PrimitiveKind.STRING)
//5
override fun serialize(encoder: Encoder, value: PLATFORM) {
encoder.encodeString(value.value)
}
//6
override fun deserialize(decoder: Decoder): PLATFORM {
return try {
val key = decoder.decodeString()
findByKey(key)
} catch (e: IllegalArgumentException) {
PLATFORM.ALL
}
}
}
1. The serialization API is still experimental. Every time you use it, Android
Studio automatically underlines its call, asking to either use OptIn or
Require annotations. This error is shown to notify the developer that this
API may change in the future. In this project, you’ll to use OptIn to avoid
adding a new annotation on every call to this class.
3. You’ll need to extend the KSerializer class and define the type of object
that is going to be serialized/deserialized.
4. It’s necessary to define the PrimitiveSerialDescriptior that contains the
class name — PLATFORM — and how the parameter should be read. In this
case, it’s going to be from a String .
242
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
5. Now that everything is ready, it’s time to define the serialize method.
Since, the content type of PLATFORM is String , you just need to call
encodeString and send the value of the received object.
That’s it! RWSerializer is ready. You just need to add it to the class. Open the
RWContent.kt file again, and above the declaration of PLATFORM add:
@Serializable(with = RWSerializer::class)
This associates the new device serializer to the PLATFORM class. Remember to
import from kotlinx.serialization.Serializable .
image: A cover image that corresponds to the platform that was selected.
These three attributes are already mapped into the data class RWContent.kt,
which is inside data/model. Open it and add to the top of its declaration:
@Serializable
243
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
This will create the Json object that’s going to be used to decode the file
content. It’s important to set ignoreUnknownKeys to true to avoid any
exceptions that might be thrown in case one of the fields inside RWContent.kt
doesn’t have a direct attribute in the JSON file. Remember to import
kotlinx.serialization.json.Json .
content is lazily initialized. In other words, it will open the file and read its
content only when accessed. When done, it calls decodeFromString to generate
a list of RWContent objects.
import kotlinx.serialization.decodeFromString
It’s time to build and run the project and see what’s new in learn. You’ll see
screens similar to the following ones on different platforms:
244
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
245
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
One might argue that Parcelable is more complex to implement. This was
true some years ago, since it was necessary to override a couple of methods and
create the read/write methods according to the object fields. But, you don’t have
to do all that now, as the kotlin-parcelize plugin generates this code
automatically. So the only effort here is to add an annotation to the top of the
class — @Parcelize — and extend Parcelable .
id("kotlin-parcelize")
To set a data class as Parcelable, one would add the annotation @Parcelize to
the top of the class declaration and then extend the Parcelable generator. It
should be something similar to:
import kotlinx.parcelize.Parcelize
@Parcelize
data class RWEntry(val id: String, val entry: String): Parcelable
246
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
package com.raywenderlich.learn.platform
//1
expect interface Parcelable
//2
@OptIn(ExperimentalMultiplatform::class)
@OptionalExpectation
//3
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
//4
expect annotation class Parcelize()
2. Declaring an annotation it’s still experimental, meaning that the API might
change in the future. These two annotations notify the user about this
behavior and prevent Android Studio from warning the developer every time
there’s a call to this Parcelize class.
3. Target and Retention annotations are part of Kotlin’s Parcelize
annotation. To keep the same behavior as the native one, they’re also added
here.
4. The Parcelize annotation that will be defined.
Note: When using the OptIn annotation, you might see warning messages
on your build log similar to this one:
This class can only be used with the compiler argument ‘-opt-in=kotlin.RequiresOptIn’
They are printed to warn the developer that there are features using OptIn
that might change or be incompatible in the future. This is something that
you’ll need to be careful with, since it means that you may end up refactoring
your code if it changes. In any case, after you acknowledge it, you can always
247
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
Open the shared build.gradle.kts file and add the following code at the end of
this file:
kotlin.sourceSets.all {
languageSettings.optIn("kotlin.RequiresOptIn")
}
Depending on the data classes that you’re using, you might need to create an
expect/ actual class for other annotations. One of these examples is
@RawValue , which is used along default serializers for custom types. You can
follow the same approach used on Parcelize to achieve the same goal:
@OptIn(ExperimentalMultiplatform::class)
@OptionalExpectation
@Target(AnnotationTarget.TYPE)
@Retention(AnnotationRetention.BINARY)
expect annotation class RawValue()
With the expected declarations defined, go over to androidMain and create the
corresponding actual implementation. It should be located in the same
directory as the file you’ve just added on commonMain.
package com.raywenderlich.learn.platform
Note: Type aliases don’t create a new type of data, but instead create a link to
an existing one. Get more information about Kotlin’s typealias directly from
the official documentation.
248
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
Go to the iosMain folder and inside the platform directory, create the
corresponding PlatformParcelable.kt file.
Note: Press Alt + Enter, and Android Studio automatically suggests creating
the files for the remaining platforms. Generate these files by pressing OK.
package com.raywenderlich.learn.platform
In each of these files, add the @Parcelize annotation above the class
declaration and extend Parcelable :
@Parcelize
@Serializable
data class RWContent(
val platform: PLATFORM,
val url: String,
val image: String
) : Parcelable
249
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
@Parcelize
data class RWEntry(
val id: String = "",
val link: String = "",
val title: String = "",
val summary: String = "",
val updated: String = "",
val imageUrl: String = "",
val platform: PLATFORM = PLATFORM.ALL,
val bookmarked: Boolean = false
) : Parcelable
Now, you can start sending this object across different activities without any
problems.
Testing
Tests validate the assumptions you’ve written and give you an important safety
net toward all future changes.
Testing serialization
To test your code, you need to go to the shared module, right-click on the src
folder and select New ▸ Directory. In the drop-down, select
commonTest/kotlin. Here, create a SerializationTests.kt class:
class SerializationTests { }
You’ll need to create encode and decode tests to validate that everything is
working as expected. Start by writing the encoder. Add the following method to
the class:
@Test
fun testEncodePlatformAll() {
val data = RWContent(
platform = PLATFORM.ALL,
url = "https://www.raywenderlich.com/feed.xml",
image =
"https://assets.carolus.raywenderlich.com/assets/razeware_460-
308933a0bda63e3e327123cab8002c0383a714cd35a10ade9bae9ca20b1f438b.pn
g"
)
250
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
xml\",\"image\":\"https://assets.carolus.raywenderlich.com/assets/r
azeware_460-
308933a0bda63e3e327123cab8002c0383a714cd35a10ade9bae9ca20b1f438b.pn
g\"}"
assertEquals(content, decoded)
}
@Test
fun testDecodePlatformAll() {
val data = "
{\"platform\":\"all\",\"url\":\"https://www.raywenderlich.com/feed.
xml\",\"image\":\"https://assets.carolus.raywenderlich.com/assets/r
azeware_460-
308933a0bda63e3e327123cab8002c0383a714cd35a10ade9bae9ca20b1f438b.pn
g\"}"
assertEquals(content, decoded)
}
Essentially, you do the opposite. Starting with the JSON response, you’ll need to
call decodeFromString so kotlinx.serialization builds your RWContent object,
decoded , and then you’ll compare it with the one that you’re expecting -
content . If the content is the same, the test successfully passes. Run the test
and see that it passes.
251
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
This serializers property contains the serializer you’ll need to encode and
decode your data.
@Test
fun testEncodeCustomPlatformAll() {
val data = PLATFORM.ALL
When you receive a response, the body is a string response in a JSON format:
{
"platform":"all",
"url":"https://www.raywenderlich.com/feed.xml",
"image":"https://assets.carolus.raywenderlich.com/assets/razeware_4
60-
308933a0bda63e3e327123cab8002c0383a714cd35a10ade9bae9ca20b1f438b.pn
g"
}
To test if RWSerializer is working correctly on the test above, check if the result
corresponds to the JSON response "all" after encoding the PLATFORM.ALL
property into a string.
If it does, the assertEquals function will return true, otherwise the test will
fail.
@Test
fun testDecodeCustomPlatformAll() {
val data = PLATFORM.ALL
Here, you’re doing the opposite. From the string “all”, returned from
data.value , you want the corresponding PLATFORM enum value. For that, you
call decodeFromString and confirm if the returned object is the one you’re
expecting.
252
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
Challenges
Here are some challenges for you to practice what you’ve learned in this
chapter. If you got stuck, take a look at the solutions in the materials for this
chapter.
Create an RW_ALL_FEED property that contains the RSS feed content of one
of the feed URLs from RW_CONTENT.
Read this property and parse its content in the shared module so it can be
available for all apps to use.
In androidApp and desktopApp, open the FeedViewModel.kt file inside
ui/home, and populate items with this new data.
Note: You should be able to read a JSON string containing the content to be
serialized and access to the object afterward.
Key points
Exchanging data between local and remote applications requires the content
that’s transferred be serialized and deserialized, depending on if it’s being
sent or received, respectively.
kotlinx.serialization is a multiplatform library that supports serialization. It
allows serializing/ deserializing JSON, Protocol buffers, CBOR, Properties,
HOCON, YAML and Apache Avro.
You can create custom serializers by implementing the serialize/ deserialize
for a custom type and then associating it with that class.
253
Kotlin Multiplatform by Tutorials Chapter 11: Serialization
You can use typealias with actual to automatically link a class declaration to
an existing one at the platform level.
The next chapter starts with the features that you’ve implemented in this one,
but instead of only loading the data locally, you’ll also fetch it from the network.
254
Kotlin Multiplatform by Tutorials
12 Networking
Written by Carlos Mota
Fetching data from the internet is one of the core features of most mobile apps.
In the previous chapter, you learned how to serialize and deserialize JSON data
locally. Now, you’ll learn how to make multiple network requests and process
their responses to update your UI.
Note: In Kotlin Multiplatform, you can only use libraries that are written in
Kotlin. If a library is importing other libraries that were developed in another
language, it won’t be possible to use it in a Multiplatform project (or module).
Developers needed a new library — a library that could provide the same
functionalities as the ones mentioned above, but was built for Multiplatform
applications. With that in mind, Ktor was created.
Using Ktor
Ktor is an open-source library created and maintained by JetBrains (and the
community). It’s available for both client and server applications.
It’s fully written in Kotlin and uses coroutines for asynchronous calls. In the
upcoming sections, you’ll see how easy it is to use it in your applications.
255
Kotlin Multiplatform by Tutorials Chapter 12: Networking
Adding Ktor
Open build.gradle.kts from shared. Inside the commonMain dependencies
section, add the following dependencies at the end:
implementation("io.ktor:ktor-client-core:2.0.0-beta-1")
implementation("io.ktor:ktor-client-serialization:2.0.0-beta-1")
Here, you’re adding the Ktor core library along with the serialization library that
it will use to parse the responses and transform the data into objects the app can
process.
Ktor has different HTTP client engines depending on the platform to which
you’re compiling the project. Although desktop doesn’t require a specific
library, since you’re also targeting Android and iOS, you’ll need to add the
following in androidMain and iosMain respectively:
implementation("io.ktor:ktor-client-android:2.0.0-beta-1")
implementation("io.ktor:ktor-client-ios:2.0.0-beta-1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-
core:1.6.0-native-mt") {
version {
strictly("1.6.0-native-mt")
}
}
Looking at the iOS implementation, you’ll see that you’ve also set a specific
version of the kotlinx-coroutines . This is required because the coroutines
version that’s bundled with Ktor only supports single-thread usage. You can
read more about this in Chapter 13, “Concurrency”.
Note: You need to add this version to the iOSMain dependencies section and
not on commonMain because the constraint is with iOS.
Click Sync Now to synchronize and wait for Android Studio to fetch and import
these new libraries.
An article webpage.
Your Gravatar account.
The data for the first one is in the RW_CONTENT property inside the
FeedPresenter.kt file located in the shared module. It can be one of the
following:
All
Android
iOS
Gametech
Flutter
Each of these requests loads the latest 20 articles published for its category.
The second request corresponds to the link field of the RWEntry . Since an
RSS entry doesn’t contain a URL for the article image, you’ll need to fetch it
manually from raywenderlich.com.
Finally, make the last request to Gravatar, a service that allows you to define an
online profile that can be used across external sites. Your picture from
raywenderlich.com, for example, is retrieved from this service.
//1
public const val GRAVATAR_URL = "https://en.gravatar.com/"
public const val GRAVATAR_RESPONSE_FORMAT = ".json"
//2
@ThreadLocal
public object FeedAPI {
//3
private val client: HttpClient = HttpClient()
//4
public suspend fun fetchRWEntry(feedUrl: String): HttpResponse =
client.get(feedUrl)
//5
public suspend fun fetchMyGravatar(hash: String): HttpResponse =
257
Kotlin Multiplatform by Tutorials Chapter 12: Networking
client.get("$GRAVATAR_URL$hash$GRAVATAR_RESPONSE_FORMAT")
}
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlin.native.concurrent.ThreadLocal
1. The constants fetchMyGravatar will use to make its request: the URL and
the response format.
2. This annotation is only valid for iOS (Kotlin/Native). It’s ignored in both
Android and desktop. Using @ThreadLocal , the FeedAPI won’t be shared
across other threads that try to access it. Instead, a new copy will be made.
This guarantees the object won’t freeze. Read more about this in Chapter 13,
“Concurrency”.
You’re making a GET request in learn. Other HTTP methods are also available
with Ktor: POST, PUT, DELETE, HEAD, OPTION and PATCH.
Note: If you look closely at these functions, you’ll see they’re declared using
the keyword suspend . It’s used so the current thread won’t get blocked while
waiting for a response. You’ll learn more about it and coroutines in Chapter
13, “Concurrency”.
You’ve made the requests, and now it’s time to process the responses.
Plugins
Ktor has a set of plugins already built in that are disabled by default. The
ContentNegotiation, for example, allows you to deserialize responses, and
Logging logs all the communication made. You’ll see an example of both later in
this chapter.
258
Kotlin Multiplatform by Tutorials Chapter 12: Networking
These plugins intercept all the requests and responses made, then process them
according to their purpose.
implementation("io.ktor:ktor-client-content-negotiation:2.0.0-beta-
1")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.0.0-beta-
1")
You’ll start with fetchMyGravatar . Since it’s JSON, you can install
ContentNegotiation for json so the response from this function will be the
deserialized object. To achieve this, update the client initialization with:
install(ContentNegotiation) {
json(nonStrictJson)
}
}
Ktor will now use json to deserialize the response body. Additionally, you also
need to define the nonStrictJson property. Declare it before the HttpClient :
import io.ktor.client.plugins.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
259
Kotlin Multiplatform by Tutorials Chapter 12: Networking
To keep your app stable on any future server update, it’s always a good approach
to define isLenient and ignoreUnknownKeys as true. Otherwise, the
deserialization might throw an exception if there’s malformed input or there
are properties in the JSON that don’t exist in the serializable object.
Since fetchMyGravatar is the only request that receives a JSON response and
you’ve already enabled the plugin, update the existing fetchMyGravatar return
type to:
Now, when it calls the function, instead of receiving a HttpResponse that you
would need to process, you’ll receive the deserialized object that you can use.
Ktor has native support for logging. Before writing the logger, you need to open
the build.gradle.kts file, and in the commonMain dependencies, add:
implementation("io.ktor:ktor-client-logging:2.0.0-beta-1")
Do a Gradle sync.
When ready, return to the FeedAPI.kt file and add the following code inside
HttpClient initialization lambda:
//1
install(Logging) {
//2
logger = Logger.DEFAULT
//3
level = LogLevel.ALL
}
1. You install the Logging feature in the app. When installed, it will intercept
260
Kotlin Multiplatform by Tutorials Chapter 12: Networking
all the network requests and responses.
2. This is the logger class that you’ll use to log all the network
communication. Using DEFAULT falls back to calling the println function.
3. This specified the data that needs to be logged.
LogLevel.INFO : Logs the URL and the method that’s going to be used for
requests. For responses, this means its status, method and the “from” field.
Additionally, you can define a custom logger class. To accomplish this, go to the
data folder inside the shared module and create a HttpClientLogger.kt file with
the following code:
import com.raywenderlich.learn.platform.Logger
Here, you’re extending Ktor Logger and changing the one that should be used
to log the requests and responses. You do this by overriding the log function.
Instead of using the default one, you’re going to use the app Logger defined on
shared.
Now, return to FeedAPI.kt and update the previously added install call to
instead use:
logger = HttpClientLogger
261
Kotlin Multiplatform by Tutorials Chapter 12: Networking
Build and run the apps to confirm everything is correct. For now, since there
are no requests made, you won’t find any log message when filtering for
HttpClientLogger both in Android Studio and Xcode. After the next section,
you’ll try this again.
You can use the filter fields both in Android Studio and Xcode to display only
messages that match a specific tag.
Note: Since the logger you created receives a TAG parameter that
corresponds to the HttpClientLogger class, you can use that to filter on
Logcat for all the network requests and responses made.
Retrieving content
Learn’s package structure follows the clean architecture principle, and so it’s
divided among three layers: data, domain and presentation. In the data layer,
there’s the FeedAPI.kt that contains the functions responsible for making the
requests. Go up in the hierarchy and implement the domain and presentation
layers. The UI will interact with the presentation layer.
262
Kotlin Multiplatform by Tutorials Chapter 12: Networking
//1
public suspend fun invokeGetMyGravatar(
hash: String,
onSuccess: (GravatarEntry) -> Unit,
onFailure: (Exception) -> Unit
) {
try {
//2
val result = FeedAPI.fetchMyGravatar(hash)
Logger.d(TAG, "invokeGetMyGravatar | result=$result")
//3
if (result.entry.isEmpty()) {
coroutineScope {
onFailure(Exception("No profile found for hash=$hash"))
}
//4
} else {
coroutineScope {
onSuccess(result.entry[0])
}
}
//5
} catch (e: Exception) {
Logger.e(TAG, "Unable to fetch my gravatar. Error: $e")
coroutineScope {
onFailure(e)
}
}
}
When importing the Logger library, don’t forget that you’re using the one from
the shared module:
import com.raywenderlich.learn.platform.Logger
1. This function receives a hash property that’s going to be used to build the
request to Gravatar and two lambda functions that will be called depending
on if the operation succeeded or not. onSuccess is triggered for the first
case and onFailure for the second.
263
Kotlin Multiplatform by Tutorials Chapter 12: Networking
3. A response is valid if there’s at least one element in result . If this list is
empty, it means the response is empty, and therefore onFailure is
triggered.
5. Finally, if anything fails during this process, onFailure is called with the
exception that caused the problem.
Now that the domain logic is ready, move to the presentation layer. Open
FeedPresenter.kt. Before the class declaration, add and define your
GRAVATAR_EMAIL :
You’ll use this later to build the request. Create an account if you don’t already
have one, and replace YOUR_GRAVATAR_EMAIL with your Gravatar email. Once
done, add the functions the UI is going to call inside the existing class:
//1
public fun fetchMyGravatar(cb: FeedData) {
Logger.d(TAG, "fetchMyGravatar")
//2
MainScope().launch {
//3
feed.invokeGetMyGravatar(
//4
hash = md5(GRAVATAR_EMAIL),
//5
onSuccess = { cb.onMyGravatarData(it) },
onFailure = { cb.onMyGravatarData(GravatarEntry()) }
)
}
}
1. This is a function that allows you to set a listener for the UI to receive updates
for the call to fetchMyGravatar . The FeedData argument is an interface
used to notify the UI when new data is available. This scenario triggers
onMyGravatarData .
264
Kotlin Multiplatform by Tutorials Chapter 12: Networking
registered. It’s easier to call this method from Utils.kt directly.
5. If the request succeeds, it calls the onSuccess expression with the received
data. Otherwise, onFailure is triggered and an empty GravatarEntry is
sent.
fun fetchMyGravatar() {
Logger.d(TAG, "fetchMyGravatar")
presenter.fetchMyGravatar(this)
}
Now that you’ve got everything ready, build and run the Android app.
265
Kotlin Multiplatform by Tutorials Chapter 12: Networking
fun fetchMyGravatar() {
Logger.d(TAG, "fetchMyGravatar")
presenter.fetchMyGravatar(this)
}
Finally, compile and run your app using the following command:
./gradlew desktopApp:run
Switch to Xcode, and navigate to the extensions’ folder. Here, open the
FeedClient.swift class and find the fetchProfile function. Before assigning
the completion to the handlerProfile , add this code to fetch the Gravatar
profile:
266
Kotlin Multiplatform by Tutorials Chapter 12: Networking
feedPresenter.fetchMyGravatar(cb: self)
//1
public suspend fun invokeFetchRWEntry(
platform: PLATFORM,
feedUrl: String,
onSuccess: (List<RWEntry>) -> Unit,
onFailure: (Exception) -> Unit
) {
try {
//2
val result = FeedAPI.fetchRWEntry(feedUrl)
267
Kotlin Multiplatform by Tutorials Chapter 12: Networking
if (parsed != null) {
feed += parsed
}
}
//4
coroutineScope {
onSuccess(feed)
}
} catch (e: Exception) {
Logger.e(TAG, "Unable to fetch feed:$feedUrl. Error: $e")
//5
coroutineScope {
onFailure(e)
}
}
}
3. Since there’s no direct support for XML serialization in Ktor, you need to use
a third-party library. In this case, due to its popularity, you’re going to use
KorIO. It will parse through all the nodes of the XML and return a list of
RWEntry.
4. If everything worked until this next code block, this function ends by
sending the feed to the onSuccess expression.
It’s now time to move in the hierarchy and open the FeedPresenter.kt file on
the presentation layer inside shared. With the request implemented, you need
to add an entry point the UI can call.
//1
public fun fetchAllFeeds(cb: FeedData) {
Logger.d(TAG, "fetchAllFeeds")
//2
268
Kotlin Multiplatform by Tutorials Chapter 12: Networking
for (feed in content) {
fetchFeed(feed.platform, feed.url, cb)
}
}
With this, you’ve finished the business (shared) logic for the network requests.
It’s now time to connect it to the Android, desktop and iOS apps. Starting with
Android, open the FeedViewModel.kt file. Look for the fetchAllFeeds
function and add the following code inside the function:
presenter.fetchAllFeeds(this)
This will trigger the network request that you defined before. Scrolling down
this file, you’ll see the onNewDataAvailable implementation. Update it with the
following code block so the items property can be updated:
269
Kotlin Multiplatform by Tutorials Chapter 12: Networking
}
}
Build and run the Android application. You’ll see a screen similar to this one:
presenter.fetchAllFeeds(this)
Main.kt calls this function to fetch all the available feeds. When they’re ready,
onNewDataAvailable is called with all the items. Update this function to:
Now that the desktop app is ready, enter the compilation and run command at
270
Kotlin Multiplatform by Tutorials Chapter 12: Networking
the Android Studio terminal:
./gradlew desktopApp:run
Finally, update the iOS app. Open the FeedClient file inside the extensions
folder, and search for fetchFeeds . Here, before assigning the completion to
the handler , add:
feedPresenter.fetchAllFeeds(cb: self)
That’s it! Build and run the app, then see which articles the team recently
published.
271
Kotlin Multiplatform by Tutorials Chapter 12: Networking
Imagine that you want to add a custom header to identify your app name.
Then, add a constant that’s going to be used to identify the parameter that you
want to add as a header:
Now, define its value by adding another property — this time it should
272
Kotlin Multiplatform by Tutorials Chapter 12: Networking
correspond to the app name:
Since this value should be the same for both platforms, you’re going to use it as
the value for the header request.
Now, if you want to add this header to all requests done through Ktor, you need
to locate client in the FeedAPI.kt file. When you’re overriding the client ,
before the call to install add:
defaultRequest {
header(X_APP_NAME, APP_NAME)
}
install(DefaultRequest)
In other words, similar to what you did for logging, you’re setting the default
configuration for every request. In this case, you’re adding an X_APP_NAME
header.
Now, compile the app on all three applications. By opening Logcat (Android),
terminal (desktop) and Xcode console (iOS), confirm in the log messages that
you’re sending this new header.
Fig. 12.9 - Android Studio Logcat showing all requests with a specific header
273
Kotlin Multiplatform by Tutorials Chapter 12: Networking
Hint: Don’t forget that you can filter your logs using the tag
HttpClientLogger .
On the contrary, if you want to add this header for a specific request, you just
need to override the HttpRequestBuilder to set it. Here’s a real example:
imagine that you want to add it only when you’re fetching your Gravatar profile.
Remove the previously added header, and in the fetchMyGravatar declaration,
update it to:
To validate your implementation, compile the project again, and with the
HttpClientLogger filter, search for this particular request.
274
Kotlin Multiplatform by Tutorials Chapter 12: Networking
Fig. 12.12 - Android Studio Logcat showing a request with a specific header
Uploading les
With Multiplatform in mind, uploading a file can be quite challenging because
each platform deals with them differently. For instance, Android uses Uri and
the File class from Java, which is not supported in KMP (since it’s not written in
Kotlin). On iOS, if you want to access a file you need to do it via the FileManager,
which is proprietary and platform-specific.
The solution is to find a common ground — in this case at a lower level. Their
implementations generate a ByteArray that can be accessed and processed at
275
Kotlin Multiplatform by Tutorials Chapter 12: Networking
the shared module.
Here, you’re defining the class and function that’s you’ll use to represent a file.
At the platform level, the MediaFile class and the corresponding
toByteArray function will be defined.
Once done, it’s now time to define the iOS implementation. Create the
PlatformMediaFile.kt in the platform package inside the iosMain folder and
add:
276
Kotlin Multiplatform by Tutorials Chapter 12: Networking
}
With these implementations, you can now access the file’s content and upload it.
Although it’s beyond the scope of this chapter, it’s worth showing you an
example of how it can be made at Ktor level.
Imagine that you selected an image to upload. Assuming your server supports
multipart requests, you could write a similar function:
//1
public suspend fun uploadAvatar(data: MediaFile): HttpResponse {
//2
return client.post(UPLOAD_AVATAR_URL) {
//3
body = MultiPartFormDataContent(
formData {
appendInput("filedata", Headers.build {
//4
append(HttpHeaders.ContentType, "application/octet-
stream")
}) {
//5
buildPacket { writeFully(data.toByteArray()) }
}
})
}
}
1. You need to receive the MediaFile that contains a reference to your image.
The important part of this object is the toByteArray function that’s used on
5.
2. The client in this example is the same that you’ve been using until now.
There’s no need to install additional plugins or set any configuration.
3. In this case, the file will be sent through a multipart request, so the body of
the request needs to contain this information.
4. Most servers require that the request contains the content type of the file —
in this case, application/octet-stream .
5. Depending on the total size of the file, more than one part might need to be
sent. Although the result is always an array of bytes, depending on the
platform that your app is running, toByteArray will call different functions.
277
Kotlin Multiplatform by Tutorials Chapter 12: Networking
Note: Depending on the file type you want to send and the server
requirements, you may need to implement a different method. For more
information, read the official documentation from Ktor.
Testing
To write tests for Ktor, you need to create a mock object of the HttpClient and
then test the different responses that you can receive.
Before writing, you need to open the build.gradle.kts file from shared and
include the following in commonTest :
implementation(kotlin("test-junit"))
implementation("junit:junit:4.13.2")
implementation("io.ktor:ktor-client-mock:2.0.0-beta-1")
Before creating the tests, you need to mock some objects. After the class
declaration, add:
Now, you’ll need to mock the HttpClient . Add it below the code you just
pasted:
278
Kotlin Multiplatform by Tutorials Chapter 12: Networking
//2
install(ContentNegotiation) {
json(nonStrictJson)
}
engine {
addHandler { request ->
//3
if (request.url.toString().contains(GRAVATAR_URL)) {
respond(
//4
content = Json.encodeToString(profile),
//5
headers = headersOf(HttpHeaders.ContentType,
ContentType.Application.Json.toString()))
}
else {
//6
error("Unhandled ${request.url}")
}
}
}
}
}
1. Unit tests need to be mocked since you won’t be making any network calls.
The goal is to go through all the possible scenarios and validate that the app
behaves accordingly. For that, you’re initializing the HttpClient with a
MockEngine .
2. To create a valid test, you need to follow the same configuration that you
used when defining the requests. In this case, you need to use the
ContentNegotation plugin.
6. Generates an error in case the request URL doesn’t match with any of the
existing conditions.
Finally, with the request and response defined, write the following test:
@Test
279
Kotlin Multiplatform by Tutorials Chapter 12: Networking
public fun testFetchMyGravatar() = runTest {
val client = getHttpClient()
assertEquals(profile, client.request
("$GRAVATAR_URL${profile.entry[0].hash}$GRAVATAR_RESPONSE_FORMAT").
body())
}
The test passes if the response it receives is the same as the profile object
mocked; it fails otherwise.
To run a test, right-click the class name NetworkTests, then click in “Run
‘NetworkTests’”, or with the file open, just click on the green arrows shown next
to a test and choose android (:testDebugUnitTest).
Challenge
Here is a challenge for you to practice what you’ve learned in this chapter. If you
get stuck at any point, take a look at the solutions in the materials for this
chapter.
Key points
Ktor is a set of networking libraries written in Kotlin. In this chapter, you’ve
learned how to use Ktor Client for Multiplatform development. It can also be
used independently in Android or desktop. There’s also Ktor Server; that’s
used server-side.
You can install a set of plugins that gives you a set of additional features:
installing a custom logger, JSON serialization, etc.
281
Kotlin Multiplatform by Tutorials
13 Concurrency
Written by Carlos Mota
Note: This chapter follows the project you started in Chapter 12,
“Networking”. Or, you can use this chapter’s starter project.
You’ll learn what coroutines are and how you can implement them.
structured concurrency
#1 #2 #3
task
segments
coroutine #1
coroutine #2
coroutine #3
secondary thread
UI-thread
screen
refresh
282
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
rate
To improve its performance, these three tasks are running in the same thread,
but concurrently to each other. They were divided into smaller segments that
run independently.
kotlinx.coroutines: The most popular one, mostly because of its use among
Android developers and recommendation from JetBrains and Google. It’s
lightweight, it allows running multiple coroutines in a single thread and it
supports exception handling and cancellation.
Reaktive: An implementation of Reactive Extensions using the Observable
pattern.
CoroutineWorker: Supports multithreaded coroutines.
In this chapter, you’ll learn how to use kotlinx.coroutines. Spoiler alert: You’ve
already worked with coroutines before. :]
If you’re already familiar with coroutines, you can skip the next few sections
and go to “Structured concurrency in iOS”, or directly to “Working with
kotlinx.coroutines”, for the next developments in learn.
Understanding kotlinx.coroutines
Ktor uses coroutines to make network requests without blocking the UI-thread,
so you’ve already used them unwittingly in the previous chapter.
283
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
If you weren’t using coroutines on fetchFeed , these instructions would run
sequentially. In other words, the app would only iterate to the next item after
fetchFeed returned, which would delay the app startup.
Suspend functions
Suspend functions are at the core of coroutines. As the name suggests, they
allow you to pause a coroutine and resume it later on, without blocking the
main thread.
Network requests are one of the use cases for suspend functions. Open the
FeedAPI.kt file on shared/commonMain/data and look at the functions’
declaration:
They’re all suspend functions. Since a response may take some time, the app
cannot block and wait for any of these functions to return.
requesting RW feeds
2 4 6
invokeFetchRWEntry()
waiting for server response fetchFeed()
1 3 5
suspend fun // notifying the UI
fetchFeed()
fetchFeed() FetchRWEntry() invokeFetchRWEntry()
suspends resumes
UI-thread
new coroutine
Fig. 13.2 - Diagram showing the different steps of a network request with coroutines.
The entry point, for all platforms, is the fetchAllFeeds function from
shared/commonMain/presentation/FeedPresenter.kt. Once invoked, it
iterates over all the RSS feeds, and calls fetchFeed for each one of its URLs:
284
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
1. This is a heavy operation that might block the UI. To avoid this, you’ll do it
asynchronously. Create a coroutine by calling launch .
2. Once launched, it calls invokeFetchRWEntry from
shared/commonMain/domain/GetFeedData.kt. A suspend function calls
the FeedAPI to make the request.
3. This function suspends after making the request, and it waits until there’s a
response or the connection times out.
4. This is done in a separate thread, so the UI doesn’t get blocked.
As a key point, you can only call a suspend function from another one or
within a coroutine.
You already know that launch creates a new coroutine, but what’s
MainScope ? A coroutine scope is where a coroutine is going to run — in this
case, it will be the main thread.
285
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
its current state:
The difference between a SupervisorJob and a Job is that they have different
policies. In the first one, the children behave independently — if one fails, the
others won’t be affected — whereas in the second one, if a parent fails, all of its
children will be cancelled.
IO : Should be used for long-running and heavy tasks because it’s one
shared pool of threads, optimized for these types of operations. It’s
currently not available for iOS.
When you create a coroutine, you have to define the dispatcher where it should
run, but you can always switch the context later on its execution by calling
withContext with the prepended Dispatcher as an argument.
//3
withContext(Dispatchers.Main) {
286
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
//4
cb.onMyGravatarData(profile)
}
}
}
1. Creates a new coroutine in a thread from the Default thread-pool and starts
it. Ideally, you should use the IO dispatcher. However, it isn’t supported in
iOS, so you’ll need to create a platform-specific logic for this, as you’ll see in
the “Implementing Dispatchers: IO for iOS” section.
3. The UI can only be updated from the UI-thread, so it’s necessary to switch
from the Default dispatcher to the Main one. This can only be done from
within a coroutine.
4. onMyGravatarData is now called from the UI-thread, so the user can see this
newly received data.
if (result.entry.isEmpty()) {
GravatarEntry()
} else {
result.entry[0]
}
} catch (e: Exception) {
Logger.e(TAG, "Unable to fetch my gravatar. Error: $e")
GravatarEntry()
}
}
You have to be extra careful when using this function. If the coroutine is unable
to finish, it will keep using resources, potentially until the user closes the app.
287
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
If you have to update the UI, and you’re using GlobalScope , you must switch to
the UI-thread before. Otherwise, when running your iOS app, you’ll get the
following exception:
You also have the coroutineScope function that allows you to create a
coroutine, but it uses the parent scope as context. It has some particularities,
namely:
Only after all the children end can the parent also terminate.
runBlocking : blocks the current thread until the coroutine that it creates
ends.
Note: It shouldn’t be used inside an existing coroutine, since it will stop its
execution.
launch : Creates a coroutine without blocking the current thread. You can
define the CoroutineScope from where it should run. This scope guarantees
structure concurrency — in other words, a coroutine only ends after all of its
children have completed their operations.
async : Similar to launch in the way it’s constructed and how it runs. It
differs on its return type in that in this case it’s not a Job, but it’s a
Deferred<T> object that will contain the future result of this function.
288
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
fetchMyGravatar is now a suspend function. With this approach, you don’t
need the onSuccess and onFailure callbacks to update the UI, since you’re
going to return a GravatarEntry . You need to call await at the end to return
its final value instead of a Deferred<GravatarEntry>.
The main difference between both calls is that CoroutineScope doesn’t use the
same scope as its caller.
Following this approach means that you’ll also have to make a few more
updates. To use the same logic to notify the UI via callbacks, you’ll need to
change fetchMyGravatar(cb: FeedData) to:
CoroutineScope(Dispatchers.Default).launch {
cb.onMyGravatarData(fetchMyGravatar())
}
}
Otherwise, you can return the GravatarEntry directly to the UI. You’ll see how
to implement this second approach in the “Creating a coroutine with async”
section.
With this change, you need to update the calling function fetchProfile from
RWEntryViewModel on the iOS app so the UI can be successfully updated:
func fetchProfile() {
FeedClient.shared.fetchProfile { profile in
Logger().d(tag: TAG, message: "fetchProfile: \(profile)")
DispatchQueue.main.async {
self.profile = profile
}
}
}
You don’t need to update the Android app or the desktop app, since the
viewModelScope runs on the UI thread.
289
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
Note: In the next sections, you’ll learn that iOS is single-threaded by default.
Only when you enable the new Kotlin/Native memory model, you’re able to
use multi-threading. With this, if you want to compile your app now, you need
to replace Dispatchers.Default with Dispatchers.Main . Alternatively, you
can implement the dispatcher at the platform-specific level, as you’ll see in
the section “Implementing Dispatchers: IO for iOS.”
Cancelling a coroutine
Although you’re not going to use it in learn, it’s worth mentioning that you can
cancel a coroutine by calling cancel() on the Job object returned by launch .
In case you’re using async , you’ll have to implement a solution similar to this
one:
Note: async/await is only available if you’re using Xcode 13.2 or later and
running your app on iOS 13 or newer versions.
With async/await, you no longer need to use completion handlers. Instead, you
can use the async keyword after the function declaration. If you want to wait
for it to return, add await before calling the suspend function:
290
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
return await feed.invokeGetMyGravatar(
hash = md5(GRAVATAR_EMAIL)
)
}
Following the same logic as suspend functions, you can only call an async
function from another one or from an asynchronous task. In Kotlin, this
corresponds to calling the function from a coroutine.
Swift uses Task . Using Task , the previous example can be translated to:
Using kotlinx.coroutines
It’s time to update learn. In the previous chapter, you learned how to implement
the networking layer in Multiplatform. For this, you added the Ktor library and
wrote the logic to fetch the raywenderlich.com RSS feed and parse its responses
that later update the UI.
However, there’s a little detail that was left for this section: Ktor is built using
291
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
kotlinx.coroutines. This is why the MainScope , launch and suspend
functions seemed familiar in the “Understanding kotlinx.coroutines” section.
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-
core:1.6.0")
Note: The release is the 1.6.0. You should use this version with Kotlin 1.6.0 or
1.6.10. If you’re using a different one, open the release details section and
confirm which version of the Kotlin compiler you should use.
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-
core:1.6.0-native-mt") {
version {
strictly("1.6.0-native-mt")
}
}
Because Ktor uses the kotlinx.coroutines library in this case, it’s necessary to
use the strictly function to force it to use this native-mt branch instead.
Otherwise, you’ll get this error when running the iOS app:
If for any reason you can’t use this native-mt version on your project, and
you’re not using Ktor, you’ll need to create your own implementation of the
292
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
Dispatchers.Main. Otherwise, you might have issues on your iOS app:
This is because iOS only supports coroutines on the main thread. If you try to
use the main dispatcher, it will fall back to Dispatchers.Default since it’s not
supported on the main version.
It’s important to point out that although coroutines on iOS need to run on the
main thread, this doesn’t mean that they will block it. There are two different
types of operations:
Blocking: When the thread stops, waiting for some operation. A quick
example for this can be calling the sleep function.
Suspending: The coroutine suspends, and the thread itself keeps running.
This won’t block the thread, and other operations can still run during this
state.
And import:
import kotlin.coroutines.CoroutineContext
293
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
you can run on the Default or IO thread pools.
And import:
import kotlinx.coroutines.Dispatchers
import kotlin.coroutines.CoroutineContext
You can copy this folder and paste it inside the desktopMain directory at the
same level as platform. The JVM supports the same version as Android, so you
can use Dispatchers.Main to run your code in the UI-thread.
Now, navigate to iosMain and repeat the previous steps. Create the domain
folder and the PlatformDispatcher.kt file, and this time, add:
package com.raywenderlich.learn.domain
Create a IosMainDispatcher.kt file inside the domain folder, and define the
IosMainDispatcher object:
This is only possible because you have access to the Objective-C signatures from
Multiplatform. The dispatch_async function that you’re calling is the one
from the iOS platform.
Finally, import:
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Runnable
import platform.darwin.dispatch_async
294
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
import platform.darwin.dispatch_get_main_queue
Compile and run the application on the three platforms, and select one article
from the list to read.
295
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
296
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
This InvalidMutabilityException means you’re accessing an object that
belongs to another thread, which is currently not possible. Confirm if you’re
using the Dispatchers.Main or the ioDispatcher that you’ve created previously
to access that object.
Delete the build folder in the shared directory in the root directory of the
project.
Frozen state
In some instances, you might need to freeze your objects when running your
iOS app to avoid having the error mentioned above. Once freeze() is called
over an object, it becomes immutable. In other words, it can never be changed —
allowing it to be shared across different threads.
This fetchImageUrlFromLink receives the link from an article and returns the
page source code as the HttpResponse . It needs to be set as a suspend , so the
current thread won’t block while it’s waiting for the server response.
Note: You need to set the Accept header in this request otherwise the server
297
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
will return a 406, not acceptable.
//1
public suspend fun invokeFetchImageUrlFromLink(
link: String,
//2
onSuccess: (String) -> Unit,
onFailure: (Exception) -> Unit
) {
try {
//3
val result = FeedAPI.fetchImageUrlFromLink(link)
//4
val url = parsePage(result.bodyAsText())
//5
coroutineScope {
onSuccess(url)
}
} catch (e: Exception) {
coroutineScope {
onFailure(e)
}
}
}
2. The onSuccess and onFailure functions define how this function should
behave, depending on if it was possible to retrieve an image for the article or
not.
In the next sections, you’ll see different approaches to create and start a
coroutine. Although both of them are valid, the API that they expose to the UI is
298
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
different.
Note: A good rule of thumb for these cases is to decide between all the teams
that are going to use the shared module what they feel most comfortable
with. This is especially important for iOS programmers who are new to Kotlin
and can feel overwhelmed having to adapt to a new language. Interacting
with your shared module should be similar to any other library that exists for
iOS.
299
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
id=$id | url=$url")
val item = _items[platform]?.firstOrNull { it.id == id } ?:
return@launch
val list = _items[platform]?.toMutableList() ?: return@launch
val index = list.indexOf(item)
When this method receives a new url , the item to which it corresponds is
updated. Updating the _items map automatically updates the UI.
Now, when the app receives new articles, it will automatically request its images.
In the iosApp, open the FeedClient.swift file that’s inside the extensions
directory and search for fetchLinkImage . To also call the fetchLinkImage
from the FeedPresenter.kt class, update this function to:
300
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
handlerImage = completion
}
Compile and run the apps for the three platforms and navigate to the latest
screen.
301
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
As you can see, it’s no longer necessary to have the platform and id
parameters, since you’re going to return the image url in case it exists. The
async function allows returning an object while await waits for the response
to be ready. Instead of returning a Deferred<T> — in this case it would be a
Deferred<String?>.
302
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
Depending on the Android Studio version you’re using, it’s probable that it
would suggest you replace the previous implementation with:
Both approaches produce similar results, but they’re quite different under the
hood.
Now it’s time to update the UI! You’ll need to change how you’re calling the
fetchLinkImage function:
303
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
list[index] = item.copy(imageUrl = url)
_items[platform] = list
}
}
This is the code that includes onNewImageUrlAvailable , along with the call to
presenter.fetchLinkImage . Since you no longer use that callback, you can
remove it.
For iOSApp, you also need to update the FeedClient.swift file, which is
inside the extensions’ folder. Start by updating the FeedHandlerImage that
no longer has to receive all of its parameters:
@MainActor
public func fetchLinkImage(_ link: String, completion: @escaping
FeedHandlerImage) {
Task {
do {
let result = try await feedPresenter.fetchLinkImage(link:
link)
completion(result)
} catch {
Logger().e(tag: TAG, message: "Unable to fetch article image
link")
}
}
}
Since you’re now accessing a suspend function from Swift, you’ll have to use
await to wait for the result to be available. The @MainActor annotation
guarantees the Task runs on the UI thread. Otherwise, you might have a
InvalidMutabilityException .
@MainActor
func fetchFeedsWithPreview() {
for platform in self.items.keys {
guard let items = self.items[platform] else { continue }
304
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
let subsetItems = Array(items[0 ..<
Swift.min(self.fetchNImages, items.count)])
for item in subsetItems {
FeedClient.shared.fetchLinkImage(item.link) { url in
guard var list = self.items[platform.description] else {
return
}
guard let index = list.firstIndex(of: item) else {
return
}
list[index] = item.doCopy(
id: item.id,
link: item.link,
title: item.title,
summary: item.summary,
updated: item.updated,
imageUrl: url,
platform: item.platform,
bookmarked: item.bookmarked
)
self.items[platform.description] = list
}
}
}
}
Compile and run your app, and browse through the outstanding artwork of the
raywenderlich.com articles. :]
305
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
306
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
Dispatcher.Main : There isn’t support for a coroutine to run directly in the
UI-thread in iOS. To achieve this, you’ll need to implement your dispatcher at
the platform-level as you read in “Implementing Dispatchers.Main for iOS”,
from this chapter.
This new Kotlin/Native memory model aims to reduce the changes that you’ll
have to do specifically for iOS.
Although it’s still in an experimental state, you can try it on your apps.
kotlin-gradle-plugin : 1.6.10
ktor : 2.0.0-beta-1
coroutines-native-mt : 1.6.0
You still need to use the native-mt branch because the korio library was
built using an older version of coroutines.
Open the gradle.properties file, located in the root folder, and add:
This activates the new memory model and disables freezing. You need to set this
last attribute because not all libraries are fully compatible with the new model. If
you don’t disable freezing , you’ll end up with InvalidMutabilityException
or FreezingException on your iOS app.
get() = Dispatchers.Default
There’s still no Dispatchers.IO for Native, but you can now use the Default
instead of always using the main thread.
With this change, you need to update the calling function fetchFeeds from
RWEntryViewModel on the iOS app:
307
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
func fetchFeeds() {
FeedClient.shared.fetchFeeds { platform, items in
Logger().d(tag: TAG, message: "fetchFeeds: \(items.count) items
| platform: \(platform)")
DispatchQueue.main.async {
self.items[platform] = items
}
}
}
Run your iOS app. Navigate through the app to confirm that everything is
working as expected.
Challenge
Here’s a challenge for you to practice what you’ve learned in this chapter. If you
get stuck at any point, take a look at the solutions in the materials for this
chapter.
Remember that you don’t need to run this logic sequentially — you can lunch
multiple coroutines to fetch and parse the response, making this operation
308
Kotlin Multiplatform by Tutorials Chapter 13: Concurrency
faster.
Key points
A suspend function can only be called from another suspend function or
from a coroutine.
You can use launch or async to create and start a coroutine.
The new Kotlin/Native memory model gives you support to run multiple
threads on iOS.
In the next chapter, you’ll learn how to migrate a feature to support Kotlin
Multiplatform and release your libraries so that you can later reuse them in
your projects.
309
Kotlin Multiplatform by Tutorials
In the previous chapters, you’ve built learn for Android, iOS and desktop. All of
these apps fetch the raywenderlich.com RSS feed and show you the latest
articles written about Android, iOS, Flutter and Unity. You can search for a
specific topic or save an article locally to read it later. During the app’s
development process, you’ve worked with:
Serialization
Networking
Databases
Concurrency
And, along this journey, you’ve also built additional tools that can be reused in
other projects:
Logger
Dispatchers
In this chapter, you’re going to learn how you can create and publish a library so
you can reuse it in the other apps that you develop in this book — and for the
next one you’re going to build. :]
In this section, you’re going to see how a simple feature like opening a website
link in a browser can easily be moved to KMP.
310
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
the implementation is entirely different.
In Android, a prompt is shown so you can select which app it should use to send
the Intent. Or, if you have one already set as default, it will automatically open it
and load the article you’ve clicked on. MainActivity.kt, in androidApp/ui,
defines this function:
Since anyone can have multiple apps installed on a device, it’s important to
define which apps are capable of receiving this intent. In this scenario, you’re
looking for apps that can open a URL. To avoid opening the wrong app, Android
allows you to define a couple of parameters the system uses to filter between all
installed apps, and the one that best fit your Intent. First, it checks for those
that have on their AndroidManifest.xml the ACTION_VIEW attribute defined,
and then those that are capable to parse URIs.
iOS has a different approach. To open a URL, you just need to use the
OpenURLAction from the environment. Open LatestView.swift from iosApp
module and scroll to the Section struct:
The var openURL allows you to send a URL that will open the default browser
on your device:
openURL(URL(string: "\(item.link)")!)
When the user clicks on one of the articles, the app creates a URL from that
item link and calls openURL with it to open the link in the browser.
311
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
}
The getDestkop call returns an instance of Desktop that contains its context
as well as a couple of functions that let you access some of your computer’s
features — like open and edit files, browser, mail, print, and more. Here, you’re
using browse to open your default browser with the url from the item that
you click on.
Now that you’re familiar with how the three platforms open a URL, it’s time to
move this logic to KMP.
The first step is to add a new KMM Module. Go to File ▸ New ▸ New Module…,
and select the Kotlin Multiplatform Shared Module template on the bottom of
the list. Here, define the:
Click Finish and wait for the project to synchronize. Afterward, if you look at
the Android Studio Project tab, you’ll see a new shared-action module added.
Open settings.gradle.kts file in the project root folder. Confirm that shared-
action is now part of learn:
include(":shared-action")
312
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
The Android Studio template for Kotlin Multiplatform Mobile only generates the
Android and iOS targets, so you’ll need to manually add the desktop platform.
jvm("desktop")
You still need to add the desktopMain folders on the shared-action module. An
easy solution to implement this is to right-click src and select New ▸ Directory.
You’ll see a new window with a couple of folder suggestions. Search for
“desktop” and select desktopMain/kotlin.
Now you’re just missing the package structure. You can easily create this
directory by right-clicking desktopMain/kotlin. This time, select New ▸
Package. In this new window, enter: com.raywenderlich.learn.action.
You can also remove the Platform.kt and Greeting.kt files that Android Studio
generated in the androidMain, commonMain and iosMain folders.
That’s it!
313
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
Depending on the view type you have selected on the Android Studio project
tab, you might have a different tree structure. To see the same one, select the
Project option on top.
314
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
android {
publishLibraryVariants("release", "debug")
}
If you don’t define Android to publish its libraries, your project will use the one
created for desktop by default. This is possible since JVM supports Android.
However, this won’t work because the platform-specific code is entirely
different on both platforms.
includeBuild("plugins/multiplatform-swiftpackage-m1_support")
id("com.chromaticnoise.multiplatform-swiftpackage-m1-support")
//1
multiplatformSwiftPackage {
//2
xcframeworkName("SharedAction")
//3
swiftToolsVersion("5.3")
//4
targetPlatforms {
iOS { v("13") }
315
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
}
//5
outputDirectory(File(projectDir, "sharedaction"))
}
1. This is the function that allows you to configure your generated Swift
package.
2. You can define a specific name for the generated framework by setting the
xcframeworkName . Otherwise, it will use the module’s name as default.
5. By default, swiftpackage is the output folder of the Swift package. You can
define a different location and name through the outputDirectory
parameter.
This plugin allows you to generate the Swift Package Manager Manifest and the
XCFramework that you can use on iosApp.
Synchronize the project. Open the terminal, and in the project root folder, run:
./gradlew shared-action:createSwiftPackage
316
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
BUILD SUCCESSFUL
Look at the shared-action folder. You’ll see a new sharedaction directory that
only contains one file: Package.swift. Since the project doesn’t contain any
code, no XCFrameworks were generated.
After its execution, return to the sharedaction folder. You now have the
XCFrameworks for arm64 and arm64_x86_-_64 simulator.
You can also make the same update on the shared module. Similar to what
you’ve done on shared-action, open the shared/build.gradle.kts file and add
the plugin :
id("com.chromaticnoise.multiplatform-swiftpackage-m1-support")
multiplatformSwiftPackage {
xcframeworkName("SharedKit")
swiftToolsVersion("5.3")
targetPlatforms {
iOS { v("13") }
}
}
The above command allows you to only generate a Swift package for the
shared-action module. If you want to generate for both shared modules, you
can easily do it by not adding the library name as prefix:
./gradlew createSwiftPackage
317
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
Alternatively, you can just define the openLink function without adding it to an
object :
You can call this function directly from androidApp and desktopApp, since
you reference it directly. However, from iosApp, you would need to access it via:
PlatformActionKt.openLink(url: "\(item.link)")
Since there’s no class defined, the compiler creates one when generating the
framework and uses the class name plus the extension of the file as its name.
Although you could define a different name, you’re always going to have the kt
prefix — which isn’t the most sympathetic name, especially when you’re trying
to convince the iOS team to adopt a shared module written in Kotlin. :]
androidApp: Open the MainActivity.kt file and scroll until you see an
openEntry function. Copy its content and paste it on the openLink
function you created in shared-action/androidMain:
318
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(url)
startActivity(intent)
}
Since this code block exists outside the scope of an Activity, you need its
Context to call startActivity . To overcome this, you’re going to declare an
activityContext variable outside the Action object:
activityContext.startActivity(intent)
You’ve got access to the Android SDK, so for the above function you’ll need to
import:
import android.content.Context
import android.content.Intent
import android.net.Uri
desktopApp: Go to the Main.kt file and search for openEntry . Copy its
content into the openLink function from shared-action/desktopMain.
The Logger class belongs to the shared module you’re not using on share-
action. To solve this, you can do one of the following:
For now, you’re going to follow the first approach. However, the third one is
tempting, so don’t forget to do the first challenge of this chapter, and afterward
come back to this step and replace the println function with Logger . :]
319
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
And import:
import java.awt.Desktop
import java.net.URI
iosApp: Open the HomeView.swift file and search for openURL . You’ll find
two results: the first one declares the variable from Environment , and the
second one its invocation.
It’s important to remember that although you’re writing Swift code, KMM uses
Objective-C signatures. This is why you use the NSLog on the
PlatformLogger.kt file from shared/iosMain.
320
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
1. These dots show you the path where openURL is located. You can see that
UIApplication belongs to UIKit if you click on them. So, to access this
function from iosMain, you’ll need to import:
import platform.UIKit.UIApplication
UIApplication.sharedApplication.openURL(url)
Note: You can see all the classes that exist inside platform if you go to JetBrains’
kotlin-native repository.
application.openURL(nsurl)
}
import platform.Foundation.NSURL
import platform.UIKit.UIApplication
Note: There’s currently an issue on the Android Studio for Mac M1 where the
platform package seems to not be resolved. In other words, you might see
the platform import along with the UIApplication and NSURL calls at red.
If this is the case, don’t worry — you can compile the project without any
problems.
321
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
That’s it! To compile the project and generate the JVM, Android libraries and
XCFramework, run:
./gradlew assemble
./gradlew createSwiftPackage
implementation(project(":shared-action"))
For iOS, you need to first open the project with Xcode. Remember that it’s the
iosApp.xcworkspace file that you should load.
1. Open the Project file and click the General tab on top.
3. A new window will open asking you to choose the framework. Click Add
Other… then Add Files…
322
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
This step is simpler to do — in this case you just need to add the shared-action
as an implementation to the shared module build.gradle.kts file in the
commonMain dependencies section:
implementation(project(":shared-action"))
To have a more strict separation of concerns, you’re going to follow the first
option for learn.
323
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
}
The activityContext that you’re setting here will be used to open a new
activity from shared-action.
import android.net.Uri
The next update that you need to do is on the Main.kt file on the desktopApp
project. When invoking the MainScreen Composable, update the onOpenEntry
call to:
onOpenEntry = { openLink(it) },
With this, you’re going to use the function from shared-action to open an
article on your default browser. You can now remove openEntry at the end of
this file and remove now-unnecessary imports:
import java.awt.Desktop
import java.net.URI
import java.net.URISyntaxException
To update the iOS app, switch to Xcode and make the same update on the
following files:
RWEntryRow.swift
LatestView.swift
Replace the Button action when iterating over the items , from openURL to:
Action().openLink(url: "\(item.link)")
import SharedAction
324
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
Now that you’ve updated the three platforms, compile and run the apps, browse
through the articles list and select one to read.
325
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
With this, you can easily import any of them by just including it on the
settings.gradle.kts file located in the project root directory:
include(":androidApp")
include(":desktopApp")
include(":shared")
include(":shared-action")
include(":pager")
include(":pager-indicators")
include(":precompose")
326
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
include(":your-library")
project(":your-library").projectDir = file("../path/to/your-
library")
Higher build time — particularly the first time the project builds. This
happens because there’s no library compiled at that moment.
In this section, you’ll publish the shared-action library that you created before.
group:name:version
The project name is the folder name — in this case, shared-action. You can
define the group and version on the build.gradle.kts file from shared-action.
version = "1.0"
The group , if not defined, uses the parent name. In this case, it would be learn.
This can be a bit misleading, since there’s no information about the author. To
overcome this, above version add:
group = "com.raywenderlich.shared"
327
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
id("maven-publish")
./gradlew shared-action:publishToMavenLocal
When the operation ends, you can read BUILD SUCCESSFUL in the console
logs.
If you want to publish all your libraries locally, you should run instead:
./gradlew publishToMavenLocal
~/.m2/repository
include(":shared-action")
mavenLocal()
The next time Gradle synchronizes, it will also look for the project dependencies
in your .m2/repositories directory.
implementation(project(":shared-action"))
328
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
implementation("com.raywenderlich.shared:shared-action:1.0")
Compile both Android and desktop apps and open an article from the list.
329
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
Depending on the repository that you select, the configuration process should
be similar to the one presented in this section. Typically, the differences are the
URL that you use to connect to and the authentication required.
Here, you’re going to use GitHub Packages — mainly because it’s simple to
configure and has a free tier that you can use. You just need to create an
account.
Before you can publish a library, you need to first create the access token that
Gradle will use to authenticate your account.
In this screen, you can configure a name for your token, how long it will be valid
and which permissions it should have. For the name, add: Publish Maven
Repository and check the write:packages and read:packages checkboxes.
It will automatically select the repo attribute. Your screen will be similar to this
one:
330
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
Note: It’s important to choose a name that you can easily remember later on.
It helps when you receive an email from GitHub saying that your token is
about to expire and you need to decide whether you want to renew it or not.
#Repository Credentials
mavenUsername=YOUR_USERNAME
mavenPassword=YOUR_TOKEN
331
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
Open the build.gradle.kts file from the shared-action module and scroll to the
bottom.
publishing {
repositories {
maven {
//1
url =
uri("https://maven.pkg.github.com/YOUR_USERNAME/YOUR_REPOSITORY")
//2
credentials(PasswordCredentials::class)
authentication {
create<BasicAuthentication>("basic")
}
}
}
}
This Gradle task is responsible for publishing your libraries into the URL you
defined. It’s using BasicAuthentication as the authentication mechanism:
2. Gradle supports different types of authentication. You can find all of these
methods on their documentation website. The
PasswordCredentials::class looks at the mavenUsername and
mavenPassword to authenticate the request.
credentials {
name = YOUR_USERNAME
password = YOUR_TOKEN
}
It’s a good practice to have these in a separate file that should be added to the
.gitignore file to avoid unconsciously pushing the credentials into the
332
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
repository.
Before publishing your library, you need to add it once again to the
settings.gradle.kts file. Open it and after shared add:
include(":shared-action")
./gradlew shared-action:publish
When this operation ends, you’ll see a BUILD SUCCESSFUL message in the
console. Open your repository GitHub page and on the right side, you’ll see a
section named Packages that should have a list of the libraries that you just
uploaded. Go to the Packages section, and you’ll see a screen similar to this one:
Now that you’ve confirmed that your libraries were successfully uploaded,
return to Android Studio and in build.gradle.kts that’s located in the root
directory, add the following code after mavenCentral , which is inside the
allProject/repositories section:
maven {
url = uri("https://maven.pkg.github.com/cmota/shared-action")
credentials(PasswordCredentials::class)
authentication {
create<BasicAuthentication>("basic")
}
}
333
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
Previously, you defined the URL for publishing your libraries. Now, you’re
adding to the list of repositories that Gradle should look into when downloading
the project dependencies.
Note: To use the credentials that you defined on gradle.properties, you need
to have this maven repository declared above the one from JetBrains.
Otherwise, you’ll get an error related to missing credentials. This is due to the
multiple declaration of maven repositories. Gradle automatically matches the
maven repositories added with the credentials on gradle.properties, so the
first one corresponds to mavenUsername/mavenPassword , the second one to
maven2Username/maven2Password and so on.
And that’s it! The project is ready. Compile and run both apps: Android and
desktop.
334
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
./gradlew shared-action:createSwiftPackage
When the build ends, you’ll see a sharedaction directory inside the shared-
action module that contains your frameworks. Copy the contents of this folder
to your GitHub repository and push these files.
In the root of your repository, you should have the following files:
SharedAction.xcframework.
Package.swift.
README.md.
SharedAction-1.0.zip
Note: You can’t have them inside a folder. Otherwise, you won’t be able to add
them easily to your project.
Open Xcode and go to Project and select the General tab. Scroll down to
Frameworks, Libraries, and Embedded Content, and in case you’re still using
the local SharedAction framework, remove it.
335
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
Next, click +, then Add Package Dependency on the bottom drop-down. A new
window opens, and you can enter your repository URL in the top right corner.
Depending on its visibility, Xcode might ask you for your GitHub credentials.
On the Add to Project drop-down, select iosApp and then Add Package. Xcode
will download your library.
Click AddPackage. When this operation ends, you can see the SharedAction
framework added to the project.
336
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
Compile and run the app. There are new articles ready for you to read!
Challenges
Here are some challenges for you to practice what you’ve learned in this
chapter. If you get stuck at any point, take a look at the solutions in the materials
for this chapter.
In this first challenge, create and publish a new library — shared-logger — that
should contain the PlatformLogger.kt implementation for all the three
platforms: Android, desktop and iOS.
337
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
They each had a customized version of a logger. The second challenge is to use
the library that you created from the first challenge, on all three apps. Don’t
forget to make the changes both at the business logic and UI levels.
At the time, there was no logger class, so you used the println function as the
module logger. With the recently created shared-logger, it’s now time to update
shared-action and use your new library.
Key points
If the features you want to migrate to KMP have any platform-specific code,
you need to write this specific logic for all the platforms your library will
target.
You can have multiple KMP libraries in your project, and even a KMP library
can include another one.
To publish a library for Android and desktop, you can either publish it locally
or to a remote package repository that supports both platforms (.jar and
.aar). In this book, you’ve seen how to use JitPack.
For iOS, you’re creating a Swift package to share your library. Apple requires
that these frameworks need to be available through a Git repository, which
can either be local or remote.
You started this journey by getting familiar with Jetpack Compose and Swift UI
for UI development, and moved toward sharing your app’s business logic across
these three platforms with Kotlin Multiplatform. You can now create an app
from scratch and apply all of these new concepts, or migrate one that you’ve
already written to KMP.
338
Kotlin Multiplatform by Tutorials Chapter 14: Creating Your KMP Library
Now that you’re a Kotlin Multiplatform master, you might be wondering what to
read next. Perhaps you want to dive deeper into Jetpack Compose and SwiftUI?
Or, do you prefer to sit back and watch a video course instead? You can see the
Jetpack Compose and Your Second iOS & SwiftUI app that teach you the same
concepts as the books.
Additionally, since you’re already familiar with Ktor, why not try it in another
platform? One that doesn’t require you to design a UI: Server-Side Kotlin with
Ktor. There are a lot more materials available for you to use as you learn — find
them at raywenderlich.com.
339
Kotlin Multiplatform by Tutorials
15 Conclusion
Congratulations! After a long journey, you’ve learned many important things
about Kotlin Multiplatform and how you can leverage it to share code across
native apps. You also learned how to develop UI on iOS and Android using the
latest UI toolkits. Now you can apply what you learned in your next app or start
migrarting features in your current app, thus saving development time.
Remember, if you want to further your understanding of Kotlin and Android app
development after working through Kotlin Multiplatform by Tutorials, we
suggest you read Jetpack Compose by Tutorials and SwiftUI Apprentice. Both
are available in our online store:
https://www.raywenderlich.com/books/jetpack-compose-by-tutorials
https://www.raywenderlich.com/books/swiftui-apprentice
If you have any questions or comments as you work through this book, please
stop by our forums at https://forums.raywenderlich.com and look for the
particular forum category for this book.
Thank you again for purchasing this book. Your continued support is what
makes the books, tutorials, videos and other things we do at raywenderlich.com
possible. We truly appreciate it!
340
Kotlin Multiplatform by Tutorials
A Appendix A: Kotlin: A
Primer for Swift Developers
Written by Carlos Mota
If you open its official website, you’ll immediately read modern, concise, safe,
powerful, interoperable (with Java for Android development) and structured
concurrency. All of these keywords are functionalities that developers look for in
any programming language, and Kotlin has all of them.
Even more importantly, Kotlin is not only for Android. It also supports Web
front-end, server-side and — the focus of your work throughout this book —
Multiplatform.
The examples shown in this appendix are code snippets from learn. A final
version of the project is available in the materials repository.
Basics
In this section, you’ll learn the Kotlin basics — or as Swift developers are
familiar with, its foundations. :]
One good thing about Android Studio is that in most cases, if you’re missing an
import or using a wrong type for a variable, it will automatically warn you and
suggest a fix.
Note: Every time Android Studio underlines your code or shows a tooltip box,
you can automatically accept its suggestion by pressing Alt-Enter.
341
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
Package declaration
The extension of a Kotlin file is .kt. The tree hierarchy of a Multiplatform project
typically follows the Android naming convention for package names — you’ve
got three folder levels. In learn, it’s com/raywenderlich/learn, and they
usually correspond to:
com, domain
Since the package name is unique — you can’t have two apps on the Google Play
Store with the same one — this convention guarantees there is no conflict
between apps from different companies.
Every time you declare a new class or object, you must define the package
declaration. This should be the first instruction of a new file. Or, if you have a
copyright header, right after it.
If you open the FeedPresenter.kt inside the presentation folder from the
shared module, you can see that the import is:
package com.raywenderlich.learn.presentation
In this case, presentation is the subfolder where this class is. The package
definition should correspond to the same tree hierarchy — otherwise, you might
end up importing the wrong files.
Imports
Typically, when an import is missing, Android Studio shows you a prompt with
one or more suggestions, so you shouldn’t have any issues. In any case, if you
want to add one manually, you need to add it after the package declaration:
import com.raywenderlich.learn.data.model.GravatarEntry
This is different from Swift. There’s no need to add classes — you just need to
import the framework you’re going to use.
Comments
342
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
Similar to Swift, you can add three types of comments:
Line, where you just need to add // before the code or text that you want to
comment. In this example, Logger won’t be executed:
Block, where you need to surround your code or text with /* */ . This is
also used for adding the copyright section at the beginning of a file:
/*
* Copyright (c) 2021 Razeware LLC
*
*/
/**
* This method fetches your Gravatar profile.
*
* @property cb, the callback used to notify the UI that the
* profile was successfully fetched or not.
*/
public fun fetchMyGravatar(cb: FeedData) {
//Your code goes here
}
Variables
Similar to Swift, in Kotlin you also have two types of variables:
343
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
private val scope = PresenterCoroutineScope(defaultDispatcher)
var is the same keyword as in Swift. It’s a mutable variable, so you can set it
as many times as you need. In this example, the initial value of listener is
null . When the UI makes a new request for data, it’s going to be updated
with a new callback reference:
You can have optional values in both languages. The only difference is Kotlin
uses null , whereas Swift uses nil to represent the absence of a value.
Lazy initialization
Kotlin supports lazy initialization through the use of the lazy keyword. This
variable needs to be immutable — in other words, you need to declare it as val .
The value of this variable will only be calculated when it’s first accessed. You
should only define a variable as lazy if you don’t need to access it right away and
the variable does some heavy work.
As you can see, it’s defined as lazy . Do this to avoid decoding RW_CONTENT
immediately when the app starts. It’s one less thing to process.
If your app has a heavy startup, following this approach will give you a faster
and smoother initialization of the app. The value will only be set when there’s a
call to content .
Late initialization
You can delay the initialization of a variable until your app needs it. For that,
you need to set it as lateinit , and it can’t be set as immutable or null.
344
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
Change the listener on FeedPresenter.kt to:
Removing the ? and null defines this object as non-null. You’ll immediately
see a couple of warnings through this file:
You need to be careful when using lateinit ; if you try to access its value
without having it initialized, your app will crash with the exception:
if (::listener.isInitialized) {
//Do something
}
if listener != nil {
//Do something
345
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
}
Nullability
Perhaps the most known trait of Kotlin is its nullability. Ideally, there are no
more NullPointerExceptions — in other words, exceptions triggered by calls to
objects that don’t exist. The word “ideally” is needed here since developers have
the final word and can always go against what the language advises.
You can see that listener has the type of FeedData , but its value can be
null . Now, try to make any operation on this object. On fetchAllFeeds ,
before the Logger call, add:
listener.onMyGravatarData(GravatarEntry())
Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver
of type FeedData ?
Since this variable might be null, you shouldn’t do any operation before
checking its value. There are two different possibilities here:
1. Explicitly say that it won’t be null. You can use the character !! to tell the
compiler that this value will never be null , so you can make any call that
you need:
listener!!.onMyGravatarData(GravatarEntry())
Going against the language rules is never a good idea, so try to run away from
this implementation.
2. Only call the method if the value is not null. In this case, listener is
mutable, so you can’t just add an if condition to see if it’s not null (since it
might be changed by another thread). The solution is to use the ? operator
again. In this scenario it will only call onMyGravatarData if listener is not
null:
346
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
listener?.onMyGravatarData(GravatarEntry())
Well, there might be a third possibility here. Don’t make listener as nullable
in the first place. :]
Additionally, you can also use *?.let { ... } as a verification to only run the
code between brackets if the variable that you’re accessing is not null:
listener?.let {
it.onMyGravatarData(GravatarEntry())
}
it corresponds to listener .
String interpolation
With string interpolation, you can easily concatenate strings and variables
together. On fetchAllFeeds , you’ll iterate over content and call fetchFeed
with the platform and feed URL. Before this block of code, add:
What happens if you don’t add those brackets, and instead you have:
Compile your app and switch to the Logcat view to confirm that this log will
show you the feed object followed by “.platform”.
Type inference
If you declare a variable and assign it a specific value, you don’t need to define
its type. Kotlin is capable of inferring it in most cases. If you look at the variables
declared at FeedPresenter.kt, you can see that json uses type inference, but
347
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
content doesn’t.
Try to remove the type from content declaration. Android Studio immediately
underlines this expression, and if you check the error it says:
Type checks
Both languages use the is to check if an object is from a specific type.
Cast
Casting a variable is similar in both languages. You just need to use the keyword
as followed by the type of the class that you want to cast.
348
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
If you’re dealing with custom objects, you can always create an extension
function for it.
Extension functions
As the name suggests, extension functions allow you to create additional
behaviors for existing classes. Imagine that you want to add a method that needs
to be available for all String objects, and it should return “Ray Wenderlich”
when called:
This is it. You use the type that you want to extend, followed by the method
name. Now this function is available for all String objects.
You can try this by adding the previous function and a new log to a String
variable on FeedPresenter.kt — for instance to RW_CONTENT :
init {
Logger.d(TAG, "content=${RW_CONTENT.toRW()}")
}
You can confirm in the Logcat that the output of this call will be similar to:
Comparing objects
You can compare objects by reference through the use of === or by content ==.
Control ow
Although the syntax is quite similar in both languages, you’ll find that Kotlin
gives you powerful expressions that you can use.
if… else
This condition check is similar in both languages. If you open GetFeedData.kt,
you can see different functions that use if… else.
349
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
if (parsed != null) {
feed += parsed
}
Moreover, you don’t need to add brackets when it’s a single instruction.
if (parsed != null)
feed += parsed
Or even inline:
switch
It doesn’t exist in Kotlin. Alternatively, you can use when which is similar.
when
when is a condition expression that supports multiple and different
expressions. You can see an example of how to use it on the ImagePreview.kt
file, which is inside the components folder of the androidApp:
when (painter.state) {
is ImagePainter.State.Loading -> {
AddImagePreviewEmpty(modifier)
}
is ImagePainter.State.Error -> {
AddImagePreviewError(modifier)
}
else -> {
// Do nothing
}
}
In this case, you’re checking the current state of an image that’s being
downloaded from the internet, and adding different composable depending on if
its value is either Loading or Error .
Since all of these expressions are single-line, you could drop the brackets.
for
Back to the FeedPresenter.kt file from the shared module. You can find the
for loop on fetchAllFeeds :
350
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
Here, you’re iterating through all the values of content . Starting with the first
element in the list, on each iteration you’ll get a different element that you can
access through feed .
Additionally, there are other possibilities to write the same for cycle:
That uses the index to go through all elements. Or, you could get the index
and the feed directly via:
These are all possibilities that iterate through the list of all the elements from
content to get the same result .
while
The while and do … while loops are similar to Swift. You just need to add
the condition that should end the cycle and the code that should run while it
isn’t met.
while (condition) {
//Do something
}
do {
//Something
} while (condition)
351
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
The difference between both is the same as in Swift: if the condition is false on
while the code block will never run, while do … while will run once.
while condition {
//Do something
}
repeat {
//Something
} while condition
Ternary operator
It doesn’t exist in Kotlin. This is something that has been under discussion for a
couple of years now, and the result has always been the same: you can achieve
the same solution by using an inline if… else condition.
Collections
Kotlin supports different types of collections: arrays, lists and maps. These are
immutable by default, but you can use their mutable counterpart by using:
mutableList and mutableMap.
Although on Swift you can change the mutability of a list or a dictionary if you
declare it with let (immutable) or var (mutable), the same is not valid for Kotlin.
As mentioned above, you’ve got the list and map for immutable variables, and
mutableList and mutableMap for mutable.
Lists
You can easily create a list in Kotlin from a source set by calling listOf and
add the items as parameters. You can see an example where this is done on the
MainScreen.kt file inside the androidApp/main folder:
Imagine that you want to add a new item to this list. You can’t. There’s no add or
352
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
remove method, since the list object is immutable. What you can do is create
a mutable list:
Now you can add or remove elements to the list. Try removing the Search
option:
bottomNavigationItems.remove(BottomNavigationScreens.Search)
Or, you could just use the minus and equal sign:
bottomNavigationItems -= BottomNavigationScreens.Search
Arrays
Arrays are mutable, but they have fixed size. Once you’ve created one, you can’t
add or remove elements. Instead, you change its content. Using the previous
example, you can create an arrayOf with an initial number of items:
And then if you want to change the value of one of its indexes:
bottomNavigationItems[0] = BottomNavigationScreens.Bookmark
bottomNavigationItems[1] = BottomNavigationScreens.Home
353
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
Modify the previous example to create a map containing the index as key and
the screen as value:
You can get any value on the map by using its key:
// Returns BottomNavigationScreens.HOME
bottomNavigationItems[0]
// Returns BottomNavigationScreens.HOME
bottomNavigationItems.get(0)
Or by converting toMutableMap :
Extra functionalities
All of these collections also provide a set of functions that allow you to easily
iterate and filter objects. Here’s a short list of the ones that you might use daily:
354
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
*.isEmpty() returns true if the collection is empty, false otherwise. On
the contrary, you also have *. isNotEmpty() that returns the opposite
values.
*.first { ... } returns the first object that meets the condition between
brackets. There’s also *.firstOrNull { } that returns null if there’s no
object that matches the predicate.
*.last { ... } is similar to first , but this time the last object found is
returned.
Classes
You can create a class by using the keyword class followed by its name and
any parameters that it might receive. If you open the FeedPresenter.kt file,
you’ll see:
Typically, each word of a class has an uppercase letter. In this case, feed has
val set, so it can be accessed from any function on FeedPresenter scope.
Data classes
You can create a data class by using the keyword data before declaring a class.
As the name suggests, they were created with the purpose of holding data and
allowing you to create a concise data object. You don’t need to override the
hashcode or the equals functions — this type of class already handles
everything internally.
You can see an example of a data class if you open RWContent.kt from the
data/model folder on the shared module:
355
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
However, they have a couple of differences when compared with a generic class:
you can’t inherit a data class or define it as abstract.
Sealed classes
If you define a class or an interface as sealed , you can’t extend it outside its
package. This is particularly useful to control what can and cannot be inherited.
Open the BottomNavigationScreens.kt file inside ui/main in the androidApp:
If you try to extend this class in any other class in the project, you’ll see an error
similar to the following:
Although sealed classes don’t exist in Swift, you can create a similar concept
with enum :
enum BottomNavigationScreens {
struct Content {
let route: String
let stringResId: Int
let drawResId: Int
}
356
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
}
enum BottomNavigationScreens {
...
Default arguments
Kotlin allows you to define default arguments for class properties or function
arguments. For instance, you can define the default value for platform to
always be PLATFORM.ALL . With this, you don’t necessarily need to define the
platform value when creating a RWContent object. In these scenarios, the
system will use the default one.
// > ALL
Logger.d(TAG, "platform=${content.platform}")
// > https://www.raywenderlich.com
Logger.d(TAG, "url=${content.url}")
// > ""
Logger.d(TAG, "image=${content.image}")
Singletons
357
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
To create a singleton in Kotlin, you need to use the keyword object . The
ServiceLocator.kt file — since it deals with object initialization — is one such
example:
This guarantees that at all times, you’ll only have one reference to
ServiceLocator throughout the scope of your app.
Interfaces
Interfaces are similar to Swift protocols. They define a set of functions that any
class or variable that uses them needs to declare.
FeedData defines three different functions that will be called when there’s a
network response. They’re declared on FeedViewModel.kt (inside
androidMain/home) and used to notify the UI that there are new data available.
Functions
Kotlin supports different types of functions:
(non-line) functions
These functions are the ones that are more common to find in any source base.
They’re quite similar to Swift func , but in Kotlin, this keyword loses a letter
because it’s fun . :]
listener = cb
358
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
for (feed in content) {
fetchFeed(feed.platform, feed.url)
}
}
A function can also return an object. If you open FeedAPI.kt and look at
fetchRWEntry , you can see that it’s returning a HttpResponse object.
Moreover, since there’s only one instruction, you don’t need to add brackets and
the return can be written on the same line. You just need to add the = sign:
Lambda expressions
Lambda expressions allow you to execute specific code blocks as functions.
They can receive parameters and even return a specific type of object. You can
see two of them on fetchFeed : onSuccess and onFailure parameters on
FeedPresenter.kt.
Both of these expressions receive an it parameter. In the first case, it’s a list of
RWEntry , and in the second it’s an Exception. Alternatively, you could define
this expression like the following to better identify what it really is:
Higher-order functions
Higher-order functions support receiving a function as an argument.
359
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
arguments on fetchFeed . If you analyze these instructions, you can see that
onSuccess and onFailure receive different it objects. Open FeedData.kt
and look for the onDataAvailable :
You can see that both parameters receive a function, but in one the type is a list
of RWEntry and in the other it’s an exception.
Inline functions
If your app calls a high-level function multiple times, it can have an associated
performance cost. Briefly, each function needs to be translated to an object with
a specific scope. Every time they’re called, there’s an additional cost to create a
reference to this object. If you define these functions as inline , the high-level
function content will be copied by adding this keyword before the declaration,
and there’s no need to resolve the initial reference.
Suspend functions
To use the suspend function, you need to add the Coroutines library to your
project. You can run, stop, resume and pause a suspended function. This is why
they’re ideal for asynchronous operations — and why the app network requests
use it:
360
Kotlin Multiplatform by Tutorials Appendix A: Kotlin: A Primer for Swift Developers
You can find a comparison table between both languages in the materials
repository.
If you want to learn more about both languages, you’ve got the Kotlin and Swift
Apprentice books that teach you everything you need to know about both
languages in detail.
361
Kotlin Multiplatform by Tutorials
Two tools are a programmer’s best friends: the console logger and breakpoints.
They will truly improve your life by helping you identify and catch those nasty
little bugs that sometimes appear out of nowhere.
You’ve used the logger throughout this book. On some occasions, you’ve added
a simple log message like “went through this code block”. On other occasions,
you’ve printed all the variables in a method. Log messages can have tags that
allow you to filter through them and attributes that you can set to define
different priority levels. This can help you easily understand where something
went wrong.
Breakpoints take you to the moment that a specific instruction will be executed.
You can see all the steps that lead to this stop and all that will succeed. Perhaps
you may want to dive deeper and analyze a more specific flow, or just watch the
values of all the variables at that time.
Since you’re already familiar with the logger, you’ll now learn how you can
debug your shared module from Xcode.
362
Kotlin Multiplatform by Tutorials Appendix B: Debugging Your Shared Code From Xcode
Before reaching a breakpoint, the app halts. You’ll see a screen similar to the
one in the image. Here’s a step-by-step description of what you can do in debug
mode:
4. If you want to resume the app, you can click on this green arrow. The app will
halt again at the next breakpoint.
5. Stops the app.
363
Kotlin Multiplatform by Tutorials Appendix B: Debugging Your Shared Code From Xcode
from a third-party library. It navigates into that specific call if you have its
source code or shows you the generated stubs if you don’t. Step into would
probably skip it and halt only on your next instruction.
11. Step out from the current instruction. The rest of the code will execute, and
the debugger will halt again when the method that was suspended executes.
12. Add a new watch. With this option, you can inspect any property or run any
method that’s available on the current running scope.
13. The list of watchers. When the app halts, a list of variables that you can
analyze is immediately shown. All the watchers that you add in the previous
point will also be displayed here.
And that’s it! You can find the final project of the book in this appendix
materials. Open it in Android Studio, add a breakpoint and run the app in debug
mode. Try out the actions that you have available, follow a network request and
inspect its response — and have fun. :]
Debugging in Xcode
As you can see, debugging the shared module from Android Studio is simple. If
you want to do the same thing in Xcode, it’s more…challenging. :]
If you want to debug the iOS UI, it uses the process you’re already familiar with.
You just need to set a breakpoint, and the next time the app goes through that
instruction it will halt.
But if you want to debug the shared module, it will take you a couple more steps.
First, you’ll need to install the Kotlin Native Xcode Support plugin. You can find
it on Touchlab GitHub repository or in the materials section of this appendix.
Note: The last version of this plugin is from December 2020. Although
Touchlab is currently providing support to the newest Xcode versions, there’s
no guarantee, for now, that it will support future versions. Moreover, there are
a couple of people reporting issues with Xcode 13.1 — although at the time of
this writing, everything is working without any issue on that specific version.
./setup.sh
364
Kotlin Multiplatform by Tutorials Appendix B: Debugging Your Shared Code From Xcode
Note: According to the plugin documentation, if you’re using Xcode 11, you
need to change the path to the xcode11 directory and run instead:
./setup-xcode11.sh
The next time you open Xcode, you’ll see the following prompt:
Every time you install a third-party plugin, you’ll see a similar notification.
Since Apple didn’t release or validate it, they cannot guarantee its behavior.
Until there’s direct support on the IDE, you need to use this plugin — so click on
Load Bundle. When this process ends, open the project from materials.
Compile and install the app to guarantee that everything is working as expected.
Before running the app, don’t forget to generate the shared framework by
executing the following code:
./gradlew createSwiftPackage
Now that you have the project up and running, on the left side panel of Xcode,
below the Pods folder, right-click on the empty area and select “New Group”.
This will add a new folder to the project. Rename it to Shared.
You’re going to add the source code of the shared module that your iOS app
uses. Once again, right-click over the newly added Shared folder and select
“Add Files…”.
A new window will open. Navigate backward to the shared module, and in this
folder select the commonMain and iosMain directories. Select the option
Create folder references for any added folder to avoid copying those files to
365
Kotlin Multiplatform by Tutorials Appendix B: Debugging Your Shared Code From Xcode
the project.
The Kotlin classes now have syntax highlight, which makes it easier to read the
code. Open FeedPresenter.kt and identify the method’s visibility, strings, for
cycle, nullability, etc.
Time to test the debugging. In this file, add a breakpoint on the call to
fetchFeed inside the for cycle of fetchAllFeeds .
Note: To add a breakpoint on Xcode, you just need to click on the line number.
Here, it can have two different states: disabled if it has a transparency and
enabled in case it doesn’t. To remove a breakpoint, click on it and drag it to the
right.
366
Kotlin Multiplatform by Tutorials Appendix B: Debugging Your Shared Code From Xcode
367
Kotlin Multiplatform by Tutorials Appendix B: Debugging Your Shared Code From Xcode
6. Step over. You can go to the next instruction without needing to add another
breakpoint.
7. With the step into action, you can access the method that’s going to be
invoked.
8. Step out from the current execution. The app will continue to run until this
method returns.
9. By right-clicking in this area, you can select “Add Expression…” and the
instruction that you want to monitor will be displayed in this list.
As you can see, the Kotlin Native Xcode Support Plugin is a great tool to debug
your business logic from Xcode.
~/Library/Developer/Xcode/Plug-ins/
Remove the ones that are no longer needed. In this case, it’s the
Kotlin.ideplugin.
In the next appendix, you can learn how to reuse your UI between Android and
desktop. Now that you know how to share your business logic, see how you can
also share your Compose UI.
368
Kotlin Multiplatform by Tutorials
Throughout this book, you’ve learned how to share your business logic across
Android, iOS and desktop apps. What if you could go a step further and also
share your Compose UI?
That’s right — along with Kotlin Multiplatform, you now have Compose
Multiplatform, which allows you to share your Compose UI with Android and
desktop apps.
Note: This appendix uses learn, the project you built in chapters 11 through
14.
Starter is the final version of learn from Chapter 14, without the iOS app and its
platform-specific code. It contains the base of the project that you’ll build here,
and Final gives you something to compare your code with when you’re done.
To share your UI, you’ll need to create a new Kotlin Multiplatform module. This
is required because different platforms have different specifications — which
means you’ll need to write some platform-specific code. This is similar to what
you’ve done throughout this book.
Start by creating a new KMM library. You can easily do this by clicking the
Android Studio status bar File, followed by New and New Module.
369
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
As you can see, there’s a new shared-ui module in learn. Open the
settings.gradle.kts file to confirm that it was added to your project.
Android Studio only has direct support for KMM (Kotlin Multiplatform Mobile).
So, when you try to add a new module, and you’re targeting other platforms —
like desktop apps — you’ll need to manually add these targets.
Now, open the shared-ui build.gradle.kts and remove all the iOS references.
Starting from the beginning of this file:
1. Moving towards the kotlin section, remove all the iOS targets:
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "shared-ui"
}
}
Now that there are no more iOS references, return to the kotlin section and
under the android() target add:
jvm("desktop")
370
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
This is required — otherwise, you would only generate the shared-ui library for
Android.
Synchronize the project. After it finishes, look at the project structure. It should
be similar to this one:
When generating a KMM library, Android Studio also adds a Platform.kt file
inside all folders and a Greetings.kt inside commonMain. You can remove
these four files. They’re just placeholders, and they won’t be used in this
appendix.
Typically, the most common scenario is that you have an Android app built with
Compose that you want to port to desktop. So, you’ll start by moving the UI from
androidApp to shared-ui. In the end, you’ll remove the classes that are no
longer needed from desktopApp.
371
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
Android libraries that use the native SDK are platform-specific, so it won’t be
possible to use them on desktop apps.
shared-ui follows the same principles of the shared module that you created
before: the code needs to be written entirely in Kotlin — even its third-party
libraries.
When prompted about how the move should be done, select “Move 8 packages
to another package” and then before pressing refactor, confirm that you have
the following settings selected:
Android Studio will open a new window enumerating a couple of issues that
were found during this process. They’re related to resources and libraries that
need to be added to shared-ui. For now, don’t worry about this. Click Continue.
After this operation ends, move the components directory into shared-
ui/commonMain. It should be at the same level as the ui folder. When prompted
about how the move should be done, before pressing refactor, confirm that you
have the following settings selected:
Click Continue.
Note: Depending on the current view that you have selected for the project
structure window on the left, you might not be able to move files directly to
the right folder. To change this, select the window mode Project Files.
372
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
Looking at the androidApp source folder, there are only two classes:
MainActivity.kt and RWApplication. All the other UI classes are now in
shared-ui.
It’s time to move the resources files. Start by creating a res folder inside
shared-ui/androidMain:
1. Right-click androidMain.
2. Select New Directory.
3. When prompted, select res from the list of Gradle Source Sets.
Similar to the androidApp/res, this folder will contain all the app resources that
are platform-specific. Although you can move all the files inside res folder to
this new location, there are a couple of ones that don’t necessarily need to be
shared, since they are Android-specific:
With your code and its resources moved to a different module, you need to
import it to the androidApp. Otherwise, MainActivity won’t be able to resolve
its imports.
implementation(project(":shared-ui"))
Once done, open MainActivity.kt. All the imports should now be resolved.
Nevertheless, the view models still need to be addressed. Because they’re
Android-specific, you need to use an external library to support them when
targeting Multiplatform. You can read more about this in the “Use LiveData and
ViewModel” section of this chapter.
Compose Multiplatform
Jetpack Compose was initially introduced for Android as the new UI toolkit
where one could finally leave the XML declarations and the findViewById calls
373
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
behind and shift towards a new paradigm – declarative UI. It’s a more concise
and modern approach — decoupled from API versions, it empowers you to
create apps faster.
Note: You can learn more about Jetpack Compose for Android in Chapter 3,
Developing UI, and by reading the Jetpack Compose by Tutorials from
raywenderlich.com.
If you look at the official documentation for Jetpack Compose, you can see that,
at the time of writing, it’s composed of seven libraries:
Compose Animation
Compose Material 3
Compose Material
Compose Foundation
Compose UI
Compose Runtime
Compose Compiler
374
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
In this image, you can see that Jetpack Compose can be spliced into the:
By changing the Compose UI Toolkit, you can use Compose on other platforms.
You need to add the Compose Multiplatform plugin and its libraries to solve this.
Open the build.gradle.kts file from shared-ui and import JetBrain’s Compose.
In the plugins section, before com.android.library , add:
375
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
Now add the Compose libraries the project is using. Scroll down to
sourceSets , and inside commonMain section add:
dependencies {
api(compose.foundation)
api(compose.material)
api(compose.runtime)
api(compose.ui)
}
Fewer imports, colored red, mean the project could now resolve its Compose
dependencies.
api(project(":shared"))
api("org.jetbrains.kotlinx:kotlinx-datetime:0.3.2")
376
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
Fetching images
In the Android app, you were using Coil to fetch images. Unfortunately, it
currently doesn’t support Multiplatform, so you’ll migrate this logic to a new
one: Kamel.
Kamel uses Ktor (you can read more about this library in Chapter 12,
“Networking”) to fetch media. This API is similar to Coil, so you won’t need to
make many changes.
api(project(":kamel-image"))
Here, you’re using a local version of Kamel, since the current one doesn’t
support the latest version of Ktor.
else {
Box {
//1
when (val resource = lazyPainterResource(url)) {
//2
is Resource.Loading -> {
Logger.d(TAG, "Loading image from uri=$url")
AddImagePreviewEmpty(modifier)
}
//3
is Resource.Success -> {
Logger.d(TAG, "Loading successful image from uri=$url")
KamelImage(
resource = resource,
contentScale = ContentScale.Crop,
contentDescription = "Image preview",
modifier = modifier,
crossfade = true
)
}
377
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
//4
is Resource.Failure -> {
Logger.d(TAG, "Loading failed image from uri=$url.
Reason=${resource.exception}")
AddImagePreviewEmpty(modifier)
}
}
}
}
2. Handling the state of a request, in case it’s loading , this means that the
operation is ongoing. Visually, it will show an image placeholder that contains
the app’s logo.
With the image-fetching API migrated to Kamel, don’t forget to remove all the
Coil imports along with the OptIn annotation at the beginning of the file. Scroll
to the top of ImagePreview.kt and remove:
import androidx.compose.ui.platform.LocalContext
import coil.annotation.ExperimentalCoilApi
import coil.compose.ImagePainter
import coil.compose.rememberImagePainter
import coil.request.ImageRequest
@OptIn(ExperimentalCoilApi::class)
378
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
Since at the time of this writing, the version on GitHub is still using an older
version of Compose Multiplatform instead of importing the published library,
learn contains the source code of the project with a couple of changes — all the
plugins/libraries are now using the latest versions.
Now that you’re familiar with precompose, open the build.gradle.kts file from
the shared-ui module, and after the api(project()) includes, add:
api(project(":precompose"))
Synchronize your project. Once this operation ends, you’ll need to update your
app ViewModels. Open the BookmarkViewModel.kt file on the shared-ui
module, and remove the imports that you no longer need:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.raywenderlich.learn.ui.utils.SingleLiveEvent
import moe.tlaster.precompose.viewmodel.ViewModel
import moe.tlaster.precompose.viewmodel.viewModelScope
The LiveData class from this library is slightly different from the Android one.
Remove the _items variable, and update the items declaration to:
This also requires that you change its usages. Update onNewBookmarksList to
set the items value:
items.value = bookmarks
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
And add the ones from precompose for ViewModel() and viewModelScope :
379
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
import moe.tlaster.precompose.viewmodel.ViewModel
import moe.tlaster.precompose.viewmodel.viewModelScope
profile.value = item
import androidx.lifecycle.MutableLiveData
With:
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
With both view models updated, navigate to the androidApp and open the
MainActivity.kt file. Here, look for their declaration and update it to:
feedViewModel = viewModel {
FeedViewModel()
}
bookmarkViewModel = viewModel {
BookmarkViewModel()
}
import moe.tlaster.precompose.ui.viewModel
380
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
And move the view models fetch calls to be bellow its initialization.
import androidx.activity.viewModels
import androidx.compose.runtime.livedata.observeAsState
After both changes, remove the SingleLiveEvent class, which is inside the utils
directory. It’s Android-specific and no longer needed.
Handling navigation
The precompose library also handles Android and desktop navigation between
different screens. In case of learn, the user can change between the tabs on the
bottom navigation bar.
The desktop app already uses precompose, so there’s nothing that you need to
do there. However, Android was using its libraries, so you’ll need to make a few
changes here. Open the MainActivity.kt file inside androidApp, and replace the
class the activity extends with:
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import moe.tlaster.precompose.lifecycle.PreComposeActivity
import moe.tlaster.precompose.lifecycle.setContent
That’s it on the app side. Now, you need to navigate back to the shared-ui
module and make a few more updates.
The bottom navigation bar on Android uses the NavHost, which isn’t available
for Multiplatform. Fortunately, precompose has a similar feature called
Navigator. You’ll need to replace the current implementation that uses
NavHostController with this one.
381
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
Open the main/MainBottomBar.kt file and replace the type of the
NavHostController to Navigator . You need to make this change on
MainBottomBar and AppBottomNavigation .
if (!isSelected) {
selectedIndex.value = index
navController.navigate(screen.route)
}
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
Now that you’ve updated MainBottomBar, you’ll need to make similar changes
on MainContent.kt. Open this file, and once again replace the
NavHostController type on the different functions with Navigator .
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
And, remove:
navController.enableOnBackPressed(false)
382
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
import androidx.navigation.compose.rememberNavController
The latest release now supports both Android and JVM, so you can easily add it
to learn and share it across both platforms. Open the shared-ui
build.gradle.kts and add to commonMain/dependencies:
api("ca.gosyer:accompanist-pager:0.20.1")
api("ca.gosyer:accompanist-pager-indicators:0.20.1")
Although, you could use Google’s version in Android and Syer10 in desktop,
since you’re sharing the UI between both platforms, you need to use the same
one on both.
To give this support, both libraries build.gradle.kts files were updated with the
Android target:
plugins {
//1
kotlin("multiplatform")
id("org.jetbrains.compose") version "1.1.0"
//2
id("com.android.library")
}
kotlin {
//3
android {
publishLibraryVariants("release", "debug")
}
//4
jvm("desktop") {
testRuns["test"].executionTask.configure {
useJUnitPlatform()
}
}
//5
sourceSets {
383
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
val commonMain by getting {
dependencies {
api(compose.material)
api(compose.ui)
implementation("androidx.annotation:annotation:1.3.0")
implementation("io.github.aakira:napier:2.1.0")
}
}
//6
android {
compileSdk = 31
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifes
t.xml")
//7
sourceSets["main"].res.srcDirs("src/androidMain/res",
"src/commonMain/resources")
defaultConfig {
minSdk = 21
targetSdk = 31
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
1. Since you’re going to generate a library for more than one platform, you
need to include the multiplatform plugin.
2. Additionally, since one of these platforms is Android, you also need to import
com.android.library so you can define the configurations set on 6.
3. Previously, this accompanist version was just generating the JVM version.
Since you want it to create an Android and desktop version, you need to add
both targets under the kotlin section. Here, you’re defining that it should
generate a debug and a release builds.
4. To easily identify the desktop version, you’re setting its name inside the jvm
target.
384
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
5. Since you’re building for more than one platform, the libraries that the
project is using need to be added to the commonMain dependencies .
Although one of the libraries is from Android, since it’s not platform-
specific, you don’t need to define any other libraries on the other properties.
Handling resources
Both platforms handle resources quite differently. Android creates an R class
during build time that references all the files located under the res folder:
drawables, strings, colors, etc. Although this gives you easy access to the
application resource files, it won’t work on another platform.
It’s also worth mentioning that both platforms use different formats for images.
Android uses vector drawables, while desktop uses PNGs. With this in mind, you
will not share these resources directly. They need to be in their own platform-
specific folders.
From the Android side, you’ve already copied all the resources needed from
androidApp/res. However, for the desktop, they’re still on desktopApp.
With all the images in their correct folders, you need to create a class to
reference them. In commonMain/theme, create Icons.kt and add:
@Composable
public expect fun icBrand(): Painter
@Composable
public expect fun icLauncher(): Painter
@Composable
public expect fun icMore(): Painter
385
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
@Composable
public expect fun icHome(): Painter
@Composable
public expect fun icBookmark(): Painter
@Composable
public expect fun icLatest(): Painter
@Composable
public expect fun icSearch(): Painter
These functions represent all images the apps are currently using. With the
expect declarations done, you now need to write the actual implementations in
androidMain and desktopMain. Starting with the first one, create an Icons.kt
following the same path as the one you created on desktopMain (you’ll need to
create the theme package):
androidMain/kotlin/com/raywenderlich/shared/ui/theme/Icons.kt
And add:
@Composable
public actual fun icBrand(): Painter =
painterResource(R.drawable.ic_brand)
@Composable
public actual fun icLauncher(): Painter =
painterResource(R.mipmap.ic_launcher)
@Composable
public actual fun icMore(): Painter =
painterResource(R.drawable.ic_more)
@Composable
public actual fun icHome(): Painter =
painterResource(R.drawable.ic_home)
@Composable
public actual fun icBookmark(): Painter =
painterResource(R.drawable.ic_bookmarks)
@Composable
public actual fun icLatest(): Painter =
painterResource(R.drawable.ic_latest)
@Composable
public actual fun icSearch(): Painter =
painterResource(R.drawable.ic_search)
Each one of these functions will access the generated R class and access the
corresponding drawable or mipmap reference.
Now, you’ll need to do the same thing for desktopMain. Create the same
Icons.kt file, but this time in
386
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
desktopMain/kotlin/com/raywenderlich/shared/ui/theme/ (you’ll need to
again create the theme package).
Then, add:
@Composable
public actual fun icBrand(): Painter =
painterResource("images/razerware.png")
@Composable
public actual fun icLauncher(): Painter =
painterResource("images/ic_launcher.png")
@Composable
public actual fun icMore(): Painter =
painterResource("images/ic_more.png")
@Composable
public actual fun icHome(): Painter =
painterResource("images/ic_home.png")
@Composable
public actual fun icBookmark(): Painter =
painterResource("images/ic_bookmarks.png")
@Composable
public actual fun icLatest(): Painter =
painterResource("images/ic_latest.png")
@Composable
public actual fun icSearch(): Painter =
painterResource("images/ic_search.png")
With all the resources properly identified with their corresponding functions,
you’ll need to make quite a few updates to replace the current calls to the R
class with this new implementation.
387
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
val icon = icMore()
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.painterResource
Since it now receives a @Composable , you need to replace the objects declared
in this file:
388
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
)
import androidx.annotation.DrawableRes
And add:
import androidx.compose.material.Icon
import com.raywenderlich.learn.ui.theme.icBookmark
import com.raywenderlich.learn.ui.theme.icHome
import com.raywenderlich.learn.ui.theme.icLatest
import com.raywenderlich.learn.ui.theme.icSearch
There are still a couple of errors here that are related to the app strings. You’ll
see how to update this logic in detail in the “Sharing Strings” section of this
appendix.
screen.icon()
import androidx.compose.material.Icon
import androidx.compose.ui.res.painterResource
389
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
import androidx.compose.ui.res.painterResource
All done! A couple more sections to go, and you’ll have your app’s UI completely
shared.
Similar to what you’ve done in the previous section, you’ll need to create a
Multiplatform function to load them.
@Composable
expect fun Font(name: String, res: String, weight: FontWeight,
style: FontStyle): Font
A font can either be referenced through the R class on Android or with its path
on desktop. The res argument represents that. weight and style
correspond to its properties, and, of course, name to its name.
Open the androidMain/theme, and create the corresponding Font.kt file with
the following actual implementation:
@Composable
actual fun Font(name: String, res: String, weight: FontWeight,
style: FontStyle): Font {
val context = LocalContext.current
val id = context.resources.getIdentifier(res, "font",
context.packageName)
return Font(id, weight, style)
}
390
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
To make this function generic with the desktop app, res needs to be a string.
@Composable
actual fun Font(name: String, res: String, weight: FontWeight,
style: FontStyle): Font =
androidx.compose.ui.text.platform.Font("font/$res.ttf", weight,
style)
Finally, create the a file named Fonts.kt inside commonMain/theme and add
the object that will contain the BitterFontFamily you can use:
object Fonts {
@Composable
fun BitterFontFamily() = FontFamily(
Font(
"BitterFontFamily",
"bitter_bold",
FontWeight.Bold,
FontStyle.Normal
),
Font(
"BitterFontFamily",
"bitter_extrabold",
FontWeight.ExtraBold,
FontStyle.Normal
),
Font(
"BitterFontFamily",
"bitter_light",
FontWeight.Light,
FontStyle.Normal
),
Font(
"BitterFontFamily",
"bitter_regular",
FontWeight.Normal,
FontStyle.Normal
),
Font(
"BitterFontFamily",
"bitter_semibold",
FontWeight.SemiBold,
FontStyle.Normal
)
)
}
Once again, the Font classes that you created represent Composable
functions, and since you cannot reference them outside a Composable, you can
use these fonts directly from the Typography property that’s on Type.kt file.
391
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
Before updating all the Text Composable’s with these new typography, you’ll
need to remove the references to the R class from Type.kt. Open this file and
remove:
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import com.raywenderlich.learn.android.R
Now that there’s no more BitterFontFamily , you need to remove this call
from all the fontFamily properties. Afterward, you need to manually update
all the Text styles, since it’s not possible to reference the Fonts that you’ve
created above from Typography.
fontFamily = Fonts.BitterFontFamily(),
fontFamily = Fonts.BitterFontFamily(),
home/HomeContent: Scroll down to the end of this file, and on Text add:
fontFamily = Fonts.BitterFontFamily(),
fontFamily = Fonts.BitterFontFamily(),
fontFamily = Fonts.BitterFontFamily(),
392
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
fontFamily = Fonts.BitterFontFamily(),
fontFamily = Fonts.BitterFontFamily(),
fontFamily = Fonts.BitterFontFamily(),
Sharing strings
There’s currently no direct support for this feature. It’s true that you could
follow a similar approach to the one you’ve made for sharing local images.
However, this will be time-consuming and costly to maintain. On every new
string, you need to create three different functions (one common and two at the
platform level).
Start by opening the build.gradle.kts file located in the root directory. In the
buildscript section, inside dependencies , at the end of the list add:
classpath("dev.icerock.moko:resources-generator:0.18.0")
This will add the resources-generator to the project. Now, open the
build.gradle.kts file, but this time the one from shared-ui and add its plugin:
id("dev.icerock.mobile.multiplatform-resources")
With this, you need to set the app package name for moko-resources to use.
After the plugins declaration, add:
multiplatformResources {
multiplatformResourcesPackage = "com.raywenderlich.learn"
393
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
}
api("dev.icerock.moko:resources:0.18.0")
Click synchronize and wait for the project to load this new library.
The strings on the desktop app are currently hardcoded. This is enough for a
simple app, but if you keep adding new features that use strings, having them
located in a single file is easier to maintain. Moreover, if you want to add support
for internationalization, you’ll need to have multiple strings files so the OS can
know which one it should load.
You’ll reuse the Android strings.xml file as the shared strings across both
platforms. Create a resources folder inside shared-ui/commonMain by right-
clicking commonMain and selecting New ▸ Directory ▸ resources when
prompted.
shared-ui/build/generated/moko/
Before using any of these strings, there’s one step missing: you still need to
implement logic to access them.
Now, you need to declare the actual implementations both for Android and
desktop. Starting with the first one, go over androidMain/ui/utils and create
394
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
the corresponding Resources.kt file with:
With the implementation defined, you’ll need to go through all the classes and
update the references to R class. Instead of accessing R.string.* they will call the
getString function that you’ve just created.
text = getString(MR.strings.empty_screen_bookmarks)
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
text = getString(MR.strings.app_ray_wenderlich),
395
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
val description = getString(MR.strings.description_more)
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
After this update, the type of the text property is String, so you can remove
the call to stringResource from the Text Composable below:
text = text
At the end of the file, there’s another reference to R. Replace this call with:
text = getString(MR.strings.action_share_link),
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
396
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
call to:
AddEmptyScreen(getString(MR.strings.empty_screen_loading))
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
With that, you need to update all the objects declared in this class.
For the home object, update the stringResId and the contentDescription ,
respectively to:
title = getString(MR.strings.navigation_home),
contentDescription = getString(MR.strings.navigation_home)
title = getString(MR.strings.navigation_bookmark),
contentDescription = getString(MR.strings.navigation_bookmark)
And to latest :
title = getString(MR.strings.navigation_latest),
contentDescription = getString(MR.strings.navigation_latest)
title = getString(MR.strings.navigation_search),
397
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
contentDescription = getString(MR.strings.navigation_search)
import androidx.annotation.StringRes
import com.raywenderlich.learn.android.R
text = screen.title,
import androidx.compose.ui.res.stringResource
text = getString(MR.strings.app_name),
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
text = getString(MR.strings.search_hint),
The second one is for leadingIcon , and you need to change the description
to:
398
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
What’s missing?
With all of these changes done, you’re almost finishing. Open the desktopApp
project and:
Open the Utils.kt class and remove the SupressLint annotation which is
Android specific.
You should only keep Main.kt, which is the entry point of your app.
MainScreen(
feeds = items,
bookmarks = bookmarks,
onUpdateBookmark = { updateBookmark(it) },
onShareAsLink = {},
onOpenEntry = { openLink(it) }
)
Now, open its build.gradle.kts and include the shared-ui dependency you’ve
created throughout this appendix. To avoid having unnecessary
implementations, you can replace all the libraries in this section with:
implementation(project(":shared"))
implementation(project(":shared-ui"))
implementation(project(":shared-action"))
implementation(compose.desktop.currentOs)
Do the same for androidApp. Open its build.gradle.kts and replace the
dependencies section with:
dependencies {
implementation(project(":shared"))
implementation(project(":shared-ui"))
implementation(project(":shared-action"))
implementation("com.google.android.material:material:1.5.0")
}
399
Appendix C: Sharing Your Compose UI
Kotlin Multiplatform by Tutorials Between Android & Desktop
Synchronize your project and — finally — compile and run your desktop and
Android apps. You’ll see screens like these: