Creating a simple Kotlin Multiplatform project based on moko-template — part 2

1. Intro

This manual is the second part in GiphyApp series, before you start we would recommend to do GiphyApp #1.

The result of this lession is available on github.

2. Implement common logic of Gif list in shared library

App should get list of Gifs from GIPHY service. There is an example with getting list of news from newsapi in the project template (using moko-network with generating network entites and API classes from OpenAPI specification).

We can get OpenAPI spec of GIPHY from apis.guru and can replace getting news by getting Gif.

Feature List is already in the project template and you have not to implement any additional logic. You can see scheme of module and look into mpp-library:feature:list for detail information about it.

Replace OpenAPI spec

Replace file mpp-library/domain/src/openapi.yml by the content from OpenAPI spec of GIPHY service. After it please do Gradle Sync and as the result you will see some errors in the newsapi code. Let's update code by new API.

You can find generated files here mpp-library/domain/build/generate-resources/main/src/main/kotlin

Replace news by gifs in domain module

You have to update the following classes after replacing OpenAPI spec in domain module:

  • News should be replaced by Gif;
  • NewsRepository – should be replaced by GifRepository;
  • DomainFactory – add gifRepository and set necessary dependencies.

News -> Gif

Let’s modify News class to the following one:

@Parcelize
data class Gif(
val id: Int,
val previewUrl: String,
val sourceUrl: String
) : Parcelable

This domain entity contains gif’s id and two URL (full and preview variant). id is used for correct identifying element in a list and in UI animations.

Let’s transform network entity dev.icerock.moko.network.generated.models.Gif to domain entity. To do this add one more construct method:

@Parcelize
data class Gif(
...
) : Parcelable {
internal constructor(entity: dev.icerock.moko.network.generated.models.Gif) : this(
id = entity.url.hashCode(),
previewUrl = requireNotNull(entity.images?.downsizedMedium?.url) { "api can't respond without preview image" },
gifUrl = requireNotNull(entity.images?.original?.url) { "api can't respond without original image" }
)
}

Above there is a field mapping from network entity to domain entity — it will reduce the number of edits if API has been changed. The application doesn’t depend on API implementation.

NewsRepository -> GifRepository

Let’s change NewsRepository to GifRepository with the following content:

class GifRepository internal constructor(
private val gifsApi: GifsApi
) {
suspend fun getGifList(query: String): List<Gif> {
return gifsApi.searchGifs(
q = query,
limit = null,
offset = null,
rating = null,
lang = null
).data?.map { Gif(entity = it) }.orEmpty()
}
}

In this class you have to get GifsApi object (generated by moko-network) and call a method API searchGifs, where we use just query string, but other arguments are by default.

Network entities we have to modify in domain entities, what can be public (network entites generated with internalmodifier only).

DomainFactory

In DomainFactory we have to replace creation newsApi and newsRepository by the following code:

private val gifsApi: GifsApi by lazy {
GifsApi(
basePath = baseUrl,
httpClient = httpClient,
json = json
)
}
val gifRepository: GifRepository by lazy {
GifRepository(
gifsApi = gifsApi
)
}

GifsApi - it's a generated class, for creation you need a several parameters:

  • baseUrl – server url, it will come from factory of native layer. It needed for set up different envoiroment configuration.
  • httpClient - http client object for work with server (from ktor-client)
  • json - JSON serialization object (from kotlinx.serialization)

GifRepository is available outside of module, you can create it using gifsApi object only.

There is a lazy initialization – API and repository are Singleton objects (objects are alive while the factory is alive and the factory is created SharedFactory exists during life cycle of an application).

Also we need to send Api Key for work with GIPHY API. To do this we can use TokenFeature for ktor. It was already connected, we just have to configure it:

install(TokenFeature) {
tokenHeaderName = "api_key"
tokenProvider = object : TokenFeature.TokenProvider {
override fun getToken(): String? = "o5tAxORWRXRxxgIvRthxWnsjEbA3vkjV"
}
}

