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

1. Intro

2. Implement common logic of Gif list in shared library

Replace OpenAPI spec

Replace news by gifs in domain module

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

News -> Gif

@Parcelize
data class Gif(
val id: Int,
val previewUrl: String,
val sourceUrl: String
) : Parcelable
@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" }
)
}

NewsRepository -> GifRepository

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()
}
}

DomainFactory

private val gifsApi: GifsApi by lazy {
GifsApi(
basePath = baseUrl,
httpClient = httpClient,
json = json
)
}
val gifRepository: GifRepository by lazy {
GifRepository(
gifsApi = gifsApi
)
}
  • 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)
install(TokenFeature) {
tokenHeaderName = "api_key"
tokenProvider = object : TokenFeature.TokenProvider {
override fun getToken(): String? = "o5tAxORWRXRxxgIvRthxWnsjEbA3vkjV"
}
}

Update connection between domain and feature:list from SharedFactory

NewsUnitsFactory -> GifsUnitsFactory

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

newsFactory -> gifsFactory

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
)
}
}
)
class SharedFactory(
settings: Settings,
antilog: Antilog,
baseUrl: String,
gifsUnitsFactory: GifsUnitsFactory
)

3. Implement Gif list on Android

Set server URL

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

Dependencies Injection

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

SharedFactory Initialization

class GifListUnitsFactory : SharedFactory.GifsUnitsFactory {
override fun createGifTile(id: Long, gifUrl: String): UnitItem {
TODO()
}
}
AppComponent.factory = SharedFactory(
baseUrl = BuildConfig.BASE_URL,
settings = AndroidSettings(getSharedPreferences("app", Context.MODE_PRIVATE)),
antilog = DebugAntilog(),
gifsUnitsFactory = GifListUnitsFactory()
)

GifListUnitsFactory Implementation

<?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>
class GifListUnitsFactory : SharedFactory.GifsUnitsFactory {
override fun createGifTile(id: Long, gifUrl: String): UnitItem {
return TileGif().apply {
itemId = id
this.gifUrl = gifUrl
}
}
}
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)
}

Create a Gif list screen

<?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>
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 }
}
}
}
}

Replace a startup screen

<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

  • 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

4. Implement Gif list on iOS

Set server URL

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

Dependencies Injections

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

SharedFactory Initialization

class GifsListUnitsFactory: SharedFactoryGifsUnitsFactory {
func createGifTile(id: Int64, gifUrl: String) -> UnitItem {
// TODO
}
}
AppComponent.factory = SharedFactory(
settings: AppleSettings(delegate: UserDefaults.standard),
antilog: DebugAntilog(defaultTag: "MPP"),
baseUrl: "https://api.giphy.com/v1/",
gifsUnitsFactory: GifsListUnitsFactory()
)

GifListUnitsFactory implementation

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
}
}
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

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()
}
}
}

Replace a startup screen

Remove unnecessary files

  • 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

--

--

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