├── .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 | | ![android-compose-mvvm](media/android-compose-mvvm.gif) | ![ios-swiftui-mvvm](media/ios-swiftui-mvvm.gif) | 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 | ![wizard-cocoapods](media/wizard-cocoapods-integration.png) 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 | ![android-compose-mvvm](media/android-compose-mvvm.gif) 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 | ![ios-swiftui-mvvm](media/ios-swiftui-mvvm.gif) 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 | ![wizard-cocoapods](media/wizard-cocoapods-integration.png) 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 | ![android-compose-mvvm](media/android-compose-mvvm.gif) 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 | ![ios-swiftui-mvvm](media/ios-swiftui-mvvm.gif) 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 | --------------------------------------------------------------------------------