Every query comes throught httpClient will be append by header api_key: o5tAxORWRXRxxgIvRthxWnsjEbA3vkjV in this case (this is a sample app key, you can create a your one in GIPHY admin area if you are exceed the limit).

Update connection between domain and feature:list from SharedFactory

In SharedFactory we have to change interface of units list factory, NewsUnitsFactory, and replace singleton newsFactory by gifsFactory with Gif configuration.

NewsUnitsFactory -> GifsUnitsFactory

Interface of units list factory should be replaced by:

interface GifsUnitsFactory {
fun createGifTile(
id: Long,
gifUrl: String
): UnitItem
}

So, there will be id (for proper diff list calculation for UI animation ) and gifUrl (this is url for animation output) from shared code.

newsFactory -> gifsFactory

List Factory should be replaced by the following code:

val gifsFactory: ListFactory<Gif> = ListFactory(
listSource = object : ListSource<Gif> {
override suspend fun getList(): List<Gif> {
return domainFactory.gifRepository.getGifList("test")
}
},
strings = object : ListViewModel.Strings {
override val unknownError: StringResource = MR.strings.unknown_error
},
unitsFactory = object : ListViewModel.UnitsFactory<Gif> {
override fun createTile(data: Gif): UnitItem {
return gifsUnitsFactory.createGifTile(
id = data.id.toLong(),
gifUrl = data.previewUrl
)
}
}
)

In code above there is a data source listSource and we call gifRepository from domain module there. Temporary query is set up as test value, but we will change it in next lessons.
Also there is a parameter strings, localization strings, will be implemented in feature:list module (this module requires only one string "unknown error").
The last required parameter is unitsFactory, but the module works with 1 method factory, createTile(data: Gif), and for native platforms it will be better to use a specific list factory (so every UI-related field was defined from common code). That's why we use gifsUnitsFactory.createGifTile.

The last thing to do — replace SharedLibrary constructor by the following code:

class SharedFactory(
settings: Settings,
antilog: Antilog,
baseUrl: String,
gifsUnitsFactory: GifsUnitsFactory
)

So native platforms will return GifsUnitsFactory object.

3. Implement Gif list on Android

Set server URL

There is a working server URL will be passed from application layer to the common code library so we avoid rebuilding when server url had changed.

In our current configuration there is only one environment and only one server url. It set up in android-app/build.gradle.kts, let's replace it:

android {
...
defaultConfig {
...
val url = "https://api.giphy.com/v1/"
buildConfigField("String", "BASE_URL", "\"$url\"")
}
}

Dependencies Injection

We have to use glide library for gif rendering and we use constraintLayout library for setting aspect ratio 2:1 of list’s unit.

constraintLayout is already declared in project dependencies and we just need to include it on android-app, let's add it in android-app/build.gradle.kts:

dependencies {
...
implementation(Deps.Libs.Android.constraintLayout.name)
}

A glide has to be appended in dependencies injection script in buildSrc/src/main/kotlin/Versions.kt:

object Versions {
...
object Libs {
...
object Android {
...
const val glide = "4.10.0"
}
}
}

And in buildSrc/src/main/kotlin/Deps.kt:

