├── .gitignore
├── .vscode
└── settings.json
├── README.md
├── androidApp
├── build.gradle.kts
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── ru
│ │ └── alex009
│ │ └── moko
│ │ └── mvvm
│ │ └── declarativeui
│ │ └── android
│ │ ├── LoginScreen.kt
│ │ └── MainActivity.kt
│ └── res
│ └── values
│ ├── colors.xml
│ └── styles.xml
├── build.gradle.kts
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── how-to-use-kmm-mvvm-swiftui-en.md
├── how-to-use-kmm-mvvm-swiftui-ru.md
├── iosApp
├── Podfile
├── Podfile.lock
├── iosApp.xcodeproj
│ └── project.pbxproj
├── iosApp.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
└── iosApp
│ ├── Assets.xcassets
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ └── Contents.json
│ └── Contents.json
│ ├── Info.plist
│ ├── LoginScreen.swift
│ ├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
│ └── iOSApp.swift
├── media
├── android-compose-mvvm.gif
├── ios-swiftui-mvvm.gif
└── wizard-cocoapods-integration.png
├── settings.gradle.kts
└── shared
├── build.gradle.kts
├── shared.podspec
└── src
├── androidMain
└── AndroidManifest.xml
└── commonMain
└── kotlin
└── ru
└── alex009
└── moko
└── mvvm
└── declarativeui
└── LoginViewModel.kt
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | .idea
5 | .DS_Store
6 | /build
7 | */build
8 | /captures
9 | .externalNativeBuild
10 | .cxx
11 | local.properties
12 | Pods/
13 | *.xcuserdatad/
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": [
3 | "androidx",
4 | "appcompat",
5 | "cocoapods",
6 | "icerock",
7 | "jetbrains",
8 | "Jetpack",
9 | "kotlinx",
10 | "kswift",
11 | "livedata",
12 | "moko",
13 | "Multiplatform",
14 | "MVVM",
15 | "podspec",
16 | "struct",
17 | "swiftui",
18 | "viewmodel",
19 | "стейта"
20 | ]
21 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # moko-mvvm-compose-swiftui
2 |
3 | Sample repository for posts:
4 | - 🇺🇸 [How to use Kotlin Multiplatform ViewModel in SwiftUI and Jetpack Compose](https://medium.com/icerock/how-to-use-kotlin-multiplatform-viewmodel-in-swiftui-and-jetpack-compose-8158e98c091d);
5 | - 🇷🇺 [Как использовать Kotlin Multiplatform ViewModel в SwiftUI и Jetpack Compose](https://habr.com/ru/post/663824/);
6 |
7 | | Android | iOS |
8 | | ---- | ---- |
9 | |  |  |
10 |
--------------------------------------------------------------------------------
/androidApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | kotlin("android")
4 | }
5 |
6 | val composeVersion = "1.1.1"
7 |
8 | android {
9 | compileSdk = 32
10 | defaultConfig {
11 | applicationId = "ru.alex009.moko.mvvm.declarativeui.android"
12 | minSdk = 21
13 | targetSdk = 32
14 | versionCode = 1
15 | versionName = "1.0"
16 | }
17 | buildTypes {
18 | getByName("release") {
19 | isMinifyEnabled = false
20 | }
21 | }
22 |
23 | buildFeatures {
24 | compose = true
25 | }
26 | composeOptions {
27 | kotlinCompilerExtensionVersion = composeVersion
28 | }
29 | }
30 |
31 | dependencies {
32 | implementation(project(":shared"))
33 |
34 | implementation("androidx.compose.foundation:foundation:$composeVersion")
35 | implementation("androidx.compose.runtime:runtime:$composeVersion")
36 | // UI
37 | implementation("androidx.compose.ui:ui:$composeVersion")
38 | implementation("androidx.compose.ui:ui-tooling:$composeVersion")
39 | // Material Design
40 | implementation("androidx.compose.material:material:$composeVersion")
41 | implementation("androidx.compose.material:material-icons-core:$composeVersion")
42 | // Activity
43 | implementation("androidx.activity:activity-compose:1.4.0")
44 | implementation("androidx.appcompat:appcompat:1.4.1")
45 | // viewmodel
46 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1")
47 | }
--------------------------------------------------------------------------------
/androidApp/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
9 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/ru/alex009/moko/mvvm/declarativeui/android/LoginScreen.kt:
--------------------------------------------------------------------------------
1 | package ru.alex009.moko.mvvm.declarativeui.android
2 |
3 | import android.content.Context
4 | import android.widget.Toast
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.height
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.size
11 | import androidx.compose.material.Button
12 | import androidx.compose.material.CircularProgressIndicator
13 | import androidx.compose.material.Text
14 | import androidx.compose.material.TextField
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.runtime.collectAsState
17 | import androidx.compose.runtime.getValue
18 | import androidx.compose.ui.Alignment
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.platform.LocalContext
21 | import androidx.compose.ui.text.input.PasswordVisualTransformation
22 | import androidx.compose.ui.tooling.preview.Preview
23 | import androidx.compose.ui.unit.dp
24 | import androidx.lifecycle.viewmodel.compose.viewModel
25 | import dev.icerock.moko.mvvm.flow.compose.observeAsActions
26 | import ru.alex009.moko.mvvm.declarativeui.LoginViewModel
27 |
28 | @Composable
29 | fun LoginScreen(
30 | viewModel: LoginViewModel = viewModel()
31 | ) {
32 | val context: Context = LocalContext.current
33 |
34 | val login: String by viewModel.login.collectAsState()
35 | val password: String by viewModel.password.collectAsState()
36 | val isLoading: Boolean by viewModel.isLoading.collectAsState()
37 | val isLoginButtonEnabled: Boolean by viewModel.isButtonEnabled.collectAsState()
38 |
39 | viewModel.actions.observeAsActions { action ->
40 | when (action) {
41 | LoginViewModel.Action.LoginSuccess ->
42 | Toast.makeText(context, "login success!", Toast.LENGTH_SHORT).show()
43 | }
44 | }
45 |
46 | Column(
47 | modifier = Modifier.padding(16.dp),
48 | horizontalAlignment = Alignment.CenterHorizontally
49 | ) {
50 | TextField(
51 | modifier = Modifier.fillMaxWidth(),
52 | value = login,
53 | enabled = !isLoading,
54 | label = { Text(text = "Login") },
55 | onValueChange = { viewModel.login.value = it }
56 | )
57 | Spacer(modifier = Modifier.height(8.dp))
58 | TextField(
59 | modifier = Modifier.fillMaxWidth(),
60 | value = password,
61 | enabled = !isLoading,
62 | label = { Text(text = "Password") },
63 | visualTransformation = PasswordVisualTransformation(),
64 | onValueChange = { viewModel.password.value = it }
65 | )
66 | Spacer(modifier = Modifier.height(8.dp))
67 | Button(
68 | modifier = Modifier
69 | .fillMaxWidth()
70 | .height(48.dp),
71 | enabled = isLoginButtonEnabled,
72 | onClick = viewModel::onLoginPressed
73 | ) {
74 | if (isLoading) CircularProgressIndicator(modifier = Modifier.size(24.dp))
75 | else Text(text = "Login")
76 | }
77 | }
78 | }
79 |
80 | @Preview(showSystemUi = true)
81 | @Composable
82 | private fun LoginScreenPreview() {
83 | LoginScreen()
84 | }
85 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/ru/alex009/moko/mvvm/declarativeui/android/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package ru.alex009.moko.mvvm.declarativeui.android
2 |
3 | import android.os.Bundle
4 | import androidx.activity.compose.setContent
5 | import androidx.appcompat.app.AppCompatActivity
6 | import androidx.compose.material.MaterialTheme
7 |
8 | class MainActivity : AppCompatActivity() {
9 | override fun onCreate(savedInstanceState: Bundle?) {
10 | super.onCreate(savedInstanceState)
11 |
12 | setContent {
13 | MaterialTheme {
14 | LoginScreen()
15 | }
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/androidApp/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #6200EE
4 | #3700B3
5 | #03DAC5
6 |
--------------------------------------------------------------------------------
/androidApp/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | buildscript {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | mavenCentral()
6 | }
7 | dependencies {
8 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.21")
9 | classpath("com.android.tools.build:gradle:7.1.3")
10 | classpath("dev.icerock.moko:kswift-gradle-plugin:0.6.1")
11 | }
12 | }
13 |
14 | allprojects {
15 | repositories {
16 | google()
17 | mavenCentral()
18 | }
19 | }
20 |
21 | tasks.register("clean", Delete::class) {
22 | delete(rootProject.buildDir)
23 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | #Gradle
2 | org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
3 |
4 | #Kotlin
5 | kotlin.code.style=official
6 |
7 | #Android
8 | android.useAndroidX=true
9 |
10 | #MPP
11 | kotlin.mpp.enableGranularSourceSetsMetadata=true
12 | kotlin.native.enableDependencyPropagation=false
13 | kotlin.mpp.enableCInteropCommonization=true
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Alex009/moko-mvvm-compose-swiftui/df9e5a601cbc514b7a14d049e27babfe1a697dc5/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat Apr 30 13:43:34 NOVT 2022
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/how-to-use-kmm-mvvm-swiftui-en.md:
--------------------------------------------------------------------------------
1 | # How to use Kotlin Multiplatform ViewModel in SwiftUI and Jetpack Compose
2 |
3 | We at [IceRock Development](https://icerock.dev) have been using the MVVM approach for many years, and the last
4 | 4 years our `ViewModel` are shared in the common code. We do it by using our library
5 | [moko-mvvm](https://github.com/icerockdev/moko-mvvm). In the last year, we have been actively moving to
6 | using Jetpack Compose and SwiftUI to build UI in our projects. And it require
7 | MOKO MVVM improvements to make it more comfortable for developers on both platforms to work with this approach.
8 |
9 | On April 30, 2022, [new version of MOKO MVVM - 0.13.0](https://github.com/icerockdev/moko-mvvm/releases/tag/release%2F0.13.0) was released.
10 | This version has full support for Jetpack Compose and SwiftUI. Let's take an example of how
11 | you can use ViewModel from common code with these frameworks.
12 |
13 | The example will be simple - an application with an authorization screen. Two input fields - login and password, button
14 | Log in and a message about a successful login after a second of waiting (while waiting, we turn the progress bar).
15 |
16 | ## Create a project
17 |
18 | The first step is simple - take Android Studio, install
19 | [Kotlin Multiplatform Mobile IDE plugin](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform-mobile),
20 | if not already installed. Create a project according to the template "Kotlin Multiplatform App" using
21 | `CocoaPods integration` (it’s more convenient with them, plus we need it to connect an additional CocoaPod later).
22 |
23 | 
24 |
25 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/ee223a80e17616e622d135c0651ab454eabfad7a)
26 |
27 | ## Login screen on Android with Jetpack Compose
28 |
29 | The app template uses the standard Android View approach, so we need to enable
30 | Jetpack Compose before implementation of UI.
31 |
32 | Enable Compose support in `androidApp/build.gradle.kts`:
33 | ```kotlin
34 | val composeVersion = "1.1.1"
35 |
36 | android {
37 | // ...
38 |
39 | buildFeatures {
40 | compose=true
41 | }
42 | composeOptions {
43 | kotlinCompilerExtensionVersion = composeVersion
44 | }
45 | }
46 | ```
47 |
48 | And we add the dependencies we need, removing the old unnecessary ones (related to the usual approach with view):
49 | ```kotlin
50 | dependencies {
51 | implementation(project(":shared"))
52 |
53 | implementation("androidx.compose.foundation:foundation:$composeVersion")
54 | implementation("androidx.compose.runtime:runtime:$composeVersion")
55 | // UI
56 | implementation("androidx.compose.ui:ui:$composeVersion")
57 | implementation("androidx.compose.ui:ui-tooling:$composeVersion")
58 | // material design
59 | implementation("androidx.compose.material:material:$composeVersion")
60 | implementation("androidx.compose.material:material-icons-core:$composeVersion")
61 | // Activity
62 | implementation("androidx.activity:activity-compose:1.4.0")
63 | implementation("androidx.appcompat:appcompat:1.4.1")
64 | }
65 | ```
66 |
67 | When running Gradle Sync, we get a message about the version incompatibility between Jetpack Compose and Kotlin.
68 | This is due to the fact that Compose uses a compiler plugin for Kotlin, and the compiler plugin APIs are not yet stabilized. Therefore, we need to install the version of Kotlin that supports
69 | the Compose version we are using is `1.6.10`.
70 |
71 | Next, it remains to implement the authorization screen, I immediately give the finished code:
72 | ```kotlin
73 | @Composable
74 | fun LoginScreen() {
75 | val context: Context = LocalContext.current
76 | val coroutineScope: CoroutineScope = rememberCoroutineScope()
77 |
78 | var login: String by remember { mutableStateOf("") }
79 | var password: String by remember { mutableStateOf("") }
80 | var isLoading: Boolean by remember { mutableStateOf(false) }
81 |
82 | val isLoginButtonEnabled: Boolean = login.isNotBlank() && password.isNotBlank() && !isLoading
83 |
84 | Column(
85 | modifier = Modifier.padding(16.dp),
86 | horizontalAlignment = Alignment.CenterHorizontally
87 | ) {
88 | TextField(
89 | modifier = Modifier.fillMaxWidth(),
90 | value = login,
91 | enabled = !isLoading,
92 | label = { Text(text = "Login") },
93 | onValueChange = { login = it }
94 | )
95 | Spacer(modifier = Modifier.height(8.dp))
96 | TextField(
97 | modifier = Modifier.fillMaxWidth(),
98 | value = password,
99 | enabled = !isLoading,
100 | label = { Text(text = "Password") },
101 | visualTransformation = PasswordVisualTransformation(),
102 | onValueChange = { password = it }
103 | )
104 | Spacer(modifier = Modifier.height(8.dp))
105 | Button(
106 | modifier = Modifier
107 | .fillMaxWidth()
108 | .height(48.dp),
109 | enabled = isLoginButtonEnabled,
110 | onClick = {
111 | coroutineScope.launch {
112 | isLoading = true
113 | delay(1000)
114 | isLoading = false
115 | Toast.makeText(context, "login success!", Toast.LENGTH_SHORT).show()
116 | }
117 | }
118 | ) {
119 | if (isLoading) CircularProgressIndicator(modifier = Modifier.size
120 | (24.dp))
121 | else Text(text = "login")
122 | }
123 | }
124 | }
125 | ```
126 |
127 | And here is our android app with authorization screen ready and functioning as required, but without
128 | common code.
129 |
130 | 
131 |
132 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/69cf1904cd16f34b5bc646cdcacda3b72c8b58cf)
133 |
134 | ## Authorization screen in iOS with SwiftUI
135 |
136 | Let's make the same screen in SwiftUI. The template has already created a SwiftUI app, so it's easy enough for us to
137 | write screen code. We get the following code:
138 |
139 | ```swift
140 | struct LoginScreen: View {
141 | @State private var login: String = ""
142 | @State private var password: String = ""
143 | @State private var isLoading: Bool = false
144 | @State private var isSuccessfulAlertShowed: Bool = false
145 |
146 | private var isButtonEnabled: Bool {
147 | get {
148 | !isLoading && !login.isEmpty && !password.isEmpty
149 | }
150 | }
151 |
152 | var body: someView {
153 | Group {
154 | VStack(spacing: 8.0) {
155 | TextField("Login", text: $login)
156 | .textFieldStyle(.roundedBorder)
157 | .disabled(isLoading)
158 |
159 | SecureField("Password", text: $password)
160 | .textFieldStyle(.roundedBorder)
161 | .disabled(isLoading)
162 |
163 | Button(
164 | action: {
165 | isLoading = true
166 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
167 | isLoading = false
168 | isSuccessfulAlertShowed = true
169 | }
170 | }, label: {
171 | if isLoading {
172 | ProgressView()
173 | } else {
174 | Text("login")
175 | }
176 | }
177 | ).disabled(!isButtonEnabled)
178 | }.padding()
179 | }.alert(
180 | "Login successful",
181 | isPresented: $isSuccessfulAlertShowed
182 | ) {
183 | Button("Close", action: { isSuccessfulAlertShowed = false })
184 | }
185 | }
186 | }
187 | ```
188 |
189 | The logic of work is completely identical to the Android version and also does not use any common logic.
190 |
191 | 
192 |
193 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/760622ab392b1e723e4bb508d8f5c8b97b9ca5a7)
194 |
195 | ## Implement a common ViewModel
196 |
197 | All preparatory steps are completed. It's time to move the authorization screen logic out of the platforms in
198 | common code.
199 |
200 | The first thing we will do for this is to connect the moko-mvvm dependency to the common module and add it to
201 | export list for iOS framework (so that in Swift we can see all public classes and methods of this
202 | libraries).
203 |
204 | ```kotlin
205 | val mokoMvvmVersion = "0.13.0"
206 |
207 | kotlin {
208 | // ...
209 |
210 | cocoapods {
211 | // ...
212 |
213 | framework {
214 | baseName = "MultiPlatformLibrary"
215 |
216 | export("dev.icerock.moko:mvvm-core:$mokoMvvmVersion")
217 | export("dev.icerock.moko:mvvm-flow:$mokoMvvmVersion")
218 | }
219 | }
220 |
221 | sourceSets {
222 | val commonMain by getting {
223 | dependencies {
224 | api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1-native-mt")
225 |
226 | api("dev.icerock.moko:mvvm-core:$mokoMvvmVersion")
227 | api("dev.icerock.moko:mvvm-flow:$mokoMvvmVersion")
228 | }
229 | }
230 | // ...
231 | val androidMain by getting {
232 | dependencies {
233 | api("dev.icerock.moko:mvvm-flow-compose:$mokoMvvmVersion")
234 | }
235 | }
236 | // ...
237 | }
238 | }
239 | ```
240 |
241 | We also changed the `baseName` of the iOS Framework to `MultiPlatformLibrary`. This is an important change,
242 | which we will not be able to connect CocoaPod with Kotlin and SwiftUI integration functions in the future.
243 |
244 | It remains to write the `LoginViewModel` itself. Here is the code:
245 | ```kotlin
246 | class LoginViewModel : ViewModel() {
247 | val login: MutableStateFlow = MutableStateFlow("")
248 | val password: MutableStateFlow = MutableStateFlow("")
249 |
250 | private val _isLoading: MutableStateFlow = MutableStateFlow(false)
251 | val isLoading: StateFlow = _isLoading
252 |
253 | val isButtonEnabled: StateFlow =
254 | combine(isLoading, login, password) { isLoading, login, password ->
255 | isLoading.not() && login.isNotBlank() && password.isNotBlank()
256 | }.stateIn(viewModelScope, SharingStarted.Eagerly, false)
257 |
258 | private val _actions = Channel()
259 | val actions: Flow get() = _actions.receiveAsFlow()
260 |
261 | fun onLoginPressed() {
262 | _isLoading.value = true
263 | viewModelScope.launch {
264 | delay(1000)
265 | _isLoading.value = false
266 | _actions.send(Action.LoginSuccess)
267 | }
268 | }
269 |
270 | sealed interface Action {
271 | object LoginSuccess : Action
272 | }
273 | }
274 | ```
275 |
276 | For input fields that can be changed by the user, we used `MutableStateFlow` from
277 | kotlinx-coroutines (but you can also use `MutableLiveData` from `moko-mvvm-livedata`).
278 | For properties that the UI should keep track of but should not change - use `StateFlow`.
279 | And to notify about the need to do something (show a success message or to go
280 | to another screen) we have created a `Channel` which is exposed on the UI as a `Flow`. All available actions
281 | we combine under a single `sealed interface Action` so that it is known exactly what actions can
282 | tell the given `ViewModel`.
283 |
284 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/d628fb60fedeeb0d259508aa09d3a98ebbc9651c)
285 |
286 | ## Connect the common ViewModel to Android
287 |
288 | On Android, to get our `ViewModel` from `ViewModelStorage` (so that when the screen rotates we
289 | received the same ViewModel) we need to include a special dependency in
290 | `androidApp/build.gradle.kts`:
291 |
292 | ```kotlin
293 | dependencies {
294 | // ...
295 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1")
296 | }
297 | ```
298 |
299 | Next, add `LoginViewModel` to our screen arguments:
300 | ```kotlin
301 | @Composable
302 | fun LoginScreen(
303 | viewModel: LoginViewModel = viewModel()
304 | )
305 | ```
306 |
307 | Let's replace the local state of the screen with getting the state from the `LoginViewModel`:
308 | ```kotlin
309 | val login: String by viewModel.login.collectAsState()
310 | val password: String by viewModel.password.collectAsState()
311 | val isLoading: Boolean by viewModel.isLoading.collectAsState()
312 | val isLoginButtonEnabled: Boolean by viewModel.isButtonEnabled.collectAsState()
313 | ```
314 |
315 | Subscribe to receive actions from the ViewModel using `observeAsAction` from moko-mvvm:
316 | ```kotlin
317 | viewModel.actions.observeAsActions { action ->
318 | when (action) {
319 | LoginViewModel.Action.LoginSuccess ->
320 | Toast.makeText(context, "login success!", Toast.LENGTH_SHORT).show()
321 | }
322 | }
323 | ```
324 |
325 | Let's change the input handler of `TextField`s from local state to writing to `ViewModel`:
326 | ```kotlin
327 | TextField(
328 | // ...
329 | onValueChange = { viewModel.login.value = it }
330 | )
331 | ```
332 |
333 | And call the button click handler:
334 | ```kotlin
335 | Button(
336 | // ...
337 | onClick = viewModel::onLoginPressed
338 | ) {
339 | // ...
340 | }
341 | ```
342 |
343 | We run the application and see that everything works exactly the same as it worked before the common code, but now
344 | all screen logic is controlled by a common ViewModel.
345 |
346 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/a93b9a3b6f1e413bebbba3a30bc5a198ebbf4e84)
347 |
348 | ## Connect the shared ViewModel to iOS
349 |
350 | To connect `LoginViewModel` to SwiftUI, we need Swift add-ons from MOKO MVVM.
351 | They connect via CocoaPods:
352 |
353 | ```ruby
354 | pod 'mokoMvvmFlowSwiftUI', :podspec => 'https://raw.githubusercontent.com/icerockdev/moko-mvvm/release/0.13.0/mokoMvvmFlowSwiftUI.podspec'
355 | ```
356 |
357 | And also, in the `LoginViewModel` itself, you need to make changes - from the side of Swift `MutableStateFlow`,
358 | `StateFlow`, `Flow` will lose their generic type since they are interfaces. So that the generic is not lost
359 | you need to use classes. MOKO MVVM provides special `CMutableStateFlow`,
360 | `CStateFlow` and `CFlow` classes to store the generic type in iOS. We bring the types with the following change:
361 |
362 | ```kotlin
363 | class LoginViewModel : ViewModel() {
364 | val login: CMutableStateFlow = MutableStateFlow("").cMutableStateFlow()
365 | val password: CMutableStateFlow = MutableStateFlow("").cMutableStateFlow()
366 |
367 | // ...
368 | val isLoading: CStateFlow = _isLoading.cStateFlow()
369 |
370 | val isButtonEnabled: CStateFlow =
371 | // ...
372 | .cStateFlow()
373 |
374 | // ...
375 | val actions: CFlow get() = _actions.receiveAsFlow().cFlow()
376 |
377 | // ...
378 | }
379 | ```
380 |
381 | Now we can move on to the Swift code. To integrate, we make the following change:
382 |
383 | ```swift
384 | import MultiPlatformLibrary
385 | import mokoMvvmFlowSwiftUI
386 | import Combine
387 |
388 | struct LoginScreen: View {
389 | @ObservedObject var viewModel: LoginViewModel = LoginViewModel()
390 | @State private var isSuccessfulAlertShowed: Bool = false
391 |
392 | // ...
393 | }
394 | ```
395 |
396 | We add `viewModel` to `View` as `@ObservedObject`, just like we do with Swift versions
397 | ViewModel, but in this case, due to the use of `mokoMvvmFlowSwiftUI` we can immediately pass
398 | Kotlin class `LoginViewModel`.
399 |
400 | Next, change the binding of the fields:
401 | ```swift
402 | TextField("Login", text: viewModel.binding(\.login))
403 | .textFieldStyle(.roundedBorder)
404 | .disabled(viewModel.state(\.isLoading))
405 | ```
406 |
407 | `mokoMvvmFlowSwiftUI` provides special extension functions to `ViewModel`:
408 | - `binding` returns a `Binding` structure, for the possibility of changing data from the UI side
409 | - `state` returns a value that will be automatically updated when `StateFlow` returns
410 | new data
411 |
412 | Similarly, we replace other places where the local state is used and subscribe to
413 | actions:
414 | ```swift
415 | .onReceive(createPublisher(viewModel.actions)) { action in
416 | let actionKs = LoginViewModelActionKs(action)
417 | switch(actionKs) {
418 | case .loginSuccess:
419 | isSuccessfulAlertShowed = true
420 | break
421 | }
422 | }
423 | ```
424 |
425 | The `createPublisher` function is also provided from `mokoMvvmFlowSwiftUI` and allows you to transform
426 | `CFlow` in `AnyPublisher` from Combine. For reliable processing actions we use
427 | [moko-kswift](https://github.com/icerockdev/moko-kswift). This is a gradle plugin that automatically
428 | generates swift code based on Kotlin. In this case, Swift was generated
429 | `enum LoginViewModelActionKs` from `sealed interface LoginViewModel.Action`. Using automatically
430 | generated `enum` we get a guarantee that the cases in `enum` and `sealed interface` match, so
431 | now we can rely on exhaustive switch logic.
432 | You can read more about MOKO KSwift [in the article](https://medium.com/icerock/how-to-implement-swift-friendly-api-with-kotlin-multiplatform-mobile-e68521a63b6d).
433 |
434 | As a result, we got a SwiftUI screen that is controlled from a common code using the MVVM approach.
435 |
436 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/5e260fbf9e4957c6fa5d1679a4282691d37da96a)
437 |
438 | ## Conclusion
439 |
440 | In development with Kotlin Multiplatform Mobile, we consider it important to strive to provide a convenient
441 | toolkit for both platforms - both Android and iOS developers should comfortably develop
442 | and the use of any approach in the common code should not force the developers of one of the platforms
443 | do extra work. By developing our [MOKO](https://moko.icerock.dev) libraries and tools, we strive to simplify the work of developers for both Android and iOS. SwiftUI and MOKO MVVM integration
444 | required a lot of experimentation, but the final result looks comfortable to use.
445 |
446 | You can try the project created in this article yourself,
447 | [on GitHub](https://github.com/Alex009/moko-mvvm-compose-swiftui).
448 |
449 | We can also [help](https://icerockdev.com/directions/pages/kmm/) and development teams,
450 | who need development assistance or advice on the topic of Kotlin Multiplatform Mobile.
451 |
--------------------------------------------------------------------------------
/how-to-use-kmm-mvvm-swiftui-ru.md:
--------------------------------------------------------------------------------
1 | # Как использовать Kotlin Multiplatform ViewModel в SwiftUI и Jetpack Compose
2 |
3 | Мы в [IceRock Development](https://icerock.dev) уже много лет пользуемся подходом MVVM, а последние
4 | 4 года наши `ViewModel` расположены в общем коде, за счет использования нашей библиотеки
5 | [moko-mvvm](https://github.com/icerockdev/moko-mvvm). В последний год мы активно переходим на
6 | использование Jetpack Compose и SwiftUI для построения UI в наших проектах. И это потребовало
7 | улучшения MOKO MVVM, чтобы разработчикам на обеих платформах было удобно работать с таким подходом.
8 |
9 | 30 апреля 2022 вышла [новая версия MOKO MVVM - 0.13.0](https://github.com/icerockdev/moko-mvvm/releases/tag/release%2F0.13.0).
10 | В этой версии появилась полноценная поддержка Jetpack Compose и SwiftUI. Разберем на примере как
11 | можно использовать ViewModel из общего кода с данными фреймворками.
12 |
13 | Пример будет простой - приложение с экраном авторизации. Два поля ввода - логин и пароль, кнопка
14 | Войти и сообщение о успешном входе после секунды ожидания (во время ожидания крутим прогресс бар).
15 |
16 | ## Создаем проект
17 |
18 | Первый шаг простой - берем Android Studio, устанавливаем
19 | [Kotlin Multiplatform Mobile IDE плагин](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform-mobile),
20 | если еще не установлен. Создаем проект по шаблону "Kotlin Multiplatform App" с использованием
21 | `CocoaPods integration` (с ними удобнее, плюс нам все равно потребуется подключать дополнительный
22 | CocoaPod).
23 |
24 | 
25 |
26 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/ee223a80e17616e622d135c0651ab454eabfad7a)
27 |
28 | ## Экран авторизации на Android с Jetpack Compose
29 |
30 | В шаблоне приложения используется стандартный подход с Android View, поэтому нам нужно подключить
31 | Jetpack Compose перед началом верстки.
32 |
33 | Включаем в `androidApp/build.gradle.kts` поддержку Compose:
34 | ```kotlin
35 | val composeVersion = "1.1.1"
36 |
37 | android {
38 | // ...
39 |
40 | buildFeatures {
41 | compose = true
42 | }
43 | composeOptions {
44 | kotlinCompilerExtensionVersion = composeVersion
45 | }
46 | }
47 | ```
48 |
49 | И подключаем необходимые нам зависимости, удаляя старые ненужные (относящиеся к обычному подходу с
50 | View):
51 | ```kotlin
52 | dependencies {
53 | implementation(project(":shared"))
54 |
55 | implementation("androidx.compose.foundation:foundation:$composeVersion")
56 | implementation("androidx.compose.runtime:runtime:$composeVersion")
57 | // UI
58 | implementation("androidx.compose.ui:ui:$composeVersion")
59 | implementation("androidx.compose.ui:ui-tooling:$composeVersion")
60 | // Material Design
61 | implementation("androidx.compose.material:material:$composeVersion")
62 | implementation("androidx.compose.material:material-icons-core:$composeVersion")
63 | // Activity
64 | implementation("androidx.activity:activity-compose:1.4.0")
65 | implementation("androidx.appcompat:appcompat:1.4.1")
66 | }
67 | ```
68 |
69 | При выполнении Gradle Sync получаем сообщение о несовместимости версии Jetpack Compose и Kotlin.
70 | Это связано с тем что Compose использует compiler plugin для Kotlin, а их API пока не стабилизировано.
71 | Поэтому нам нужно поставить ту версию Kotlin, которую поддерживает
72 | используемая нами версия Compose - `1.6.10`.
73 |
74 | Далее остается сверстать экран авторизации, привожу сразу готовый код:
75 | ```kotlin
76 | @Composable
77 | fun LoginScreen() {
78 | val context: Context = LocalContext.current
79 | val coroutineScope: CoroutineScope = rememberCoroutineScope()
80 |
81 | var login: String by remember { mutableStateOf("") }
82 | var password: String by remember { mutableStateOf("") }
83 | var isLoading: Boolean by remember { mutableStateOf(false) }
84 |
85 | val isLoginButtonEnabled: Boolean = login.isNotBlank() && password.isNotBlank() && !isLoading
86 |
87 | Column(
88 | modifier = Modifier.padding(16.dp),
89 | horizontalAlignment = Alignment.CenterHorizontally
90 | ) {
91 | TextField(
92 | modifier = Modifier.fillMaxWidth(),
93 | value = login,
94 | enabled = !isLoading,
95 | label = { Text(text = "Login") },
96 | onValueChange = { login = it }
97 | )
98 | Spacer(modifier = Modifier.height(8.dp))
99 | TextField(
100 | modifier = Modifier.fillMaxWidth(),
101 | value = password,
102 | enabled = !isLoading,
103 | label = { Text(text = "Password") },
104 | visualTransformation = PasswordVisualTransformation(),
105 | onValueChange = { password = it }
106 | )
107 | Spacer(modifier = Modifier.height(8.dp))
108 | Button(
109 | modifier = Modifier
110 | .fillMaxWidth()
111 | .height(48.dp),
112 | enabled = isLoginButtonEnabled,
113 | onClick = {
114 | coroutineScope.launch {
115 | isLoading = true
116 | delay(1000)
117 | isLoading = false
118 | Toast.makeText(context, "login success!", Toast.LENGTH_SHORT).show()
119 | }
120 | }
121 | ) {
122 | if (isLoading) CircularProgressIndicator(modifier = Modifier.size(24.dp))
123 | else Text(text = "Login")
124 | }
125 | }
126 | }
127 | ```
128 |
129 | И вот наше приложение для Android с экраном авторизации готово и функционирует как требуется, но без
130 | общего кода.
131 |
132 | 
133 |
134 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/69cf1904cd16f34b5bc646cdcacda3b72c8b58cf)
135 |
136 | ## Экран авторизации в iOS с SwiftUI
137 |
138 | Сделаем тот же экран в SwiftUI. Шаблон уже создал SwiftUI приложение, поэтому нам достаточно просто
139 | написать код экрана. Получаем следующий код:
140 |
141 | ```swift
142 | struct LoginScreen: View {
143 | @State private var login: String = ""
144 | @State private var password: String = ""
145 | @State private var isLoading: Bool = false
146 | @State private var isSuccessfulAlertShowed: Bool = false
147 |
148 | private var isButtonEnabled: Bool {
149 | get {
150 | !isLoading && !login.isEmpty && !password.isEmpty
151 | }
152 | }
153 |
154 | var body: some View {
155 | Group {
156 | VStack(spacing: 8.0) {
157 | TextField("Login", text: $login)
158 | .textFieldStyle(.roundedBorder)
159 | .disabled(isLoading)
160 |
161 | SecureField("Password", text: $password)
162 | .textFieldStyle(.roundedBorder)
163 | .disabled(isLoading)
164 |
165 | Button(
166 | action: {
167 | isLoading = true
168 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
169 | isLoading = false
170 | isSuccessfulAlertShowed = true
171 | }
172 | }, label: {
173 | if isLoading {
174 | ProgressView()
175 | } else {
176 | Text("Login")
177 | }
178 | }
179 | ).disabled(!isButtonEnabled)
180 | }.padding()
181 | }.alert(
182 | "Login successful",
183 | isPresented: $isSuccessfulAlertShowed
184 | ) {
185 | Button("Close", action: { isSuccessfulAlertShowed = false })
186 | }
187 | }
188 | }
189 | ```
190 |
191 | Логика работы полностью идентична Android версии и также не использует никакой общей логики.
192 |
193 | 
194 |
195 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/760622ab392b1e723e4bb508d8f5c8b97b9ca5a7)
196 |
197 | ## Реализуем общую ViewModel
198 |
199 | Все подготовительные шаги завершены. Пора вынести из платформ логику работы экрана авторизации в
200 | общий код.
201 |
202 | Первое, что для этого мы сделаем - подключим в общий модуль зависимость moko-mvvm и добавим ее в
203 | список export'а для iOS framework (чтобы в Swift мы видели все публичные классы и методы этой
204 | библиотеки).
205 |
206 | ```kotlin
207 | val mokoMvvmVersion = "0.13.0"
208 |
209 | kotlin {
210 | // ...
211 |
212 | cocoapods {
213 | // ...
214 |
215 | framework {
216 | baseName = "MultiPlatformLibrary"
217 |
218 | export("dev.icerock.moko:mvvm-core:$mokoMvvmVersion")
219 | export("dev.icerock.moko:mvvm-flow:$mokoMvvmVersion")
220 | }
221 | }
222 |
223 | sourceSets {
224 | val commonMain by getting {
225 | dependencies {
226 | api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1-native-mt")
227 |
228 | api("dev.icerock.moko:mvvm-core:$mokoMvvmVersion")
229 | api("dev.icerock.moko:mvvm-flow:$mokoMvvmVersion")
230 | }
231 | }
232 | // ...
233 | val androidMain by getting {
234 | dependencies {
235 | api("dev.icerock.moko:mvvm-flow-compose:$mokoMvvmVersion")
236 | }
237 | }
238 | // ...
239 | }
240 | }
241 | ```
242 |
243 | Также мы изменили `baseName` у iOS Framework на `MultiPlatformLibrary`. Это важное изменение, без
244 | которого мы в дальнейшем не сможем подключить CocoaPod с функциями интеграции Kotlin и SwiftUI.
245 |
246 | Осталось написать саму `LoginViewModel`. Вот код:
247 | ```kotlin
248 | class LoginViewModel : ViewModel() {
249 | val login: MutableStateFlow = MutableStateFlow("")
250 | val password: MutableStateFlow = MutableStateFlow("")
251 |
252 | private val _isLoading: MutableStateFlow = MutableStateFlow(false)
253 | val isLoading: StateFlow = _isLoading
254 |
255 | val isButtonEnabled: StateFlow =
256 | combine(isLoading, login, password) { isLoading, login, password ->
257 | isLoading.not() && login.isNotBlank() && password.isNotBlank()
258 | }.stateIn(viewModelScope, SharingStarted.Eagerly, false)
259 |
260 | private val _actions = Channel()
261 | val actions: Flow get() = _actions.receiveAsFlow()
262 |
263 | fun onLoginPressed() {
264 | _isLoading.value = true
265 | viewModelScope.launch {
266 | delay(1000)
267 | _isLoading.value = false
268 | _actions.send(Action.LoginSuccess)
269 | }
270 | }
271 |
272 | sealed interface Action {
273 | object LoginSuccess : Action
274 | }
275 | }
276 | ```
277 |
278 | Для полей ввода, которые может менять пользователь, мы использовали `MutableStateFlow` из
279 | kotlinx-coroutines (но можно использовать и `MutableLiveData` из `moko-mvvm-livedata`).
280 | Для свойств, которые UI должен отслеживать, но не должен менять - используем `StateFlow`.
281 | А для оповещения о необходимости что-то сделать (показать сообщение о успехе или чтобы перейти
282 | на другой экран) мы создали `Channel`, который выдается на UI в виде `Flow`. Все доступные действия
283 | мы объединяем под единый `sealed interface Action`, чтобы точно было известно какие действия может
284 | сообщить данная `ViewModel`.
285 |
286 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/d628fb60fedeeb0d259508aa09d3a98ebbc9651c)
287 |
288 | ## Подключаем общую ViewModel к Android
289 |
290 | На Android чтобы получить из `ViewModelStorage` нашу `ViewModel` (чтобы при поворотах экрана мы
291 | получали туже-самую ViewModel) нам нужно подключить специальную зависимость в
292 | `androidApp/build.gradle.kts`:
293 |
294 | ```kotlin
295 | dependencies {
296 | // ...
297 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1")
298 | }
299 | ```
300 |
301 | Далее добавим в аргументы нашего экрана `LoginViewModel`:
302 | ```kotlin
303 | @Composable
304 | fun LoginScreen(
305 | viewModel: LoginViewModel = viewModel()
306 | )
307 | ```
308 |
309 | Заменим локальное состояние экрана, на получение состояния из `LoginViewModel`:
310 | ```kotlin
311 | val login: String by viewModel.login.collectAsState()
312 | val password: String by viewModel.password.collectAsState()
313 | val isLoading: Boolean by viewModel.isLoading.collectAsState()
314 | val isLoginButtonEnabled: Boolean by viewModel.isButtonEnabled.collectAsState()
315 | ```
316 |
317 | Подпишемся на получение действий от ViewModel'и используя `observeAsAction` из moko-mvvm:
318 | ```kotlin
319 | viewModel.actions.observeAsActions { action ->
320 | when (action) {
321 | LoginViewModel.Action.LoginSuccess ->
322 | Toast.makeText(context, "login success!", Toast.LENGTH_SHORT).show()
323 | }
324 | }
325 | ```
326 |
327 | Заменим обработчик ввода у `TextField`'ов с локального состояния на запись в `ViewModel`:
328 | ```kotlin
329 | TextField(
330 | // ...
331 | onValueChange = { viewModel.login.value = it }
332 | )
333 | ```
334 |
335 | И вызовем обработчик нажатия на кнопку авторизации:
336 | ```kotlin
337 | Button(
338 | // ...
339 | onClick = viewModel::onLoginPressed
340 | ) {
341 | // ...
342 | }
343 | ```
344 |
345 | Запускаем приложение и видим что все работает точно также, как работало до общего кода, но теперь
346 | вся логика работы экрана управляется общей ViewModel.
347 |
348 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/a93b9a3b6f1e413bebbba3a30bc5a198ebbf4e84)
349 |
350 | ## Подключаем общую ViewModel к iOS
351 |
352 | Для подключения `LoginViewModel` к SwiftUI нам потребуются Swift дополнения от MOKO MVVM.
353 | Подключаются они через CocoaPods:
354 |
355 | ```ruby
356 | pod 'mokoMvvmFlowSwiftUI', :podspec => 'https://raw.githubusercontent.com/icerockdev/moko-mvvm/release/0.13.0/mokoMvvmFlowSwiftUI.podspec'
357 | ```
358 |
359 | А также, в самой `LoginViewModel` нужно внести изменения - со стороны Swift `MutableStateFlow`,
360 | `StateFlow`, `Flow` потеряют generic type, так как это интерфейсы. Чтобы generic не был потерян
361 | нужно использовать классы. MOKO MVVM предоставляет специальные классы `CMutableStateFlow`,
362 | `CStateFlow` и `CFlow` для сохранения generic type в iOS. Приведем типы следующим изменением:
363 |
364 | ```kotlin
365 | class LoginViewModel : ViewModel() {
366 | val login: CMutableStateFlow = MutableStateFlow("").cMutableStateFlow()
367 | val password: CMutableStateFlow = MutableStateFlow("").cMutableStateFlow()
368 |
369 | // ...
370 | val isLoading: CStateFlow = _isLoading.cStateFlow()
371 |
372 | val isButtonEnabled: CStateFlow =
373 | // ...
374 | .cStateFlow()
375 |
376 | // ...
377 | val actions: CFlow get() = _actions.receiveAsFlow().cFlow()
378 |
379 | // ...
380 | }
381 | ```
382 |
383 | Теперь можем переходить в Swift код. Для интеграции делаем следующее изменение:
384 |
385 | ```swift
386 | import MultiPlatformLibrary
387 | import mokoMvvmFlowSwiftUI
388 | import Combine
389 |
390 | struct LoginScreen: View {
391 | @ObservedObject var viewModel: LoginViewModel = LoginViewModel()
392 | @State private var isSuccessfulAlertShowed: Bool = false
393 |
394 | // ...
395 | }
396 | ```
397 |
398 | Мы добавляем `viewModel` в `View` как `@ObservedObject`, также как мы делаем с Swift версиями
399 | ViewModel, но в данном случе, за счет использования `mokoMvvmFlowSwiftUI` мы можем передать сразу
400 | Kotlin класс `LoginViewModel`.
401 |
402 | Далее меняем привязку полей:
403 | ```swift
404 | TextField("Login", text: viewModel.binding(\.login))
405 | .textFieldStyle(.roundedBorder)
406 | .disabled(viewModel.state(\.isLoading))
407 | ```
408 |
409 | `mokoMvvmFlowSwiftUI` предоставляет специальные функции расширения к `ViewModel`:
410 | - `binding` возвращает `Binding` структуру, для возможности изменения данных со стороны UI
411 | - `state` возвращает значение, которое будет автоматически обновляться, когда `StateFlow` выдаст
412 | новые данные
413 |
414 | Аналогичным образом заменяем другие места использования локального стейта и подписываемся на
415 | действия:
416 | ```swift
417 | .onReceive(createPublisher(viewModel.actions)) { action in
418 | let actionKs = LoginViewModelActionKs(action)
419 | switch(actionKs) {
420 | case .loginSuccess:
421 | isSuccessfulAlertShowed = true
422 | break
423 | }
424 | }
425 | ```
426 |
427 | Функция `createPublisher` также предоставляется из `mokoMvvmFlowSwiftUI` и позволяет преобразовать
428 | `CFlow` в `AnyPublisher` от Combine. Для надежности обработки действий мы используем
429 | [moko-kswift](https://github.com/icerockdev/moko-kswift). Это gradle плагин, который автоматически
430 | генерирует swift код, на основе Kotlin. В данном случае был сгенерирован Swift
431 | `enum LoginViewModelActionKs` из `sealed interface LoginViewModel.Action`. Используя автоматически
432 | генерируемый `enum` мы получаем гарантию соответствия кейсов в `enum` и в `sealed interface`, поэтому
433 | теперь мы можем полагаться на exhaustive логику switch.
434 | Подробнее про MOKO KSwift можно прочитать [в статье](https://habr.com/ru/post/571714/).
435 |
436 | В итоге мы получили SwiftUI экран, который управляется из общего кода используя подход MVVM.
437 |
438 | [git commit](https://github.com/Alex009/moko-mvvm-compose-swiftui/commit/5e260fbf9e4957c6fa5d1679a4282691d37da96a)
439 |
440 | ## Выводы
441 |
442 | В разработке с Kotlin Multiplatform Mobile мы считаем важным стремиться предоставить удобный
443 | инструментарий для обеих платформ - и Android и iOS разработчики должны с комфортом вести разработку
444 | и использование какого-либо подхода в общем коде не должно заставлять разработчиков одной из платформ
445 | делать лишнюю работу. Разрабатывая наши [MOKO](https://moko.icerock.dev) библиотеки и инструменты мы
446 | стремимся упростить работу разработчиков и под Android и iOS. Интеграция SwiftUI и MOKO MVVM
447 | потребовала множество экспериментов, но итоговый результат выглядит удобным в использовании.
448 |
449 | Вы можете самостоятельно попробовать проект, созданный в этой статье,
450 | [на GitHub](https://github.com/Alex009/moko-mvvm-compose-swiftui).
451 |
452 | Также, если вас интересует тема Kotlin Multiplatform Mobile, рекомендуем наши материалы на
453 | [kmm.icerock.dev](https://kmm.icerock.dev).
454 |
455 | Для начинающих разработчиков, желающих погрузиться в разработку под Android и iOS с Kotlin
456 | Multiplatform у нас работает корпоративный университет, материалы которого доступны всем на
457 | [kmm.icerock.dev - University](https://kmm.icerock.dev/university/intro). Желающие узнать больше
458 | о наших подходах к разработке могут также ознакомиться с материалами университета.
459 |
460 | Мы также [можем помочь](https://icerockdev.com/directions/pages/kmm/) и командам разработки,
461 | которым нужна помощь в разработке или консультации по теме Kotlin Multiplatform Mobile.
462 |
--------------------------------------------------------------------------------
/iosApp/Podfile:
--------------------------------------------------------------------------------
1 | target 'iosApp' do
2 | use_frameworks!
3 | platform :ios, '15.0'
4 | pod 'shared', :path => '../shared'
5 | pod 'mokoMvvmFlowSwiftUI', :podspec => 'https://raw.githubusercontent.com/icerockdev/moko-mvvm/release/0.15.0/mokoMvvmFlowSwiftUI.podspec'
6 | end
7 |
--------------------------------------------------------------------------------
/iosApp/Podfile.lock:
--------------------------------------------------------------------------------
1 | PODS:
2 | - mokoMvvmFlowSwiftUI (0.15.0)
3 | - shared (1.0)
4 |
5 | DEPENDENCIES:
6 | - mokoMvvmFlowSwiftUI (from `https://raw.githubusercontent.com/icerockdev/moko-mvvm/release/0.15.0/mokoMvvmFlowSwiftUI.podspec`)
7 | - shared (from `../shared`)
8 |
9 | EXTERNAL SOURCES:
10 | mokoMvvmFlowSwiftUI:
11 | :podspec: https://raw.githubusercontent.com/icerockdev/moko-mvvm/release/0.15.0/mokoMvvmFlowSwiftUI.podspec
12 | shared:
13 | :path: "../shared"
14 |
15 | SPEC CHECKSUMS:
16 | mokoMvvmFlowSwiftUI: dd95361932cc305d0a0746c32735b7e1cea41c21
17 | shared: bf905616148459e54532de32c0603295c33e665b
18 |
19 | PODFILE CHECKSUM: 542e68f70f330600ce1356473f4c2de9327c1dcd
20 |
21 | COCOAPODS: 1.11.3
22 |
--------------------------------------------------------------------------------
/iosApp/iosApp.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 50;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; };
11 | 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; };
12 | 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
13 | 2298ADFB281D1B9C00E48E23 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2298ADFA281D1B9C00E48E23 /* LoginScreen.swift */; };
14 | 22D1437D281D2E3C00877407 /* mvvm-compose-swiftui_shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22D1437C281D2E3C00877407 /* mvvm-compose-swiftui_shared.swift */; };
15 | BFEB439EC3BA1230B2717052 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFFC86256250121488CCB760 /* Pods_iosApp.framework */; };
16 | /* End PBXBuildFile section */
17 |
18 | /* Begin PBXFileReference section */
19 | 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
20 | 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
21 | 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; };
22 | 2298ADFA281D1B9C00E48E23 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = ""; };
23 | 22D1437C281D2E3C00877407 /* mvvm-compose-swiftui_shared.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "mvvm-compose-swiftui_shared.swift"; path = "../shared/build/cocoapods/framework/MultiPlatformLibrarySwift/mvvm-compose-swiftui_shared.swift"; sourceTree = ""; };
24 | 35F7955DFCEB2B4412BBFA11 /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; };
25 | 5D1F1757EDB573EB0431395F /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; };
26 | 7555FF7B242A565900829871 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
27 | 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
28 | BFFC86256250121488CCB760 /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
29 | /* End PBXFileReference section */
30 |
31 | /* Begin PBXFrameworksBuildPhase section */
32 | 0E42395E7B77DDDF48973EE7 /* Frameworks */ = {
33 | isa = PBXFrameworksBuildPhase;
34 | buildActionMask = 2147483647;
35 | files = (
36 | BFEB439EC3BA1230B2717052 /* Pods_iosApp.framework in Frameworks */,
37 | );
38 | runOnlyForDeploymentPostprocessing = 0;
39 | };
40 | /* End PBXFrameworksBuildPhase section */
41 |
42 | /* Begin PBXGroup section */
43 | 058557D7273AAEEB004C7B11 /* Preview Content */ = {
44 | isa = PBXGroup;
45 | children = (
46 | 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */,
47 | );
48 | path = "Preview Content";
49 | sourceTree = "";
50 | };
51 | 16B0D155DA2A6101340F0EC6 /* Pods */ = {
52 | isa = PBXGroup;
53 | children = (
54 | 5D1F1757EDB573EB0431395F /* Pods-iosApp.debug.xcconfig */,
55 | 35F7955DFCEB2B4412BBFA11 /* Pods-iosApp.release.xcconfig */,
56 | );
57 | path = Pods;
58 | sourceTree = "";
59 | };
60 | 22D1437B281D2E2300877407 /* kswift */ = {
61 | isa = PBXGroup;
62 | children = (
63 | 22D1437C281D2E3C00877407 /* mvvm-compose-swiftui_shared.swift */,
64 | );
65 | name = kswift;
66 | sourceTree = "";
67 | };
68 | 524F2A576B70A627391AE5C4 /* Frameworks */ = {
69 | isa = PBXGroup;
70 | children = (
71 | BFFC86256250121488CCB760 /* Pods_iosApp.framework */,
72 | );
73 | name = Frameworks;
74 | sourceTree = "";
75 | };
76 | 7555FF72242A565900829871 = {
77 | isa = PBXGroup;
78 | children = (
79 | 22D1437B281D2E2300877407 /* kswift */,
80 | 7555FF7D242A565900829871 /* iosApp */,
81 | 7555FF7C242A565900829871 /* Products */,
82 | 16B0D155DA2A6101340F0EC6 /* Pods */,
83 | 524F2A576B70A627391AE5C4 /* Frameworks */,
84 | );
85 | sourceTree = "";
86 | };
87 | 7555FF7C242A565900829871 /* Products */ = {
88 | isa = PBXGroup;
89 | children = (
90 | 7555FF7B242A565900829871 /* iosApp.app */,
91 | );
92 | name = Products;
93 | sourceTree = "";
94 | };
95 | 7555FF7D242A565900829871 /* iosApp */ = {
96 | isa = PBXGroup;
97 | children = (
98 | 058557BA273AAA24004C7B11 /* Assets.xcassets */,
99 | 7555FF8C242A565B00829871 /* Info.plist */,
100 | 2152FB032600AC8F00CF470E /* iOSApp.swift */,
101 | 058557D7273AAEEB004C7B11 /* Preview Content */,
102 | 2298ADFA281D1B9C00E48E23 /* LoginScreen.swift */,
103 | );
104 | path = iosApp;
105 | sourceTree = "";
106 | };
107 | /* End PBXGroup section */
108 |
109 | /* Begin PBXNativeTarget section */
110 | 7555FF7A242A565900829871 /* iosApp */ = {
111 | isa = PBXNativeTarget;
112 | buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
113 | buildPhases = (
114 | FB1517A6BA217D7B1E69C486 /* [CP] Check Pods Manifest.lock */,
115 | 7555FF77242A565900829871 /* Sources */,
116 | 7555FF79242A565900829871 /* Resources */,
117 | 0E42395E7B77DDDF48973EE7 /* Frameworks */,
118 | 12C4EE351A8EB569C0D6B483 /* [CP] Embed Pods Frameworks */,
119 | );
120 | buildRules = (
121 | );
122 | dependencies = (
123 | );
124 | name = iosApp;
125 | productName = iosApp;
126 | productReference = 7555FF7B242A565900829871 /* iosApp.app */;
127 | productType = "com.apple.product-type.application";
128 | };
129 | /* End PBXNativeTarget section */
130 |
131 | /* Begin PBXProject section */
132 | 7555FF73242A565900829871 /* Project object */ = {
133 | isa = PBXProject;
134 | attributes = {
135 | LastSwiftUpdateCheck = 1130;
136 | LastUpgradeCheck = 1130;
137 | ORGANIZATIONNAME = orgName;
138 | TargetAttributes = {
139 | 7555FF7A242A565900829871 = {
140 | CreatedOnToolsVersion = 11.3.1;
141 | };
142 | };
143 | };
144 | buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */;
145 | compatibilityVersion = "Xcode 9.3";
146 | developmentRegion = en;
147 | hasScannedForEncodings = 0;
148 | knownRegions = (
149 | en,
150 | Base,
151 | );
152 | mainGroup = 7555FF72242A565900829871;
153 | productRefGroup = 7555FF7C242A565900829871 /* Products */;
154 | projectDirPath = "";
155 | projectRoot = "";
156 | targets = (
157 | 7555FF7A242A565900829871 /* iosApp */,
158 | );
159 | };
160 | /* End PBXProject section */
161 |
162 | /* Begin PBXResourcesBuildPhase section */
163 | 7555FF79242A565900829871 /* Resources */ = {
164 | isa = PBXResourcesBuildPhase;
165 | buildActionMask = 2147483647;
166 | files = (
167 | 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */,
168 | 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */,
169 | );
170 | runOnlyForDeploymentPostprocessing = 0;
171 | };
172 | /* End PBXResourcesBuildPhase section */
173 |
174 | /* Begin PBXShellScriptBuildPhase section */
175 | 12C4EE351A8EB569C0D6B483 /* [CP] Embed Pods Frameworks */ = {
176 | isa = PBXShellScriptBuildPhase;
177 | buildActionMask = 2147483647;
178 | files = (
179 | );
180 | inputFileListPaths = (
181 | "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
182 | );
183 | name = "[CP] Embed Pods Frameworks";
184 | outputFileListPaths = (
185 | "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
186 | );
187 | runOnlyForDeploymentPostprocessing = 0;
188 | shellPath = /bin/sh;
189 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n";
190 | showEnvVarsInLog = 0;
191 | };
192 | FB1517A6BA217D7B1E69C486 /* [CP] Check Pods Manifest.lock */ = {
193 | isa = PBXShellScriptBuildPhase;
194 | buildActionMask = 2147483647;
195 | files = (
196 | );
197 | inputFileListPaths = (
198 | );
199 | inputPaths = (
200 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
201 | "${PODS_ROOT}/Manifest.lock",
202 | );
203 | name = "[CP] Check Pods Manifest.lock";
204 | outputFileListPaths = (
205 | );
206 | outputPaths = (
207 | "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt",
208 | );
209 | runOnlyForDeploymentPostprocessing = 0;
210 | shellPath = /bin/sh;
211 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
212 | showEnvVarsInLog = 0;
213 | };
214 | /* End PBXShellScriptBuildPhase section */
215 |
216 | /* Begin PBXSourcesBuildPhase section */
217 | 7555FF77242A565900829871 /* Sources */ = {
218 | isa = PBXSourcesBuildPhase;
219 | buildActionMask = 2147483647;
220 | files = (
221 | 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */,
222 | 22D1437D281D2E3C00877407 /* mvvm-compose-swiftui_shared.swift in Sources */,
223 | 2298ADFB281D1B9C00E48E23 /* LoginScreen.swift in Sources */,
224 | );
225 | runOnlyForDeploymentPostprocessing = 0;
226 | };
227 | /* End PBXSourcesBuildPhase section */
228 |
229 | /* Begin XCBuildConfiguration section */
230 | 7555FFA3242A565B00829871 /* Debug */ = {
231 | isa = XCBuildConfiguration;
232 | buildSettings = {
233 | ALWAYS_SEARCH_USER_PATHS = NO;
234 | CLANG_ANALYZER_NONNULL = YES;
235 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
236 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
237 | CLANG_CXX_LIBRARY = "libc++";
238 | CLANG_ENABLE_MODULES = YES;
239 | CLANG_ENABLE_OBJC_ARC = YES;
240 | CLANG_ENABLE_OBJC_WEAK = YES;
241 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
242 | CLANG_WARN_BOOL_CONVERSION = YES;
243 | CLANG_WARN_COMMA = YES;
244 | CLANG_WARN_CONSTANT_CONVERSION = YES;
245 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
246 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
247 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
248 | CLANG_WARN_EMPTY_BODY = YES;
249 | CLANG_WARN_ENUM_CONVERSION = YES;
250 | CLANG_WARN_INFINITE_RECURSION = YES;
251 | CLANG_WARN_INT_CONVERSION = YES;
252 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
253 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
254 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
255 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
256 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
257 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
258 | CLANG_WARN_STRICT_PROTOTYPES = YES;
259 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
260 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
261 | CLANG_WARN_UNREACHABLE_CODE = YES;
262 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
263 | COPY_PHASE_STRIP = NO;
264 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
265 | ENABLE_STRICT_OBJC_MSGSEND = YES;
266 | ENABLE_TESTABILITY = YES;
267 | GCC_C_LANGUAGE_STANDARD = gnu11;
268 | GCC_DYNAMIC_NO_PIC = NO;
269 | GCC_NO_COMMON_BLOCKS = YES;
270 | GCC_OPTIMIZATION_LEVEL = 0;
271 | GCC_PREPROCESSOR_DEFINITIONS = (
272 | "DEBUG=1",
273 | "$(inherited)",
274 | );
275 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
276 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
277 | GCC_WARN_UNDECLARED_SELECTOR = YES;
278 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
279 | GCC_WARN_UNUSED_FUNCTION = YES;
280 | GCC_WARN_UNUSED_VARIABLE = YES;
281 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
282 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
283 | MTL_FAST_MATH = YES;
284 | ONLY_ACTIVE_ARCH = YES;
285 | SDKROOT = iphoneos;
286 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
287 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
288 | };
289 | name = Debug;
290 | };
291 | 7555FFA4242A565B00829871 /* Release */ = {
292 | isa = XCBuildConfiguration;
293 | buildSettings = {
294 | ALWAYS_SEARCH_USER_PATHS = NO;
295 | CLANG_ANALYZER_NONNULL = YES;
296 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
297 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
298 | CLANG_CXX_LIBRARY = "libc++";
299 | CLANG_ENABLE_MODULES = YES;
300 | CLANG_ENABLE_OBJC_ARC = YES;
301 | CLANG_ENABLE_OBJC_WEAK = YES;
302 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
303 | CLANG_WARN_BOOL_CONVERSION = YES;
304 | CLANG_WARN_COMMA = YES;
305 | CLANG_WARN_CONSTANT_CONVERSION = YES;
306 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
307 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
308 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
309 | CLANG_WARN_EMPTY_BODY = YES;
310 | CLANG_WARN_ENUM_CONVERSION = YES;
311 | CLANG_WARN_INFINITE_RECURSION = YES;
312 | CLANG_WARN_INT_CONVERSION = YES;
313 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
314 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
315 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
316 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
317 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
318 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
319 | CLANG_WARN_STRICT_PROTOTYPES = YES;
320 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
321 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
322 | CLANG_WARN_UNREACHABLE_CODE = YES;
323 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
324 | COPY_PHASE_STRIP = NO;
325 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
326 | ENABLE_NS_ASSERTIONS = NO;
327 | ENABLE_STRICT_OBJC_MSGSEND = YES;
328 | GCC_C_LANGUAGE_STANDARD = gnu11;
329 | GCC_NO_COMMON_BLOCKS = YES;
330 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
331 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
332 | GCC_WARN_UNDECLARED_SELECTOR = YES;
333 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
334 | GCC_WARN_UNUSED_FUNCTION = YES;
335 | GCC_WARN_UNUSED_VARIABLE = YES;
336 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
337 | MTL_ENABLE_DEBUG_INFO = NO;
338 | MTL_FAST_MATH = YES;
339 | SDKROOT = iphoneos;
340 | SWIFT_COMPILATION_MODE = wholemodule;
341 | SWIFT_OPTIMIZATION_LEVEL = "-O";
342 | VALIDATE_PRODUCT = YES;
343 | };
344 | name = Release;
345 | };
346 | 7555FFA6242A565B00829871 /* Debug */ = {
347 | isa = XCBuildConfiguration;
348 | baseConfigurationReference = 5D1F1757EDB573EB0431395F /* Pods-iosApp.debug.xcconfig */;
349 | buildSettings = {
350 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
351 | CODE_SIGN_STYLE = Automatic;
352 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
353 | ENABLE_PREVIEWS = YES;
354 | INFOPLIST_FILE = iosApp/Info.plist;
355 | LD_RUNPATH_SEARCH_PATHS = (
356 | "$(inherited)",
357 | "@executable_path/Frameworks",
358 | );
359 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosApp;
360 | PRODUCT_NAME = "$(TARGET_NAME)";
361 | SWIFT_VERSION = 5.0;
362 | TARGETED_DEVICE_FAMILY = "1,2";
363 | };
364 | name = Debug;
365 | };
366 | 7555FFA7242A565B00829871 /* Release */ = {
367 | isa = XCBuildConfiguration;
368 | baseConfigurationReference = 35F7955DFCEB2B4412BBFA11 /* Pods-iosApp.release.xcconfig */;
369 | buildSettings = {
370 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
371 | CODE_SIGN_STYLE = Automatic;
372 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
373 | ENABLE_PREVIEWS = YES;
374 | INFOPLIST_FILE = iosApp/Info.plist;
375 | LD_RUNPATH_SEARCH_PATHS = (
376 | "$(inherited)",
377 | "@executable_path/Frameworks",
378 | );
379 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosApp;
380 | PRODUCT_NAME = "$(TARGET_NAME)";
381 | SWIFT_VERSION = 5.0;
382 | TARGETED_DEVICE_FAMILY = "1,2";
383 | };
384 | name = Release;
385 | };
386 | /* End XCBuildConfiguration section */
387 |
388 | /* Begin XCConfigurationList section */
389 | 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = {
390 | isa = XCConfigurationList;
391 | buildConfigurations = (
392 | 7555FFA3242A565B00829871 /* Debug */,
393 | 7555FFA4242A565B00829871 /* Release */,
394 | );
395 | defaultConfigurationIsVisible = 0;
396 | defaultConfigurationName = Release;
397 | };
398 | 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
399 | isa = XCConfigurationList;
400 | buildConfigurations = (
401 | 7555FFA6242A565B00829871 /* Debug */,
402 | 7555FFA7242A565B00829871 /* Release */,
403 | );
404 | defaultConfigurationIsVisible = 0;
405 | defaultConfigurationName = Release;
406 | };
407 | /* End XCConfigurationList section */
408 | };
409 | rootObject = 7555FF73242A565900829871 /* Project object */;
410 | }
411 |
--------------------------------------------------------------------------------
/iosApp/iosApp.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/iosApp/iosApp.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "scale" : "1x",
46 | "size" : "20x20"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "scale" : "2x",
51 | "size" : "20x20"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "scale" : "1x",
56 | "size" : "29x29"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "scale" : "2x",
61 | "size" : "29x29"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "scale" : "1x",
66 | "size" : "40x40"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "scale" : "2x",
71 | "size" : "40x40"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "scale" : "1x",
76 | "size" : "76x76"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "scale" : "2x",
81 | "size" : "76x76"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "scale" : "2x",
86 | "size" : "83.5x83.5"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "scale" : "1x",
91 | "size" : "1024x1024"
92 | }
93 | ],
94 | "info" : {
95 | "author" : "xcode",
96 | "version" : 1
97 | }
98 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 |
28 | UIRequiredDeviceCapabilities
29 |
30 | armv7
31 |
32 | UISupportedInterfaceOrientations
33 |
34 | UIInterfaceOrientationPortrait
35 | UIInterfaceOrientationLandscapeLeft
36 | UIInterfaceOrientationLandscapeRight
37 |
38 | UISupportedInterfaceOrientations~ipad
39 |
40 | UIInterfaceOrientationPortrait
41 | UIInterfaceOrientationPortraitUpsideDown
42 | UIInterfaceOrientationLandscapeLeft
43 | UIInterfaceOrientationLandscapeRight
44 |
45 | UILaunchScreen
46 |
47 |
48 |
--------------------------------------------------------------------------------
/iosApp/iosApp/LoginScreen.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginScreen.swift
3 | // iosApp
4 | //
5 | // Created by Aleksey Mikhailov on 30.04.2022.
6 | // Copyright © 2022 orgName. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 | import MultiPlatformLibrary
11 | import mokoMvvmFlowSwiftUI
12 | import Combine
13 |
14 | struct LoginScreen: View {
15 | @ObservedObject var viewModel: LoginViewModel = LoginViewModel()
16 |
17 | @State private var isSuccessfulAlertShowed: Bool = false
18 |
19 | var body: some View {
20 | Group {
21 | VStack(spacing: 8.0) {
22 | TextField("Login", text: viewModel.binding(\.login))
23 | .textFieldStyle(.roundedBorder)
24 | .disabled(viewModel.state(\.isLoading))
25 |
26 | SecureField("Password", text: viewModel.binding(\.password))
27 | .textFieldStyle(.roundedBorder)
28 | .disabled(viewModel.state(\.isLoading))
29 |
30 | Button(
31 | action: {
32 | viewModel.onLoginPressed()
33 | }, label: {
34 | if viewModel.state(\.isLoading) {
35 | ProgressView()
36 | } else {
37 | Text("Login")
38 | }
39 | }
40 | ).disabled(!viewModel.state(\.isButtonEnabled))
41 | }.padding()
42 | }.onReceive(createPublisher(viewModel.actions)) { action in
43 | let actionKs = LoginViewModelActionKs(action)
44 | switch(actionKs) {
45 | case .loginSuccess:
46 | isSuccessfulAlertShowed = true
47 | break
48 | }
49 | }.alert(
50 | "Login successful",
51 | isPresented: $isSuccessfulAlertShowed
52 | ) {
53 | Button("Close", action: { isSuccessfulAlertShowed = false })
54 | }
55 | }
56 | }
57 |
58 | struct LoginScreen_Previews: PreviewProvider {
59 | static var previews: some View {
60 | LoginScreen()
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/iOSApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct iOSApp: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | LoginScreen()
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/media/android-compose-mvvm.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Alex009/moko-mvvm-compose-swiftui/df9e5a601cbc514b7a14d049e27babfe1a697dc5/media/android-compose-mvvm.gif
--------------------------------------------------------------------------------
/media/ios-swiftui-mvvm.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Alex009/moko-mvvm-compose-swiftui/df9e5a601cbc514b7a14d049e27babfe1a697dc5/media/ios-swiftui-mvvm.gif
--------------------------------------------------------------------------------
/media/wizard-cocoapods-integration.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Alex009/moko-mvvm-compose-swiftui/df9e5a601cbc514b7a14d049e27babfe1a697dc5/media/wizard-cocoapods-integration.png
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | gradlePluginPortal()
5 | mavenCentral()
6 | }
7 | }
8 |
9 | rootProject.name = "mvvm-compose-swiftui"
10 | include(":androidApp")
11 | include(":shared")
--------------------------------------------------------------------------------
/shared/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("multiplatform")
3 | kotlin("native.cocoapods")
4 | id("com.android.library")
5 | id("dev.icerock.moko.kswift")
6 | }
7 |
8 | version = "1.0"
9 |
10 | val mokoMvvmVersion = "0.15.0"
11 |
12 | kotlin {
13 | android()
14 | iosX64()
15 | iosArm64()
16 | iosSimulatorArm64()
17 |
18 | cocoapods {
19 | summary = "Some description for the Shared Module"
20 | homepage = "Link to the Shared Module homepage"
21 | ios.deploymentTarget = "15.0"
22 | podfile = project.file("../iosApp/Podfile")
23 | framework {
24 | baseName = "MultiPlatformLibrary"
25 | isStatic = false
26 |
27 | export("dev.icerock.moko:mvvm-core:$mokoMvvmVersion")
28 | export("dev.icerock.moko:mvvm-flow:$mokoMvvmVersion")
29 | }
30 | }
31 |
32 | sourceSets {
33 | val commonMain by getting {
34 | dependencies {
35 | api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1-native-mt")
36 |
37 | api("dev.icerock.moko:mvvm-core:$mokoMvvmVersion")
38 | api("dev.icerock.moko:mvvm-flow:$mokoMvvmVersion")
39 | }
40 | }
41 | val commonTest by getting {
42 | dependencies {
43 | implementation(kotlin("test"))
44 | }
45 | }
46 | val androidMain by getting {
47 | dependencies {
48 | api("dev.icerock.moko:mvvm-flow-compose:$mokoMvvmVersion")
49 | }
50 | }
51 | val androidTest by getting
52 | val iosX64Main by getting
53 | val iosArm64Main by getting
54 | val iosSimulatorArm64Main by getting
55 | val iosMain by creating {
56 | dependsOn(commonMain)
57 | iosX64Main.dependsOn(this)
58 | iosArm64Main.dependsOn(this)
59 | iosSimulatorArm64Main.dependsOn(this)
60 | }
61 | val iosX64Test by getting
62 | val iosArm64Test by getting
63 | val iosSimulatorArm64Test by getting
64 | val iosTest by creating {
65 | dependsOn(commonTest)
66 | iosX64Test.dependsOn(this)
67 | iosArm64Test.dependsOn(this)
68 | iosSimulatorArm64Test.dependsOn(this)
69 | }
70 | }
71 | }
72 |
73 | android {
74 | compileSdk = 32
75 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
76 | defaultConfig {
77 | minSdk = 21
78 | targetSdk = 32
79 | }
80 | }
81 |
82 | kswift {
83 | install(dev.icerock.moko.kswift.plugin.feature.SealedToSwiftEnumFeature)
84 | }
85 |
--------------------------------------------------------------------------------
/shared/shared.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |spec|
2 | spec.name = 'shared'
3 | spec.version = '1.0'
4 | spec.homepage = 'Link to the Shared Module homepage'
5 | spec.source = { :git => "Not Published", :tag => "Cocoapods/#{spec.name}/#{spec.version}" }
6 | spec.authors = ''
7 | spec.license = ''
8 | spec.summary = 'Some description for the Shared Module'
9 |
10 | spec.vendored_frameworks = "build/cocoapods/framework/MultiPlatformLibrary.framework"
11 | spec.libraries = "c++"
12 | spec.module_name = "#{spec.name}_umbrella"
13 |
14 | spec.ios.deployment_target = '15.0'
15 |
16 |
17 |
18 | spec.pod_target_xcconfig = {
19 | 'KOTLIN_PROJECT_PATH' => ':shared',
20 | 'PRODUCT_MODULE_NAME' => 'shared',
21 | }
22 |
23 | spec.script_phases = [
24 | {
25 | :name => 'Build shared',
26 | :execution_position => :before_compile,
27 | :shell_path => '/bin/sh',
28 | :script => <<-SCRIPT
29 | if [ "YES" = "$COCOAPODS_SKIP_KOTLIN_BUILD" ]; then
30 | echo "Skipping Gradle build task invocation due to COCOAPODS_SKIP_KOTLIN_BUILD environment variable set to \"YES\""
31 | exit 0
32 | fi
33 | set -ev
34 | REPO_ROOT="$PODS_TARGET_SRCROOT"
35 | "$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
36 | -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
37 | -Pkotlin.native.cocoapods.archs="$ARCHS" \
38 | -Pkotlin.native.cocoapods.configuration=$CONFIGURATION
39 | SCRIPT
40 | }
41 | ]
42 | end
--------------------------------------------------------------------------------
/shared/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/ru/alex009/moko/mvvm/declarativeui/LoginViewModel.kt:
--------------------------------------------------------------------------------
1 | package ru.alex009.moko.mvvm.declarativeui
2 |
3 | import dev.icerock.moko.mvvm.flow.CFlow
4 | import dev.icerock.moko.mvvm.flow.CMutableStateFlow
5 | import dev.icerock.moko.mvvm.flow.CStateFlow
6 | import dev.icerock.moko.mvvm.flow.cFlow
7 | import dev.icerock.moko.mvvm.flow.cMutableStateFlow
8 | import dev.icerock.moko.mvvm.flow.cStateFlow
9 | import dev.icerock.moko.mvvm.viewmodel.ViewModel
10 | import kotlinx.coroutines.channels.Channel
11 | import kotlinx.coroutines.delay
12 | import kotlinx.coroutines.flow.MutableStateFlow
13 | import kotlinx.coroutines.flow.SharingStarted
14 | import kotlinx.coroutines.flow.combine
15 | import kotlinx.coroutines.flow.receiveAsFlow
16 | import kotlinx.coroutines.flow.stateIn
17 | import kotlinx.coroutines.launch
18 |
19 | class LoginViewModel : ViewModel() {
20 | val login: CMutableStateFlow = MutableStateFlow("").cMutableStateFlow()
21 | val password: CMutableStateFlow = MutableStateFlow("").cMutableStateFlow()
22 |
23 | private val _isLoading: MutableStateFlow = MutableStateFlow(false)
24 | val isLoading: CStateFlow = _isLoading.cStateFlow()
25 |
26 | val isButtonEnabled: CStateFlow =
27 | combine(isLoading, login, password) { isLoading, login, password ->
28 | isLoading.not() && login.isNotBlank() && password.isNotBlank()
29 | }.stateIn(viewModelScope, SharingStarted.Eagerly, false).cStateFlow()
30 |
31 | private val _actions = Channel()
32 | val actions: CFlow get() = _actions.receiveAsFlow().cFlow()
33 |
34 | fun onLoginPressed() {
35 | _isLoading.value = true
36 | viewModelScope.launch {
37 | delay(1000)
38 | _isLoading.value = false
39 | _actions.send(Action.LoginSuccess)
40 | }
41 | }
42 |
43 | sealed interface Action {
44 | object LoginSuccess : Action
45 | }
46 | }
47 |
--------------------------------------------------------------------------------