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 byGif
;NewsRepository
– should be replaced byGifRepository
;DomainFactory
– addgifRepository
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<Gif>" />
</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.