object Deps {
...
object Libs {
...
object Android {
...
val glide = AndroidLibrary(
name = "com.github.bumptech.glide:glide:${Versions.Libs.Android.glide}"
)
}

After this we can add in android-app/build.gradle.kts the following code:

dependencies {
...
implementation(Deps.Libs.Android.glide.name)
}

SharedFactory Initialization

To create SharedFactory you have to replace newsUnitsFactory by gifsUnitsFactory. To create this dependency let's modify NewsUnitsFactory class to the following:

class GifListUnitsFactory : SharedFactory.GifsUnitsFactory {
override fun createGifTile(id: Long, gifUrl: String): UnitItem {
TODO()
}
}

And we should use it in SharedFactory:

AppComponent.factory = SharedFactory(
baseUrl = BuildConfig.BASE_URL,
settings = AndroidSettings(getSharedPreferences("app", Context.MODE_PRIVATE)),
antilog = DebugAntilog(),
gifsUnitsFactory = GifListUnitsFactory()
)

GifListUnitsFactory Implementation

SharedFactory.GifsUnitsFactory interface requires to create UnitItem from id and gifUrl variables. UnitItem is a part of moko-units and you can generate implementation from a DataBinding layout.

Let’s create android-app/src/main/res/layout/tile_gif.xml with the following content:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data> <variable
name="gifUrl"
type="String" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<ImageView
android:layout_width="match_parent"
android:layout_height="0dp"
app:gifUrl="@{gifUrl}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="2:1"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

And run Gradle Sync after this – TileGif class will be generated automatically and we will use it in GifListUnitsFactory class.

class GifListUnitsFactory : SharedFactory.GifsUnitsFactory {
override fun createGifTile(id: Long, gifUrl: String): UnitItem {
return TileGif().apply {
itemId = id
this.gifUrl = gifUrl
}
}
}

In the layout we use non-standart Binding Adapter — app:gifUrl. We should implement it. To do this let's create android-app/src/main/java/org/example/app/BindingAdapters.kt file with the following code:

package org.example.appimport android.widget.ImageView
import androidx.databinding.BindingAdapter
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.bumptech.glide.Glide
@BindingAdapter("gifUrl")
fun ImageView.bindGif(gifUrl: String?) {
if (gifUrl == null) {
this.setImageDrawable(null)
return
}
val circularProgressDrawable = CircularProgressDrawable(context).apply {
strokeWidth = 5f
centerRadius = 30f
start()
}
Glide.with(this)
.load(gifUrl)
.placeholder(circularProgressDrawable)
.error(android.R.drawable.stat_notify_error)
.into(this)
}

This allows us to set gifUrl for ImageView from layout. Moreover on loading there will be progress bar and on error it will be error icon.

Create a Gif list screen

All that’s left to do a screen showing data from our common code.
Create android-app/src/main/res/layout/activity_gif_list.xml with the content:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="org.example.library.domain.entity.Gif"/>
<import type="org.example.library.feature.list.presentation.ListViewModel" />
<variable
name="viewModel"
type="ListViewModel&lt;Gif&gt;" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:visibleOrGone="@{viewModel.state.ld.isSuccess}">
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:adapter="@{`dev.icerock.moko.units.adapter.UnitsRecyclerViewAdapter`}"
app:bindValue="@{viewModel.state.ld.dataValue}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> <ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:visibleOrGone="@{viewModel.state.ld.isLoading}" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:text="@string/no_data"
app:visibleOrGone="@{viewModel.state.ld.isEmpty}" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp"
android:orientation="vertical"
app:visibleOrGone="@{viewModel.state.ld.isError}">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@{viewModel.state.ld.errorValue}" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:onClick="@{() -> viewModel.onRetryPressed()}"
android:text="@string/retry_btn" />
</LinearLayout>
</FrameLayout>
</layout>

Layout uses Data Binding and show 1 of the 4 states got from ListViewModel. There is SwipeRefreshLayoutwith RecyclerView inside in data state, and RecyclerView uses LinearLayoutManager and UnitsRecyclerViewAdapter for rendering UnitItem objectes that got from UnitsFactory.

Let’s create android-app/src/main/java/org/example/app/view/GifListActivity.kt with the content:

class GifListActivity : MvvmActivity<ActivityGifListBinding, ListViewModel<Gif>>() {
override val layoutId: Int = R.layout.activity_gif_list
override val viewModelClass = ListViewModel::class.java as Class<ListViewModel<Gif>>
override val viewModelVariableId: Int = BR.viewModel
override fun viewModelFactory(): ViewModelProvider.Factory = createViewModelFactory {
AppComponent.factory.gifsFactory.createListViewModel()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
with(binding.refreshLayout) {
setOnRefreshListener {
viewModel.onRefresh { isRefreshing = false }
}
}
}
}

We’ve got ListViewModel<Gif> from gifsFactory factory and it will be inserted in viewModel field from activity_gif_list layout.

Also we define setOnRefreshListener in code for proper execution SwipeRefreshLayout and call viewModel.onRefresh that report in lambda when update will be finished and we can turn off the updating animation.

Replace a startup screen

Let’s set up GifListActivity as a launch screen. To do it let's add GifListActivity in android-app/src/main/AndroidManifest.xml file and remove others (we don't need it any more).

<application ...>    <activity android:name=".view.GifListActivity" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

Remove unnecessary classes

Now we can delete all unnnecessary files from project template:

