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

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 internal modifier 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

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.app

import 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 SwipeRefreshLayout with 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.