  • android-app/src/main/java/org/example/app/view/ConfigActivity.kt
  • android-app/src/main/java/org/example/app/view/NewsActivity.kt
  • android-app/src/main/res/layout/activity_news.xml
  • android-app/src/main/res/layout/tile_news.xml

Run

You can run the application on Android and see list of Gifs.

4. Implement Gif list on iOS

Set server URL

As well as on Android, a working server URL will be passed from application layer to the common code library so we avoid rebuilding common library when server url had changed.
This setting can be set in ios-app/src/AppDelegate.swift file:

AppComponent.factory = SharedFactory(
...
baseUrl: "https://api.giphy.com/v1/",
...
)

Dependencies Injections

We have to use SwiftyGif for showing gif files. To include it we have to inject ios-app/Podfile dependency:

target 'ios-app' do
...
pod 'SwiftyGif', '5.1.1'
end

and after this we can run a pod install command in ios-app directory.

SharedFactory Initialization

We have to use gifsUnitsFactory instead of newsUnitsFactory to create SharedFactory. To do this let's modify NewsUnitsFactory class in following code:

class GifsListUnitsFactory: SharedFactoryGifsUnitsFactory {
func createGifTile(id: Int64, gifUrl: String) -> UnitItem {
// TODO
}
}

And will pass it in SharedFactory:

AppComponent.factory = SharedFactory(
settings: AppleSettings(delegate: UserDefaults.standard),
antilog: DebugAntilog(defaultTag: "MPP"),
baseUrl: "https://api.giphy.com/v1/",
gifsUnitsFactory: GifsListUnitsFactory()
)

GifListUnitsFactory implementation

SharedFactory.GifsUnitsFactory interface requires to create UnitItem from id and gifUrl variables. UnitItem is a part of moko-units and implementation requires to create xib with cell interface and specific cell class.

Create ios-app/src/units/GifTableViewCell.swift with the content:

import MultiPlatformLibraryUnits
import SwiftyGif
class GifTableViewCell: UITableViewCell, Fillable {
typealias DataType = CellModel

struct CellModel {
let id: Int64
let gifUrl: String
}

@IBOutlet private var gifImageView: UIImageView!

private var gifDownloadTask: URLSessionDataTask?

override func prepareForReuse() {
super.prepareForReuse()

gifDownloadTask?.cancel()
gifImageView.clear()
}

func fill(_ data: GifTableViewCell.CellModel) {
gifDownloadTask = gifImageView.setGifFromURL(URL(string: data.gifUrl)!)
}

func update(_ data: GifTableViewCell.CellModel) {

}
}
extension GifTableViewCell: Reusable {
static func reusableIdentifier() -> String {
return "GifTableViewCell"
}

static func xibName() -> String {
return "GifTableViewCell"
}

static func bundle() -> Bundle {
return Bundle.main
}
}

Then create ios-app/src/units/GifTableViewCell.xib with a cell layout.

The result looks like this:

We have to set GifTableViewCell class in UITableViewCell cell:

And set an identifier for reuse:

Now we can implement UnitItem creation in GifListUnitsFactory:

class GifsListUnitsFactory: SharedFactoryGifsUnitsFactory {
func createGifTile(id: Int64, gifUrl: String) -> UnitItem {
return UITableViewCellUnit<GifTableViewCell>(
data: GifTableViewCell.CellModel(
id: id,
gifUrl: gifUrl
),
configurator: nil
)
}
}

Create a Gif list screen

All that’s left to do a screen showing data from our common code.

Create ios-app/src/view/GifListViewController.swift with the content:

import MultiPlatformLibraryMvvm
import MultiPlatformLibraryUnits
class GifListViewController: UIViewController {
@IBOutlet private var tableView: UITableView!
@IBOutlet private var activityIndicator: UIActivityIndicatorView!
@IBOutlet private var emptyView: UIView!
@IBOutlet private var errorView: UIView!
@IBOutlet private var errorLabel: UILabel!

private var viewModel: ListViewModel<Gif>!
private var dataSource: FlatUnitTableViewDataSource!
private var refreshControl: UIRefreshControl!

override func viewDidLoad() {
super.viewDidLoad()

viewModel = AppComponent.factory.gifsFactory.createListViewModel()
// binding methods from https://github.com/icerockdev/moko-mvvm
activityIndicator.bindVisibility(liveData: viewModel.state.isLoadingState())
tableView.bindVisibility(liveData: viewModel.state.isSuccessState())
emptyView.bindVisibility(liveData: viewModel.state.isEmptyState())
errorView.bindVisibility(liveData: viewModel.state.isErrorState())
// in/out generics of Kotlin removed in swift, so we should map to valid class
let errorText: LiveData<StringDesc> = viewModel.state.error().map { $0 as? StringDesc } as! LiveData<StringDesc>
errorLabel.bindText(liveData: errorText)
// datasource from https://github.com/icerockdev/moko-units
dataSource = FlatUnitTableViewDataSource()
dataSource.setup(for: tableView)
// manual bind to livedata, see https://github.com/icerockdev/moko-mvvm
viewModel.state.data().addObserver { [weak self] itemsObject in
guard let items = itemsObject as? [UITableViewCellUnitProtocol] else { return }

self?.dataSource.units = items
self?.tableView.reloadData()
}

refreshControl = UIRefreshControl()
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(onRefresh), for: .valueChanged)
}

@IBAction func onRetryPressed() {
viewModel.onRetryPressed()
}

@objc func onRefresh() {
viewModel.onRefresh { [weak self] in
self?.refreshControl.endRefreshing()
}
}
}

And let’s bind NewsViewController to GifListViewController in MainStoryboard:

Replace a startup screen

To launch the application from gif screen we have to link rootViewController with GifListViewController in Navigation Controller:

Remove unnecessary files

Now we can delete all unnnecessary files from project:

  • ios-app/src/units/NewsTableViewCell.swift
  • ios-app/src/units/NewsTableViewCell.xib
  • ios-app/src/view/ConfigViewController.swift
  • ios-app/src/view/NewsViewController.swift

Run

Now you can run the application on iOS and see a list of Gif.

--

--

--

A boundlessly imaginative mobile development team from the depths of Siberia and its capital, the city of Novosibirsk. Let’s meet on www.icerockdev.com

Recommended from Medium

What is MicroService?

How to start coding? No. Wait! Why start coding?

Build an IoT based HOME Automation/cooling system using NodeMCU

InsureDAO testnet

English to English Dictionary in Python Using JSON

Deploying Smart Contract in Hyperledger-V2.0

Building an Uber Like Cab Service

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
IceRock Development

IceRock Development

A boundlessly imaginative mobile development team from Erevan. Let’s meet on www.icerockdev.com

More from Medium

Kotlin Multiplatform: A Shopping List for Android & iOS including Flow

Overview of Kotlin Multiplatform Mobile

Kotlin Multiplatform Mobile: expect-actual Concept

Going Modular — The Kotlin Multiplatform Way

Managing the UI State by using a Finite State Machine and MVI architecture