├── .github ├── actions │ └── cache-konan │ │ └── action.yaml └── workflows │ ├── code-quality.yaml │ ├── release-kotlin.yaml │ └── release-swift.yaml ├── .gitignore ├── .idea ├── .gitignore ├── .name ├── kotlinc.xml ├── misc.xml └── vcs.xml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── KMPObservableViewModelCore.podspec ├── KMPObservableViewModelCore ├── ChildViewModels.swift ├── ObservableViewModel.swift ├── ObservableViewModelPublisher.swift ├── ObservableViewModelPublishers.swift └── ViewModel.swift ├── KMPObservableViewModelCoreObjC.podspec ├── KMPObservableViewModelCoreObjC ├── KMPOVMViewModelScope.h ├── KMPObservableViewModelCoreObjC.h └── KMPObservableViewModelCoreObjC.m ├── KMPObservableViewModelSwiftUI.podspec ├── KMPObservableViewModelSwiftUI ├── EnvironmentViewModel.swift ├── ObservableViewModelProjection.swift ├── ObservedViewModel.swift └── StateViewModel.swift ├── LICENSE ├── Package.swift ├── README.md ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ ├── kmp-observableviewmodel-android-library.gradle.kts │ ├── kmp-observableviewmodel-kotlin-multiplatform.gradle.kts │ └── kmp-observableviewmodel-publish.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── kmp-observableviewmodel-core ├── build.gradle.kts └── src │ ├── androidxMain │ └── kotlin │ │ └── com │ │ └── rickclephas │ │ └── kmp │ │ └── observableviewmodel │ │ └── ViewModel.kt │ ├── appleMain │ └── kotlin │ │ └── com │ │ └── rickclephas │ │ └── kmp │ │ └── observableviewmodel │ │ ├── StateFlow.kt │ │ └── ViewModelScope.kt │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── rickclephas │ │ └── kmp │ │ └── observableviewmodel │ │ ├── DefaultCoroutineScope.kt │ │ ├── InternalKMPObservableViewModelApi.kt │ │ ├── StateFlow.kt │ │ ├── StateFlowUtils.kt │ │ ├── ViewModel.kt │ │ ├── ViewModelScope.kt │ │ └── ViewModelScopeUtils.kt │ ├── nativeInterop │ └── cinterop │ │ └── KMPObservableViewModelCoreObjC.def │ ├── nonAndroidxMain │ └── kotlin │ │ └── com │ │ └── rickclephas │ │ └── kmp │ │ └── observableviewmodel │ │ ├── Closeables.kt │ │ └── ViewModel.kt │ └── nonAppleMain │ └── kotlin │ └── com │ └── rickclephas │ └── kmp │ └── observableviewmodel │ ├── StateFlow.kt │ └── ViewModelScope.kt ├── kotlin-js-store └── yarn.lock ├── qodana.yaml ├── sample ├── androidApp │ ├── build.gradle.kts │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── com │ │ │ └── rickclephas │ │ │ └── kmp │ │ │ └── observableviewmodel │ │ │ └── sample │ │ │ ├── ComposeFragment.kt │ │ │ ├── ComposeMPFragment.kt │ │ │ ├── MainActivity.kt │ │ │ ├── PickerFragment.kt │ │ │ └── ViewFragment.kt │ │ └── res │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── fragment_picker.xml │ │ └── fragment_time_travel.xml │ │ └── values │ │ └── styles.xml ├── build.gradle.kts ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── iosApp │ ├── KMPObservableViewModelSample.xcodeproj │ │ ├── project.pbxproj │ │ └── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ ├── KMPObservableViewModelSample │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── ContentView.swift │ │ ├── ContentViewMP.swift │ │ ├── KMPObservableViewModel.swift │ │ ├── KMPObservableViewModelSampleApp.swift │ │ ├── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ │ └── Contents.json │ │ └── TimeTravelViewModel.swift │ ├── KMPObservableViewModelSampleTests │ │ └── KMPObservableViewModelSampleTests.swift │ └── KMPObservableViewModelSampleUITests │ │ ├── KMPObservableViewModelSampleUITests.swift │ │ └── KMPObservableViewModelSampleUITestsLaunchTests.swift ├── settings.gradle.kts └── shared │ ├── build.gradle.kts │ └── src │ ├── androidMain │ └── kotlin │ │ └── com │ │ └── rickclephas │ │ └── kmp │ │ └── observableviewmodel │ │ └── sample │ │ └── shared │ │ ├── Clock.kt │ │ └── Platform.kt │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── rickclephas │ │ └── kmp │ │ └── observableviewmodel │ │ └── sample │ │ └── shared │ │ ├── Clock.kt │ │ ├── Greeting.kt │ │ ├── Platform.kt │ │ ├── TimeTravelScreen.kt │ │ ├── TimeTravelViewModel.kt │ │ └── TravelEffect.kt │ └── iosMain │ └── kotlin │ └── com │ └── rickclephas │ └── kmp │ └── observableviewmodel │ └── sample │ └── shared │ ├── Clock.kt │ ├── Platform.kt │ └── TimeTravelViewController.kt └── settings.gradle.kts /.github/actions/cache-konan/action.yaml: -------------------------------------------------------------------------------- 1 | name: Cache Konan 2 | description: Caches Konan files 3 | runs: 4 | using: composite 5 | steps: 6 | - uses: actions/cache@v4 7 | with: 8 | path: ~/.konan 9 | key: ${{ runner.os }}-konan-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} 10 | restore-keys: | 11 | ${{ runner.os }}-konan- 12 | -------------------------------------------------------------------------------- /.github/workflows/code-quality.yaml: -------------------------------------------------------------------------------- 1 | name: Qodana 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | branches: 6 | - master 7 | jobs: 8 | qodana: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | checks: write 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | ref: ${{ github.event.pull_request.head.sha }} 19 | fetch-depth: 0 20 | - id: get-java-home-11 21 | run: echo "JAVA_HOME_11_X64=$JAVA_HOME_11_X64" >> $GITHUB_OUTPUT 22 | - name: 'Qodana Scan' 23 | uses: JetBrains/qodana-action@v2024.1 24 | with: 25 | args: -v,${{ steps.get-java-home-11.outputs.JAVA_HOME_11_X64 }}:/root/.jdks/jdk11 26 | post-pr-comment: false 27 | env: 28 | QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/release-kotlin.yaml: -------------------------------------------------------------------------------- 1 | name: Publish a Kotlin release 2 | on: 3 | push: 4 | tags: 5 | - 'v[0-9]+.[0-9]+.[0-9]+' 6 | - 'v[0-9]+.[0-9]+.[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+' 7 | - 'v[0-9]+.[0-9]+.[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-Beta' 8 | - 'v[0-9]+.[0-9]+.[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-Beta[0-9]+' 9 | - 'v[0-9]+.[0-9]+.[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-RC' 10 | - 'v[0-9]+.[0-9]+.[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-RC[0-9]+' 11 | - 'v[0-9]+.[0-9]+.[0-9]+-BETA-[0-9]+' 12 | - 'v[0-9]+.[0-9]+.[0-9]+-BETA-[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+' 13 | - 'v[0-9]+.[0-9]+.[0-9]+-BETA-[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-Beta' 14 | - 'v[0-9]+.[0-9]+.[0-9]+-BETA-[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-Beta[0-9]+' 15 | - 'v[0-9]+.[0-9]+.[0-9]+-BETA-[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-RC' 16 | - 'v[0-9]+.[0-9]+.[0-9]+-BETA-[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-RC[0-9]+' 17 | - 'v[0-9]+.[0-9]+.[0-9]+-ALPHA-[0-9]+' 18 | - 'v[0-9]+.[0-9]+.[0-9]+-ALPHA-[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+' 19 | - 'v[0-9]+.[0-9]+.[0-9]+-ALPHA-[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-Beta' 20 | - 'v[0-9]+.[0-9]+.[0-9]+-ALPHA-[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-Beta[0-9]+' 21 | - 'v[0-9]+.[0-9]+.[0-9]+-ALPHA-[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-RC' 22 | - 'v[0-9]+.[0-9]+.[0-9]+-ALPHA-[0-9]+-kotlin-[0-9]+.[0-9]+.[0-9]+-RC[0-9]+' 23 | jobs: 24 | publish-kotlin-libraries: 25 | runs-on: macos-14 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | - name: Setup JDK 30 | uses: actions/setup-java@v4 31 | with: 32 | distribution: 'zulu' 33 | java-version: '17' 34 | - name: Setup Gradle 35 | uses: gradle/actions/setup-gradle@v4 36 | - name: Cache Konan 37 | uses: ./.github/actions/cache-konan 38 | - name: Publish to Maven Central 39 | run: ./gradlew publishToMavenCentral 40 | env: 41 | ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }} 42 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} 43 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_SECRET_KEY }} 44 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.OSSRH_USERNAME }} 45 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.OSSRH_PASSWORD }} 46 | -------------------------------------------------------------------------------- /.github/workflows/release-swift.yaml: -------------------------------------------------------------------------------- 1 | name: Publish a Swift release 2 | on: 3 | push: 4 | tags: 5 | - 'v[0-9]+.[0-9]+.[0-9]+' 6 | - 'v[0-9]+.[0-9]+.[0-9]+-BETA-[0-9]+' 7 | - 'v[0-9]+.[0-9]+.[0-9]+-ALPHA-[0-9]+' 8 | jobs: 9 | publish-cocoapods-libraries: 10 | runs-on: macos-14 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | - name: Setup Xcode 15 | uses: maxim-lobanov/setup-xcode@v1 16 | with: 17 | xcode-version: '15.0' 18 | - name: Publish KMPObservableViewModelCoreObjC 19 | run: pod trunk push KMPObservableViewModelCoreObjC.podspec --synchronous 20 | env: 21 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} 22 | - name: Publish KMPObservableViewModelCore 23 | run: pod trunk push KMPObservableViewModelCore.podspec --synchronous 24 | env: 25 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} 26 | - name: Publish KMPObservableViewModelSwiftUI 27 | run: pod trunk push KMPObservableViewModelSwiftUI.podspec --synchronous 28 | env: 29 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .kotlin/ 2 | local.properties 3 | 4 | # Created by https://www.toptal.com/developers/gitignore/api/swift,xcode,gradle,kotlin,intellij 5 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,xcode,gradle,kotlin,intellij 6 | 7 | ### Intellij ### 8 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 9 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 10 | 11 | # User-specific stuff 12 | .idea/**/workspace.xml 13 | .idea/**/tasks.xml 14 | .idea/**/usage.statistics.xml 15 | .idea/**/dictionaries 16 | .idea/**/shelf 17 | .idea/deploymentTargetDropDown.xml 18 | 19 | # AWS User-specific 20 | .idea/**/aws.xml 21 | 22 | # Generated files 23 | .idea/**/contentModel.xml 24 | 25 | # Sensitive or high-churn files 26 | .idea/**/dataSources/ 27 | .idea/**/dataSources.ids 28 | .idea/**/dataSources.local.xml 29 | .idea/**/sqlDataSources.xml 30 | .idea/**/dynamic.xml 31 | .idea/**/uiDesigner.xml 32 | .idea/**/dbnavigator.xml 33 | 34 | # Gradle 35 | .idea/**/gradle.xml 36 | .idea/**/libraries 37 | 38 | # Gradle and Maven with auto-import 39 | # When using Gradle or Maven with auto-import, you should exclude module files, 40 | # since they will be recreated, and may cause churn. Uncomment if using 41 | # auto-import. 42 | .idea/artifacts 43 | .idea/compiler.xml 44 | .idea/jarRepositories.xml 45 | .idea/modules.xml 46 | .idea/*.iml 47 | .idea/modules 48 | *.iml 49 | *.ipr 50 | 51 | # CMake 52 | cmake-build-*/ 53 | 54 | # Mongo Explorer plugin 55 | .idea/**/mongoSettings.xml 56 | 57 | # File-based project format 58 | *.iws 59 | 60 | # IntelliJ 61 | out/ 62 | 63 | # mpeltonen/sbt-idea plugin 64 | .idea_modules/ 65 | 66 | # JIRA plugin 67 | atlassian-ide-plugin.xml 68 | 69 | # Cursive Clojure plugin 70 | .idea/replstate.xml 71 | 72 | # SonarLint plugin 73 | .idea/sonarlint/ 74 | 75 | # Crashlytics plugin (for Android Studio and IntelliJ) 76 | com_crashlytics_export_strings.xml 77 | crashlytics.properties 78 | crashlytics-build.properties 79 | fabric.properties 80 | 81 | # Editor-based Rest Client 82 | .idea/httpRequests 83 | 84 | # Android studio 3.1+ serialized cache file 85 | .idea/caches/build_file_checksums.ser 86 | 87 | ### Intellij Patch ### 88 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 89 | 90 | # *.iml 91 | # modules.xml 92 | # .idea/misc.xml 93 | # *.ipr 94 | 95 | # Sonarlint plugin 96 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 97 | .idea/**/sonarlint/ 98 | 99 | # SonarQube Plugin 100 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 101 | .idea/**/sonarIssues.xml 102 | 103 | # Markdown Navigator plugin 104 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 105 | .idea/**/markdown-navigator.xml 106 | .idea/**/markdown-navigator-enh.xml 107 | .idea/**/markdown-navigator/ 108 | 109 | # Cache file creation bug 110 | # See https://youtrack.jetbrains.com/issue/JBR-2257 111 | .idea/$CACHE_FILE$ 112 | 113 | # CodeStream plugin 114 | # https://plugins.jetbrains.com/plugin/12206-codestream 115 | .idea/codestream.xml 116 | 117 | # Azure Toolkit for IntelliJ plugin 118 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 119 | .idea/**/azureSettings.xml 120 | 121 | ### Kotlin ### 122 | # Compiled class file 123 | *.class 124 | 125 | # Log file 126 | *.log 127 | 128 | # BlueJ files 129 | *.ctxt 130 | 131 | # Mobile Tools for Java (J2ME) 132 | .mtj.tmp/ 133 | 134 | # Package Files # 135 | *.jar 136 | *.war 137 | *.nar 138 | *.ear 139 | *.zip 140 | *.tar.gz 141 | *.rar 142 | 143 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 144 | hs_err_pid* 145 | replay_pid* 146 | 147 | ### Swift ### 148 | # Xcode 149 | # 150 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 151 | 152 | ## User settings 153 | xcuserdata/ 154 | 155 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 156 | *.xcscmblueprint 157 | *.xccheckout 158 | 159 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 160 | build/ 161 | DerivedData/ 162 | *.moved-aside 163 | *.pbxuser 164 | !default.pbxuser 165 | *.mode1v3 166 | !default.mode1v3 167 | *.mode2v3 168 | !default.mode2v3 169 | *.perspectivev3 170 | !default.perspectivev3 171 | 172 | ## Obj-C/Swift specific 173 | *.hmap 174 | 175 | ## App packaging 176 | *.ipa 177 | *.dSYM.zip 178 | *.dSYM 179 | 180 | ## Playgrounds 181 | timeline.xctimeline 182 | playground.xcworkspace 183 | 184 | # Swift Package Manager 185 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 186 | # Packages/ 187 | # Package.pins 188 | # Package.resolved 189 | # *.xcodeproj 190 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 191 | # hence it is not needed unless you have added a package configuration file to your project 192 | # .swiftpm 193 | 194 | .build/ 195 | 196 | # CocoaPods 197 | # We recommend against adding the Pods directory to your .gitignore. However 198 | # you should judge for yourself, the pros and cons are mentioned at: 199 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 200 | # Pods/ 201 | # Add this line if you want to avoid checking in source code from the Xcode workspace 202 | # *.xcworkspace 203 | 204 | # Carthage 205 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 206 | # Carthage/Checkouts 207 | 208 | Carthage/Build/ 209 | 210 | # Accio dependency management 211 | Dependencies/ 212 | .accio/ 213 | 214 | # fastlane 215 | # It is recommended to not store the screenshots in the git repo. 216 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 217 | # For more information about the recommended setup visit: 218 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 219 | 220 | fastlane/report.xml 221 | fastlane/Preview.html 222 | fastlane/screenshots/**/*.png 223 | fastlane/test_output 224 | 225 | # Code Injection 226 | # After new code Injection tools there's a generated folder /iOSInjectionProject 227 | # https://github.com/johnno1962/injectionforxcode 228 | 229 | iOSInjectionProject/ 230 | 231 | ### Xcode ### 232 | 233 | ## Xcode 8 and earlier 234 | 235 | ### Xcode Patch ### 236 | *.xcodeproj/* 237 | !*.xcodeproj/project.pbxproj 238 | !*.xcodeproj/xcshareddata/ 239 | !*.xcodeproj/project.xcworkspace/ 240 | !*.xcworkspace/contents.xcworkspacedata 241 | /*.gcno 242 | **/xcshareddata/WorkspaceSettings.xcsettings 243 | 244 | ### Gradle ### 245 | .gradle 246 | **/build/ 247 | !src/**/build/ 248 | 249 | # Ignore Gradle GUI config 250 | gradle-app.setting 251 | 252 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 253 | !gradle-wrapper.jar 254 | 255 | # Avoid ignore Gradle wrappper properties 256 | !gradle-wrapper.properties 257 | 258 | # Cache of project 259 | .gradletasknamecache 260 | 261 | # Eclipse Gradle plugin generated files 262 | # Eclipse Core 263 | .project 264 | # JDT-specific (Eclipse Java Development Tools) 265 | .classpath 266 | 267 | ### Gradle Patch ### 268 | # Java heap dump 269 | *.hprof 270 | 271 | # End of https://www.toptal.com/developers/gitignore/api/swift,xcode,gradle,kotlin,intellij 272 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | kmp-observableviewmodel -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /KMPObservableViewModelCore.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'KMPObservableViewModelCore' 3 | s.version = '1.0.0-BETA-14' 4 | s.summary = 'Library to share Kotlin ViewModels with Swift' 5 | 6 | s.homepage = 'https://github.com/rickclephas/KMP-ObservableViewModel' 7 | s.license = 'MIT' 8 | s.authors = 'Rick Clephas' 9 | 10 | s.source = { 11 | :git => 'https://github.com/rickclephas/KMP-ObservableViewModel.git', 12 | :tag => 'v' + s.version.to_s 13 | } 14 | 15 | s.swift_versions = ['5.0'] 16 | s.ios.deployment_target = '13.0' 17 | s.osx.deployment_target = '10.15' 18 | s.watchos.deployment_target = '6.0' 19 | s.tvos.deployment_target = '13.0' 20 | 21 | s.dependency 'KMPObservableViewModelCoreObjC', s.version.to_s 22 | s.framework = 'Combine' 23 | 24 | s.source_files = 'KMPObservableViewModelCore/**/*.swift' 25 | end 26 | -------------------------------------------------------------------------------- /KMPObservableViewModelCore/ChildViewModels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChildViewModels.swift 3 | // KMPObservableViewModelCore 4 | // 5 | // Created by Rick Clephas on 02/07/2023. 6 | // 7 | 8 | import KMPObservableViewModelCoreObjC 9 | 10 | public extension ViewModel { 11 | 12 | private func setChildViewModelPublishers(_ keyPath: AnyKeyPath, _ publishers: AnyHashable?) { 13 | if let publishers = publishers { 14 | observableViewModelPublishers(for: self).childPublishers[keyPath] = publishers 15 | } else { 16 | observableViewModelPublishers(for: self).childPublishers.removeValue(forKey: keyPath) 17 | } 18 | } 19 | 20 | private func setChildViewModel( 21 | _ viewModel: VM?, 22 | at keyPath: AnyKeyPath 23 | ) { 24 | setChildViewModelPublishers(keyPath, observableViewModelPublishers(for: viewModel)) 25 | } 26 | 27 | /// Stores a reference to the `ObservableObject` for the specified child `ViewModel`. 28 | func childViewModel( 29 | at keyPath: KeyPath 30 | ) -> VM? { 31 | let viewModel = self[keyPath: keyPath] 32 | setChildViewModel(viewModel, at: keyPath) 33 | return viewModel 34 | } 35 | 36 | /// Stores a reference to the `ObservableObject` for the specified child `ViewModel`. 37 | func childViewModel( 38 | at keyPath: KeyPath 39 | ) -> VM { 40 | let viewModel = self[keyPath: keyPath] 41 | setChildViewModel(viewModel, at: keyPath) 42 | return viewModel 43 | } 44 | 45 | // MARK: Arrays 46 | 47 | private func setChildViewModels( 48 | _ viewModels: [VM?]?, 49 | at keyPath: AnyKeyPath 50 | ) { 51 | setChildViewModelPublishers(keyPath, viewModels?.map { viewModel in 52 | observableViewModelPublishers(for: viewModel) 53 | }) 54 | } 55 | 56 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 57 | func childViewModels( 58 | at keyPath: KeyPath 59 | ) -> [VM?]? { 60 | let viewModels = self[keyPath: keyPath] 61 | setChildViewModels(viewModels, at: keyPath) 62 | return viewModels 63 | } 64 | 65 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 66 | func childViewModels( 67 | at keyPath: KeyPath 68 | ) -> [VM?] { 69 | let viewModels = self[keyPath: keyPath] 70 | setChildViewModels(viewModels, at: keyPath) 71 | return viewModels 72 | } 73 | 74 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 75 | func childViewModels( 76 | at keyPath: KeyPath 77 | ) -> [VM]? { 78 | let viewModels = self[keyPath: keyPath] 79 | setChildViewModels(viewModels, at: keyPath) 80 | return viewModels 81 | } 82 | 83 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 84 | func childViewModels( 85 | at keyPath: KeyPath 86 | ) -> [VM] { 87 | let viewModels = self[keyPath: keyPath] 88 | setChildViewModels(viewModels, at: keyPath) 89 | return viewModels 90 | } 91 | 92 | // MARK: Sets 93 | 94 | private func setChildViewModels( 95 | _ viewModels: Set?, 96 | at keyPath: AnyKeyPath 97 | ) { 98 | setChildViewModelPublishers(keyPath, viewModels?.map { viewModel in 99 | observableViewModelPublishers(for: viewModel) 100 | }) 101 | } 102 | 103 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 104 | func childViewModels( 105 | at keyPath: KeyPath?> 106 | ) -> Set? { 107 | let viewModels = self[keyPath: keyPath] 108 | setChildViewModels(viewModels, at: keyPath) 109 | return viewModels 110 | } 111 | 112 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 113 | func childViewModels( 114 | at keyPath: KeyPath> 115 | ) -> Set { 116 | let viewModels = self[keyPath: keyPath] 117 | setChildViewModels(viewModels, at: keyPath) 118 | return viewModels 119 | } 120 | 121 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 122 | func childViewModels( 123 | at keyPath: KeyPath?> 124 | ) -> Set? { 125 | let viewModels = self[keyPath: keyPath] 126 | setChildViewModels(viewModels, at: keyPath) 127 | return viewModels 128 | } 129 | 130 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 131 | func childViewModels( 132 | at keyPath: KeyPath> 133 | ) -> Set { 134 | let viewModels = self[keyPath: keyPath] 135 | setChildViewModels(viewModels, at: keyPath) 136 | return viewModels 137 | } 138 | 139 | // MARK: Dictionaries 140 | 141 | private func setChildViewModels( 142 | _ viewModels: [Key : VM?]?, 143 | at keyPath: AnyKeyPath 144 | ) { 145 | setChildViewModelPublishers(keyPath, viewModels?.mapValues { viewModel in 146 | observableViewModelPublishers(for: viewModel) 147 | }) 148 | } 149 | 150 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 151 | func childViewModels( 152 | at keyPath: KeyPath 153 | ) -> [Key : VM?]? { 154 | let viewModels = self[keyPath: keyPath] 155 | setChildViewModels(viewModels, at: keyPath) 156 | return viewModels 157 | } 158 | 159 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 160 | func childViewModels( 161 | at keyPath: KeyPath 162 | ) -> [Key : VM?] { 163 | let viewModels = self[keyPath: keyPath] 164 | setChildViewModels(viewModels, at: keyPath) 165 | return viewModels 166 | } 167 | 168 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 169 | func childViewModels( 170 | at keyPath: KeyPath 171 | ) -> [Key : VM]? { 172 | let viewModels = self[keyPath: keyPath] 173 | setChildViewModels(viewModels, at: keyPath) 174 | return viewModels 175 | } 176 | 177 | /// Stores references to the `ObservableObject`s of the specified child `ViewModel`s. 178 | func childViewModels( 179 | at keyPath: KeyPath 180 | ) -> [Key : VM] { 181 | let viewModels = self[keyPath: keyPath] 182 | setChildViewModels(viewModels, at: keyPath) 183 | return viewModels 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /KMPObservableViewModelCore/ObservableViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObservableViewModel.swift 3 | // KMPObservableViewModelCore 4 | // 5 | // Created by Rick Clephas on 27/11/2022. 6 | // 7 | 8 | import Combine 9 | import KMPObservableViewModelCoreObjC 10 | 11 | /// Gets an `ObservableObject` for the specified `ViewModel`. 12 | /// - Parameter viewModel: The `ViewModel` to wrap in an `ObservableObject`. 13 | public func observableViewModel( 14 | for viewModel: VM 15 | ) -> ObservableViewModel { 16 | let publishers = observableViewModelPublishers(for: viewModel) 17 | return ObservableViewModel(publishers, viewModel) 18 | } 19 | 20 | /// Gets an `ObservableObject` for the specified `ViewModel`. 21 | /// - Parameter viewModel: The `ViewModel` to wrap in an `ObservableObject`. 22 | public func observableViewModel( 23 | for viewModel: VM? 24 | ) -> ObservableViewModel? { 25 | guard let viewModel = viewModel else { return nil } 26 | let observableViewModel = observableViewModel(for: viewModel) 27 | return observableViewModel 28 | } 29 | 30 | /// An `ObservableObject` for a `ViewModel`. 31 | public final class ObservableViewModel: ObservableObject, Hashable { 32 | 33 | public let objectWillChange: ObservableViewModelPublisher 34 | 35 | /// The observed `ViewModel`. 36 | public let viewModel: VM 37 | 38 | /// Holds a strong reference to the publishers 39 | private let publishers: ObservableViewModelPublishers 40 | 41 | internal init(_ publishers: ObservableViewModelPublishers, _ viewModel: VM) { 42 | objectWillChange = publishers.publisher 43 | self.viewModel = viewModel 44 | self.publishers = publishers 45 | } 46 | 47 | public static func == (lhs: ObservableViewModel, rhs: ObservableViewModel) -> Bool { 48 | return ObjectIdentifier(lhs) == ObjectIdentifier(rhs) 49 | } 50 | 51 | public func hash(into hasher: inout Hasher) { 52 | hasher.combine(ObjectIdentifier(self)) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /KMPObservableViewModelCore/ObservableViewModelPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObservableViewModelPublisher.swift 3 | // KMPObservableViewModelCore 4 | // 5 | // Created by Rick Clephas on 20/09/2023. 6 | // 7 | 8 | import Combine 9 | import KMPObservableViewModelCoreObjC 10 | 11 | /// Publisher for `ObservableViewModel` that connects to the `ViewModelScope`. 12 | public final class ObservableViewModelPublisher: Publisher { 13 | public typealias Output = Void 14 | public typealias Failure = Never 15 | 16 | internal weak var viewModel: (any ViewModel)? 17 | 18 | private let publisher = ObservableObjectPublisher() 19 | private var objectWillChangeCancellable: AnyCancellable? = nil 20 | 21 | internal init(_ viewModel: any ViewModel, _ objectWillChange: ObservableObjectPublisher) { 22 | self.viewModel = viewModel 23 | viewModel.viewModelScope.setSendObjectWillChange { [weak self] in 24 | self?.publisher.send() 25 | } 26 | objectWillChangeCancellable = objectWillChange.sink { [weak self] _ in 27 | self?.publisher.send() 28 | } 29 | } 30 | 31 | public func receive(subscriber: S) where S : Subscriber, Never == S.Failure, Void == S.Input { 32 | viewModel?.viewModelScope.increaseSubscriptionCount() 33 | publisher.receive(subscriber: ObservableViewModelSubscriber(self, subscriber)) 34 | } 35 | 36 | deinit { 37 | guard let viewModel else { return } 38 | if let cancellable = viewModel as? Cancellable { 39 | cancellable.cancel() 40 | } 41 | viewModel.clear() 42 | } 43 | } 44 | 45 | /// Subscriber for `ObservableViewModelPublisher` that creates `ObservableViewModelSubscription`s. 46 | private class ObservableViewModelSubscriber: Subscriber where S : Subscriber, Never == S.Failure, Void == S.Input { 47 | typealias Input = Void 48 | typealias Failure = Never 49 | 50 | private let publisher: ObservableViewModelPublisher 51 | private let subscriber: S 52 | 53 | init(_ publisher: ObservableViewModelPublisher, _ subscriber: S) { 54 | self.publisher = publisher 55 | self.subscriber = subscriber 56 | } 57 | 58 | func receive(subscription: Subscription) { 59 | subscriber.receive(subscription: ObservableViewModelSubscription(publisher, subscription)) 60 | } 61 | 62 | func receive(_ input: Void) -> Subscribers.Demand { 63 | subscriber.receive(input) 64 | } 65 | 66 | func receive(completion: Subscribers.Completion) { 67 | subscriber.receive(completion: completion) 68 | } 69 | } 70 | 71 | /// Subscription for `ObservableViewModelPublisher` that decreases the subscription count upon cancellation. 72 | private class ObservableViewModelSubscription: Subscription { 73 | 74 | private let publisher: ObservableViewModelPublisher 75 | private let subscription: Subscription 76 | 77 | init(_ publisher: ObservableViewModelPublisher, _ subscription: Subscription) { 78 | self.publisher = publisher 79 | self.subscription = subscription 80 | } 81 | 82 | func request(_ demand: Subscribers.Demand) { 83 | subscription.request(demand) 84 | } 85 | 86 | private var cancelled = false 87 | 88 | func cancel() { 89 | subscription.cancel() 90 | guard !cancelled else { return } 91 | cancelled = true 92 | publisher.viewModel?.viewModelScope.decreaseSubscriptionCount() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /KMPObservableViewModelCore/ObservableViewModelPublishers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObservableViewModelPublishers.swift 3 | // KMPObservableViewModelCore 4 | // 5 | // Created by Rick Clephas on 20/09/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | private var observableViewModelPublishersKey: Void? 11 | 12 | private class WeakObservableViewModelPublishers { 13 | weak var publishers: ObservableViewModelPublishers? 14 | init(_ publishers: ObservableViewModelPublishers) { 15 | self.publishers = publishers 16 | } 17 | } 18 | 19 | /// Gets the `ObservableViewModelPublishers` for the specified `viewModel`. 20 | internal func observableViewModelPublishers( 21 | for viewModel: VM 22 | ) -> ObservableViewModelPublishers { 23 | let publishers: ObservableViewModelPublishers 24 | if let object = objc_getAssociatedObject(viewModel, &observableViewModelPublishersKey) { 25 | publishers = (object as! WeakObservableViewModelPublishers).publishers ?? { 26 | fatalError("ObservableViewModel has been deallocated") 27 | }() 28 | } else { 29 | let publisher = ObservableViewModelPublisher(viewModel, viewModel.objectWillChange) 30 | publishers = ObservableViewModelPublishers(publisher) 31 | let object = WeakObservableViewModelPublishers(publishers) 32 | objc_setAssociatedObject(viewModel, &observableViewModelPublishersKey, object, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 33 | } 34 | return publishers 35 | } 36 | 37 | /// Gets the `ObservableViewModelPublishers` for the specified `viewModel`. 38 | internal func observableViewModelPublishers( 39 | for viewModel: VM? 40 | ) -> ObservableViewModelPublishers? { 41 | guard let viewModel = viewModel else { return nil } 42 | let observableViewModelPublishers = observableViewModelPublishers(for: viewModel) 43 | return observableViewModelPublishers 44 | } 45 | 46 | /// Helper object that keeps strong references to the `ObservableViewModelPublisher`s. 47 | internal final class ObservableViewModelPublishers: Hashable { 48 | let publisher: ObservableViewModelPublisher 49 | var childPublishers: Dictionary = [:] 50 | 51 | init(_ publisher: ObservableViewModelPublisher) { 52 | self.publisher = publisher 53 | } 54 | 55 | static func == (lhs: ObservableViewModelPublishers, rhs: ObservableViewModelPublishers) -> Bool { 56 | return ObjectIdentifier(lhs) == ObjectIdentifier(rhs) 57 | } 58 | 59 | func hash(into hasher: inout Hasher) { 60 | hasher.combine(ObjectIdentifier(self)) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /KMPObservableViewModelCore/ViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModel.swift 3 | // KMPObservableViewModelCore 4 | // 5 | // Created by Rick Clephas on 28/11/2022. 6 | // 7 | 8 | import Combine 9 | import KMPObservableViewModelCoreObjC 10 | 11 | /// A Kotlin Multiplatform Mobile ViewModel. 12 | public protocol ViewModel: ObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher { 13 | /// The `ViewModelScope` of this `ViewModel`. 14 | var viewModelScope: ViewModelScope { get } 15 | /// Internal KMP-ObservableViewModel function used to clear the ViewModel. 16 | /// - Warning: You should NOT call this yourself! 17 | func clear() 18 | } 19 | -------------------------------------------------------------------------------- /KMPObservableViewModelCoreObjC.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'KMPObservableViewModelCoreObjC' 3 | s.version = '1.0.0-BETA-14' 4 | s.summary = 'Library to share Kotlin ViewModels with Swift' 5 | 6 | s.homepage = 'https://github.com/rickclephas/KMP-ObservableViewModel' 7 | s.license = 'MIT' 8 | s.authors = 'Rick Clephas' 9 | 10 | s.source = { 11 | :git => 'https://github.com/rickclephas/KMP-ObservableViewModel.git', 12 | :tag => 'v' + s.version.to_s 13 | } 14 | 15 | s.ios.deployment_target = '13.0' 16 | s.osx.deployment_target = '10.15' 17 | s.watchos.deployment_target = '6.0' 18 | s.tvos.deployment_target = '13.0' 19 | 20 | s.source_files = 'KMPObservableViewModelCoreObjC/**/*.{h,m}' 21 | end 22 | -------------------------------------------------------------------------------- /KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h: -------------------------------------------------------------------------------- 1 | // 2 | // KMPOVMViewModelScope.h 3 | // KMPObservableViewModelCoreObjC 4 | // 5 | // Created by Rick Clephas on 27/11/2022. 6 | // 7 | 8 | #ifndef KMPOVMViewModelScope_h 9 | #define KMPOVMViewModelScope_h 10 | 11 | #import 12 | 13 | __attribute__((swift_name("ViewModelScope"))) 14 | @protocol KMPOVMViewModelScope 15 | - (void)increaseSubscriptionCount; 16 | - (void)decreaseSubscriptionCount; 17 | - (void)setSendObjectWillChange:(void (^ _Nonnull)(void))sendObjectWillChange; 18 | @end 19 | 20 | #endif /* KMPOVMViewModelScope_h */ 21 | -------------------------------------------------------------------------------- /KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h: -------------------------------------------------------------------------------- 1 | // 2 | // KMPObservableViewModelCoreObjC.h 3 | // KMPObservableViewModelCoreObjC 4 | // 5 | // Created by Rick Clephas on 27/11/2022. 6 | // 7 | 8 | #import "KMPOVMViewModelScope.h" 9 | -------------------------------------------------------------------------------- /KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.m: -------------------------------------------------------------------------------- 1 | // We need this empty file, else SPM won't build this 2 | -------------------------------------------------------------------------------- /KMPObservableViewModelSwiftUI.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'KMPObservableViewModelSwiftUI' 3 | s.version = '1.0.0-BETA-14' 4 | s.summary = 'Library to share Kotlin ViewModels with SwiftUI' 5 | 6 | s.homepage = 'https://github.com/rickclephas/KMP-ObservableViewModel' 7 | s.license = 'MIT' 8 | s.authors = 'Rick Clephas' 9 | 10 | s.source = { 11 | :git => 'https://github.com/rickclephas/KMP-ObservableViewModel.git', 12 | :tag => 'v' + s.version.to_s 13 | } 14 | 15 | s.swift_versions = ['5.0'] 16 | s.ios.deployment_target = '13.0' 17 | s.osx.deployment_target = '10.15' 18 | s.watchos.deployment_target = '6.0' 19 | s.tvos.deployment_target = '13.0' 20 | 21 | s.dependency 'KMPObservableViewModelCore', s.version.to_s 22 | s.framework = 'SwiftUI' 23 | 24 | s.source_files = 'KMPObservableViewModelSwiftUI/**/*.swift' 25 | end 26 | -------------------------------------------------------------------------------- /KMPObservableViewModelSwiftUI/EnvironmentViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentViewModel.swift 3 | // KMPObservableViewModelSwiftUI 4 | // 5 | // Created by Rick Clephas on 27/11/2022. 6 | // 7 | 8 | import SwiftUI 9 | import KMPObservableViewModelCore 10 | import KMPObservableViewModelCoreObjC 11 | 12 | /// An `EnvironmentObject` property wrapper for `ViewModel`s. 13 | @propertyWrapper 14 | public struct EnvironmentViewModel: DynamicProperty { 15 | 16 | @EnvironmentObject private var observableObject: ObservableViewModel 17 | 18 | /// The underlying `ViewModel` referenced by the `EnvironmentViewModel`. 19 | public var wrappedValue: VM { observableObject.viewModel } 20 | 21 | /// A projection of the observed `ViewModel` that creates bindings to its properties using dynamic member lookup. 22 | public var projectedValue: ObservableViewModel.Projection { 23 | ObservableViewModel.Projection(observableObject) 24 | } 25 | 26 | /// Creates an `EnvironmentViewModel`. 27 | public init() { } 28 | } 29 | 30 | public extension View { 31 | /// Supplies a `ViewModel` to a view subhierarchy. 32 | /// - Parameter viewModel: The `ViewModel` to supply to a view subhierarchy. 33 | func environmentViewModel(_ viewModel: VM) -> some View { 34 | environmentObject(observableViewModel(for: viewModel)) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /KMPObservableViewModelSwiftUI/ObservableViewModelProjection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObservableViewModelProjection.swift 3 | // KMPObservableViewModelSwiftUI 4 | // 5 | // Created by Rick Clephas on 27/11/2022. 6 | // 7 | 8 | import SwiftUI 9 | import KMPObservableViewModelCore 10 | 11 | public extension ObservableViewModel { 12 | 13 | /// A projection of a `ViewModel` that creates bindings to its properties using dynamic member lookup. 14 | @dynamicMemberLookup 15 | struct Projection { 16 | 17 | internal let observableObject: ObservableViewModel 18 | 19 | internal init(_ observableObject: ObservableViewModel) { 20 | self.observableObject = observableObject 21 | } 22 | 23 | public subscript(dynamicMember keyPath: WritableKeyPath) -> Binding { 24 | Binding { 25 | observableObject.viewModel[keyPath: keyPath] 26 | } set: { value in 27 | var viewModel = observableObject.viewModel 28 | viewModel[keyPath: keyPath] = value 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /KMPObservableViewModelSwiftUI/ObservedViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObservedViewModel.swift 3 | // KMPObservableViewModelSwiftUI 4 | // 5 | // Created by Rick Clephas on 27/11/2022. 6 | // 7 | 8 | import SwiftUI 9 | import KMPObservableViewModelCore 10 | import KMPObservableViewModelCoreObjC 11 | 12 | /// An `ObservedObject` property wrapper for `ViewModel`s. 13 | @propertyWrapper 14 | public struct ObservedViewModel: DynamicProperty { 15 | 16 | @ObservedObject private var observableObject: ObservableViewModel 17 | 18 | /// A projection of the observed `ViewModel` that creates bindings to its properties using dynamic member lookup. 19 | public var projectedValue: ObservableViewModel.Projection 20 | 21 | /// The underlying `ViewModel` referenced by the `ObservedViewModel`. 22 | public var wrappedValue: VM { 23 | get { observableObject.viewModel } 24 | set { 25 | let observableObject = observableViewModel(for: newValue) 26 | self.observableObject = observableObject 27 | self.projectedValue = ObservableViewModel.Projection(observableObject) 28 | } 29 | } 30 | 31 | /// Creates an `ObservedViewModel` for the specified `ViewModel`. 32 | /// - Parameter wrappedValue: The `ViewModel` to observe. 33 | public init(wrappedValue: VM) { 34 | let observableObject = observableViewModel(for: wrappedValue) 35 | self.observableObject = observableObject 36 | self.projectedValue = ObservableViewModel.Projection(observableObject) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /KMPObservableViewModelSwiftUI/StateViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateViewModel.swift 3 | // KMPObservableViewModelSwiftUI 4 | // 5 | // Created by Rick Clephas on 27/11/2022. 6 | // 7 | 8 | import SwiftUI 9 | import KMPObservableViewModelCore 10 | import KMPObservableViewModelCoreObjC 11 | 12 | /// A `StateObject` property wrapper for `ViewModel`s. 13 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 14 | @propertyWrapper 15 | public struct StateViewModel: DynamicProperty { 16 | 17 | @StateObject private var observableObject: ObservableViewModel 18 | 19 | /// The underlying `ViewModel` referenced by the `StateViewModel`. 20 | public var wrappedValue: VM { observableObject.viewModel } 21 | 22 | /// A projection of the observed `ViewModel` that creates bindings to its properties using dynamic member lookup. 23 | public var projectedValue: ObservableViewModel.Projection { 24 | ObservableViewModel.Projection(observableObject) 25 | } 26 | 27 | /// Creates a `StateViewModel` for the specified `ViewModel`. 28 | /// - Parameter wrappedValue: The `ViewModel` to observe. 29 | public init(wrappedValue: @autoclosure @escaping () -> VM) { 30 | self._observableObject = StateObject(wrappedValue: observableViewModel(for: wrappedValue())) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Rick Clephas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "KMPObservableViewModel", 6 | platforms: [.iOS(.v13), .macOS(.v10_15), .tvOS(.v13), .watchOS(.v6)], 7 | products: [ 8 | .library( 9 | name: "KMPObservableViewModelCore", 10 | targets: ["KMPObservableViewModelCore"] 11 | ), 12 | .library( 13 | name: "KMPObservableViewModelSwiftUI", 14 | targets: ["KMPObservableViewModelSwiftUI"] 15 | ) 16 | ], 17 | targets: [ 18 | .target( 19 | name: "KMPObservableViewModelCoreObjC", 20 | path: "KMPObservableViewModelCoreObjC", 21 | publicHeadersPath: "." 22 | ), 23 | .target( 24 | name: "KMPObservableViewModelCore", 25 | dependencies: [.target(name: "KMPObservableViewModelCoreObjC")], 26 | path: "KMPObservableViewModelCore" 27 | ), 28 | .target( 29 | name: "KMPObservableViewModelSwiftUI", 30 | dependencies: [.target(name: "KMPObservableViewModelCore")], 31 | path: "KMPObservableViewModelSwiftUI" 32 | ) 33 | ], 34 | swiftLanguageVersions: [.v5] 35 | ) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KMP-ObservableViewModel 2 | 3 | A library (previously known as KMM-ViewModel) that allows you to use AndroidX/Kotlin ViewModels with SwiftUI. 4 | 5 | ## Compatibility 6 | 7 | You can use this library in any KMP project, 8 | but not all targets support AndroidX and/or SwiftUI interop: 9 | 10 | | Target | Supported | AndroidX | SwiftUI | 11 | |------------|:-----------:|:--------:|:-------:| 12 | | Android | ✅ | ✅ | - | 13 | | JVM | ✅ | ✅ | - | 14 | | iOS | ✅ | ✅ | ✅ | 15 | | macOS | ✅ | ✅ | ✅ | 16 | | tvOS | ✅ | - | ✅ | 17 | | watchOS | ✅ | - | ✅ | 18 | | linuxX64 | ✅ | ✅ | - | 19 | | linuxArm64 | ✅ | - | - | 20 | | mingwX64 | ✅ | - | - | 21 | | JS | ✅ | - | - | 22 | | Wasm | ✅ | - | - | 23 | 24 | The latest version of the library uses Kotlin version `2.2.20`. 25 | Compatibility versions for older and/or preview Kotlin versions are also available: 26 | 27 | | Version | Version suffix | Kotlin | Coroutines | AndroidX Lifecycle | 28 | |---------------|---------------------|:----------:|:----------:|:------------------:| 29 | | **_latest_** | **_no suffix_** | **2.2.20** | **1.10.1** | **2.8.7** | 30 | | 1.0.0-BETA-13 | _no suffix_ | 2.2.10 | 1.10.1 | 2.8.7 | 31 | | 1.0.0-BETA-12 | _no suffix_ | 2.2.0 | 1.10.1 | 2.8.7 | 32 | | 1.0.0-BETA-11 | _no suffix_ | 2.1.21 | 1.10.1 | 2.8.7 | 33 | | 1.0.0-BETA-10 | _no suffix_ | 2.1.20 | 1.10.1 | 2.8.7 | 34 | | 1.0.0-BETA-9 | _no suffix_ | 2.1.10 | 1.10.1 | 2.8.7 | 35 | | 1.0.0-BETA-8 | _no suffix_ | 2.1.0 | 1.9.0 | 2.8.4 | 36 | | 1.0.0-BETA-7 | _no suffix_ | 2.0.21 | 1.9.0 | 2.8.4 | 37 | | 1.0.0-BETA-6 | _no suffix_ | 2.0.20 | 1.9.0 | 2.8.4 | 38 | | 1.0.0-BETA-4 | _no suffix_ | 2.0.10 | 1.8.1 | 2.8.4 | 39 | | 1.0.0-BETA-3 | _no suffix_ | 2.0.0 | 1.8.1 | 2.8.0 | 40 | | 1.0.0-BETA-2 | _no suffix_ | 1.9.24 | 1.8.1 | 2.8.0 | 41 | 42 | ## Kotlin 43 | 44 | Add the library to your shared Kotlin module and opt-in to the `ExperimentalForeignApi`: 45 | ```kotlin 46 | kotlin { 47 | sourceSets { 48 | all { 49 | languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") 50 | } 51 | commonMain { 52 | dependencies { 53 | api("com.rickclephas.kmp:kmp-observableviewmodel-core:1.0.0-BETA-14") 54 | } 55 | } 56 | } 57 | } 58 | ``` 59 | 60 | And create your ViewModels: 61 | ```kotlin 62 | import com.rickclephas.kmp.observableviewmodel.ViewModel 63 | import com.rickclephas.kmp.observableviewmodel.MutableStateFlow 64 | import com.rickclephas.kmp.observableviewmodel.stateIn 65 | 66 | open class TimeTravelViewModel: ViewModel() { 67 | 68 | private val clockTime = Clock.time 69 | 70 | /** 71 | * A [StateFlow] that emits the actual time. 72 | */ 73 | val actualTime = clockTime.map { formatTime(it) } 74 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "N/A") 75 | 76 | private val _travelEffect = MutableStateFlow(viewModelScope, null) 77 | /** 78 | * A [StateFlow] that emits the applied [TravelEffect]. 79 | */ 80 | val travelEffect = _travelEffect.asStateFlow() 81 | } 82 | ``` 83 | 84 | As you might notice it isn't much different from an AndroidX ViewModel. 85 | We are obviously using a different `ViewModel` superclass: 86 | 87 | ```diff 88 | - import androidx.lifecycle.ViewModel 89 | + import com.rickclephas.kmp.observableviewmodel.ViewModel 90 | 91 | open class TimeTravelViewModel: ViewModel() { 92 | ``` 93 | 94 | But besides that there are only 2 minor differences. 95 | The first being a different import for `stateIn`: 96 | 97 | ```diff 98 | - import kotlinx.coroutines.flow.stateIn 99 | + import com.rickclephas.kmp.observableviewmodel.stateIn 100 | 101 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "N/A") 102 | ``` 103 | 104 | And the second being a different `MutableStateFlow` constructor: 105 | 106 | ```diff 107 | - import kotlinx.coroutines.flow.MutableStateFlow 108 | + import com.rickclephas.kmp.observableviewmodel.MutableStateFlow 109 | 110 | - private val _travelEffect = MutableStateFlow(null) 111 | + private val _travelEffect = MutableStateFlow(viewModelScope, null) 112 | ``` 113 | 114 | These minor differences will make sure that state changes are propagated to SwiftUI. 115 | 116 | > [!NOTE] 117 | > `viewModelScope` is a wrapper around the actual `CoroutineScope` which can be accessed 118 | > via the `ViewModelScope.coroutineScope` property. 119 | 120 | ### KMP-NativeCoroutines 121 | 122 | I highly recommend you to use the `@NativeCoroutinesState` annotation from 123 | [KMP-NativeCoroutines](https://github.com/rickclephas/KMP-NativeCoroutines) 124 | to turn your `StateFlow`s into properties in Swift: 125 | 126 | ```kotlin 127 | @NativeCoroutinesState 128 | val travelEffect = _travelEffect.asStateFlow() 129 | ``` 130 | 131 | Checkout the KMP-NativeCoroutines [README](https://github.com/rickclephas/KMP-NativeCoroutines/blob/master/README.md) 132 | for more information and installation instructions. 133 | 134 |
Alternative 135 |

136 | 137 | Alternatively you can create extension properties in your iOS/Apple source-set yourself: 138 | ```kotlin 139 | val TimeTravelViewModel.travelEffectValue: TravelEffect? 140 | get() = travelEffect.value 141 | ``` 142 |

143 |
144 | 145 | ## Android 146 | 147 | Use the view model like you would any other AndroidX ViewModel: 148 | ```kotlin 149 | class TimeTravelFragment: Fragment(R.layout.fragment_time_travel) { 150 | private val viewModel: TimeTravelViewModel by viewModels() 151 | } 152 | ``` 153 | 154 | ## Swift 155 | 156 | After you have configured your `shared` Kotlin module and created a ViewModel it's time to configure your Swift project. 157 | Start by adding the Swift package to your `Package.swift` file: 158 | ```swift 159 | dependencies: [ 160 | .package(url: "https://github.com/rickclephas/KMP-ObservableViewModel.git", from: "1.0.0-BETA-14") 161 | ] 162 | ``` 163 | 164 | Or add it in Xcode by going to `File` > `Add Packages...` and providing the URL: 165 | `https://github.com/rickclephas/KMP-ObservableViewModel.git`. 166 | 167 |
CocoaPods 168 |

169 | 170 | If you like you can also use CocoaPods instead of SPM: 171 | ```ruby 172 | pod 'KMPObservableViewModelSwiftUI', '1.0.0-BETA-14' 173 | ``` 174 |

175 |
176 | 177 | Create a `KMPObservableViewModel.swift` file with the following contents: 178 | ```swift 179 | import KMPObservableViewModelCore 180 | import shared // This should be your shared KMP module 181 | 182 | extension Kmp_observableviewmodel_coreViewModel: ViewModel { } 183 | ``` 184 | 185 | After that you can use your view model almost as if it were an `ObservableObject`. 186 | Just use the view model specific property wrappers and functions: 187 | 188 | | `ObservableObject` | `ViewModel` | 189 | |-------------------------|----------------------------| 190 | | `@StateObject` | `@StateViewModel` | 191 | | `@ObservedObject` | `@ObservedViewModel` | 192 | | `@EnvironmentObject` | `@EnvironmentViewModel` | 193 | | `environmentObject(_:)` | `environmentViewModel(_:)` | 194 | 195 | E.g. to use the `TimeTravelViewModel` as a `StateObject`: 196 | ```swift 197 | import SwiftUI 198 | import KMPObservableViewModelSwiftUI 199 | import shared // This should be your shared KMP module 200 | 201 | struct ContentView: View { 202 | @StateViewModel var viewModel = TimeTravelViewModel() 203 | } 204 | ``` 205 | 206 | It's also possible to subclass your view model in Swift: 207 | ```swift 208 | import Combine 209 | import shared // This should be your shared KMP module 210 | 211 | class TimeTravelViewModel: shared.TimeTravelViewModel { 212 | @Published var isResetDisabled: Bool = false 213 | } 214 | ``` 215 | 216 | ### Child view models 217 | 218 | You'll need some additional logic if your `ViewModel`s expose child view models. 219 | 220 | First make sure to use the `NativeCoroutinesRefinedState` annotation instead of the `NativeCoroutinesState` annotation: 221 | ```kotlin 222 | class MyParentViewModel: ViewModel() { 223 | @NativeCoroutinesRefinedState 224 | val myChildViewModel: StateFlow = MutableStateFlow(null) 225 | } 226 | ``` 227 | 228 | After that you should create a Swift extension property using the `childViewModel(at:)` function: 229 | ```swift 230 | extension MyParentViewModel { 231 | var myChildViewModel: MyChildViewModel? { 232 | childViewModel(at: \.__myChildViewModel) 233 | } 234 | } 235 | ``` 236 | 237 | This will prevent your Swift view models from being deallocated too soon. 238 | 239 | > [!NOTE] 240 | > For lists, sets and dictionaries containing view models there is `childViewModels(at:)`. 241 | 242 | ### Cancellable ViewModel 243 | 244 | When subclassing your Kotlin ViewModel in Swift you might experience some issues in the way those view models are cleared. 245 | 246 | An example of such an issue is when you are using a Combine publisher to observe a Flow through KMP-NativeCoroutines: 247 | ```swift 248 | import Combine 249 | import KMPNativeCoroutinesCombine 250 | import shared // This should be your shared KMP module 251 | 252 | class TimeTravelViewModel: shared.TimeTravelViewModel { 253 | 254 | private var cancellables = Set() 255 | 256 | override init() { 257 | super.init() 258 | createPublisher(for: currentTimeFlow) 259 | .assertNoFailure() 260 | .sink { time in print("It's \(time)") } 261 | .store(in: &cancellables) 262 | } 263 | } 264 | ``` 265 | 266 | Since `currentTimeFlow` is a StateFlow we don't ever expect it to fail, which is why we are using the `assertNoFailure`. 267 | However, in this case you'll notice that the publisher will fail with a `JobCancellationException`. 268 | 269 | The problem here is that before the `TimeTravelViewModel` is deinited it will already be cleared. 270 | Meaning the `viewModelScope` is cancelled and `onCleared` is called. 271 | This results in the Combine publisher outliving the underlying StateFlow collection. 272 | 273 | To solve such issues you should have your Swift view model conform to `Cancellable` 274 | and perform the required cleanup in the `cancel` function: 275 | ```swift 276 | class TimeTravelViewModel: shared.TimeTravelViewModel, Cancellable { 277 | func cancel() { 278 | cancellables = [] 279 | } 280 | } 281 | ``` 282 | 283 | KMP-ObservableViewModel will make sure to call the `cancel` function before the ViewModel is being cleared. 284 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | gradlePluginPortal() 4 | mavenCentral() 5 | google() 6 | } 7 | } 8 | 9 | allprojects { 10 | group = "com.rickclephas.kmp" 11 | version = "1.0.0-BETA-14" 12 | } 13 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | dependencies { 6 | implementation(libs.kotlin.gradle.plugin) 7 | implementation(libs.android.library.gradle.plugin) 8 | implementation(libs.vanniktech.mavenPublish) 9 | } 10 | -------------------------------------------------------------------------------- /buildSrc/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | dependencyResolutionManagement { 4 | repositories { 5 | mavenCentral() 6 | gradlePluginPortal() 7 | google() 8 | } 9 | versionCatalogs { 10 | create("libs") { 11 | from(files("../gradle/libs.versions.toml")) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/kmp-observableviewmodel-android-library.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | } 4 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/kmp-observableviewmodel-kotlin-multiplatform.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("multiplatform") 3 | } 4 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/kmp-observableviewmodel-publish.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | import com.vanniktech.maven.publish.SonatypeHost 4 | 5 | plugins { 6 | id("com.vanniktech.maven.publish.base") 7 | } 8 | 9 | mavenPublishing { 10 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 11 | signAllPublications() 12 | configureBasedOnAppliedPlugins() 13 | pom { 14 | name = "KMP-ObservableViewModel" 15 | description = "Library to share Kotlin ViewModels with SwiftUI" 16 | url = "https://github.com/rickclephas/KMP-ObservableViewModel" 17 | licenses { 18 | license { 19 | name = "MIT" 20 | url = "https://opensource.org/licenses/MIT" 21 | } 22 | } 23 | developers { 24 | developer { 25 | id = "rickclephas" 26 | name = "Rick Clephas" 27 | email = "rclephas@gmail.com" 28 | } 29 | } 30 | scm { 31 | url = "https://github.com/rickclephas/KMP-ObservableViewModel" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | kotlin.mpp.androidSourceSetLayoutVersion=2 3 | kotlin.mpp.enableCInteropCommonization=true 4 | android.useAndroidX=true 5 | org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" 6 | 7 | # Disable BuildConfig: https://github.com/rickclephas/KMP-ObservableViewModel/issues/42 8 | android.defaults.buildfeatures.buildconfig=false 9 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kotlin = "2.2.20" 3 | kotlinx-coroutines = "1.10.1" 4 | android = "8.2.0" 5 | androidx-lifecycle = "2.8.7" 6 | atomicfu = "0.27.0" 7 | 8 | # Sample versions 9 | androidx-compose = "2023.10.01" 10 | androidx-fragment = "1.6.2" 11 | ksp = "2.2.20-2.0.2" 12 | nativecoroutines = "1.0.0-ALPHA-47" 13 | 14 | [libraries] 15 | kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } 16 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 17 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } 18 | android-library-gradle-plugin = { module = "com.android.library:com.android.library.gradle.plugin", version.ref = "android" } 19 | androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } 20 | vanniktech-mavenPublish = { module = "com.vanniktech:gradle-maven-publish-plugin", version = "0.32.0" } 21 | 22 | # Sample libraries 23 | androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose" } 24 | androidx-compose-ui = { module = "androidx.compose.ui:ui" } 25 | androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } 26 | androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } 27 | androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } 28 | androidx-compose-material = { module = "androidx.compose.material:material" } 29 | androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" } 30 | androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } 31 | 32 | [plugins] 33 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 34 | android-library = { id = "com.android.library", version.ref = "android" } 35 | atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } 36 | 37 | # Sample plugins 38 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 39 | jetbrains-compose = { id = "org.jetbrains.compose", version = "1.6.11" } 40 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 41 | android-application = { id = "com.android.application", version.ref = "android" } 42 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 43 | nativecoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "nativecoroutines" } 44 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rickclephas/KMP-ObservableViewModel/adca37e1bcd80a4e9dc6ee5f5cfd27b81201f8e6/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original 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 POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /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 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 2 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 3 | 4 | plugins { 5 | id("kmp-observableviewmodel-android-library") 6 | id("kmp-observableviewmodel-kotlin-multiplatform") 7 | id("kmp-observableviewmodel-publish") 8 | alias(libs.plugins.atomicfu) 9 | } 10 | 11 | kotlin { 12 | explicitApi() 13 | jvmToolchain(11) 14 | 15 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 16 | applyDefaultHierarchyTemplate { 17 | common { 18 | group("androidx") { 19 | withAndroidTarget() 20 | group("ios") 21 | withJvm() 22 | withLinuxX64() 23 | group("macos") 24 | } 25 | group("nonAndroidx") { 26 | withJs() 27 | withLinuxArm64() 28 | group("mingw") 29 | group("tvos") 30 | withWasmJs() 31 | group("watchos") 32 | } 33 | group("nonApple") { 34 | withAndroidTarget() 35 | withJvm() 36 | withJs() 37 | group("linux") 38 | group("mingw") 39 | withWasmJs() 40 | } 41 | } 42 | } 43 | 44 | listOf( 45 | macosX64(), macosArm64(), 46 | iosArm64(), iosX64(), iosSimulatorArm64(), 47 | watchosArm32(), watchosArm64(), watchosX64(), watchosSimulatorArm64(), watchosDeviceArm64(), 48 | tvosArm64(), tvosX64(), tvosSimulatorArm64(), 49 | ).forEach { 50 | it.compilations.getByName("main") { 51 | cinterops.create("KMPObservableViewModelCoreObjC") { 52 | includeDirs("$projectDir/../KMPObservableViewModelCoreObjC") 53 | } 54 | } 55 | } 56 | androidTarget() 57 | jvm() 58 | js { 59 | browser() 60 | nodejs() 61 | } 62 | linuxArm64() 63 | linuxX64() 64 | mingwX64() 65 | @OptIn(ExperimentalWasmDsl::class) 66 | wasmJs { 67 | browser() 68 | nodejs() 69 | d8() 70 | } 71 | 72 | targets.all { 73 | compilations.all { 74 | compileTaskProvider.configure { 75 | compilerOptions { 76 | freeCompilerArgs.add("-Xexpect-actual-classes") 77 | } 78 | } 79 | } 80 | } 81 | 82 | sourceSets { 83 | all { 84 | languageSettings { 85 | optIn("com.rickclephas.kmp.observableviewmodel.InternalKMPObservableViewModelApi") 86 | optIn("kotlinx.cinterop.ExperimentalForeignApi") 87 | } 88 | } 89 | 90 | commonMain { 91 | dependencies { 92 | api(libs.kotlinx.coroutines.core) 93 | } 94 | } 95 | commonTest { 96 | dependencies { 97 | implementation(libs.kotlin.test) 98 | } 99 | } 100 | 101 | val androidxMain by getting { 102 | dependencies { 103 | api(libs.androidx.lifecycle.viewmodel) 104 | } 105 | } 106 | } 107 | } 108 | 109 | android { 110 | namespace = "com.rickclephas.kmp.observableviewmodel" 111 | compileSdk = 33 112 | defaultConfig { 113 | minSdk = 19 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/androidxMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import androidx.lifecycle.ViewModel as AndroidXViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import androidx.lifecycle.ViewModelStore 6 | import androidx.lifecycle.viewmodel.CreationExtras 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlin.reflect.KClass 9 | 10 | /** 11 | * A Kotlin Multiplatform Mobile ViewModel. 12 | */ 13 | public actual abstract class ViewModel: AndroidXViewModel { 14 | 15 | /** 16 | * The [ViewModelScope] containing the [CoroutineScope] of this ViewModel. 17 | */ 18 | public actual val viewModelScope: ViewModelScope 19 | 20 | public actual constructor(): this(DefaultCoroutineScope()) 21 | 22 | public actual constructor(coroutineScope: CoroutineScope): super(coroutineScope) { 23 | viewModelScope = ViewModelScope(coroutineScope) 24 | } 25 | 26 | public actual constructor(vararg closeables: AutoCloseable): this(DefaultCoroutineScope(), *closeables) 27 | 28 | public actual constructor( 29 | coroutineScope: CoroutineScope, 30 | vararg closeables: AutoCloseable 31 | ): super(coroutineScope, *closeables) { 32 | viewModelScope = ViewModelScope(coroutineScope) 33 | } 34 | 35 | /** 36 | * Called when this ViewModel is no longer used and will be destroyed. 37 | */ 38 | public actual override fun onCleared() { 39 | super.onCleared() 40 | } 41 | 42 | /** 43 | * Internal KMP-ObservableViewModel function used by the Swift implementation to clear the ViewModel. 44 | * Warning: you should NOT call this yourself! 45 | */ 46 | @InternalKMPObservableViewModelApi 47 | public fun clear() { 48 | // We can't directly call the internal clear function from AndroidX. 49 | // To call it indirectly we use the public Store and Provider APIs instead. 50 | val store = ViewModelStore() 51 | ViewModelProvider.create( 52 | store = store, 53 | factory = object : ViewModelProvider.Factory { 54 | override fun create(modelClass: KClass, extras: CreationExtras): T { 55 | @Suppress("UNCHECKED_CAST") 56 | return this@ViewModel as T 57 | } 58 | } 59 | )[ViewModel::class] 60 | store.clear() 61 | } 62 | } 63 | 64 | /** 65 | * Adds an [AutoCloseable] resource with an associated [key] to this [ViewModel]. 66 | * The resource will be closed right before the [onCleared][ViewModel.onCleared] method is called. 67 | * 68 | * If the [key] already has a resource associated with it, the old resource will be replaced and closed immediately. 69 | * 70 | * If [onCleared][ViewModel.onCleared] has already been called, 71 | * the provided resource will not be added and will be closed immediately. 72 | */ 73 | @Suppress("EXTENSION_SHADOWED_BY_MEMBER") 74 | public actual inline fun ViewModel.addCloseable(key: String, closeable: AutoCloseable): Unit = 75 | addCloseable(key, closeable) 76 | 77 | /** 78 | * Adds an [AutoCloseable] resource to this [ViewModel]. 79 | * The resource will be closed right before the [onCleared][ViewModel.onCleared] method is called. 80 | * 81 | * If [onCleared][ViewModel.onCleared] has already been called, 82 | * the provided resource will not be added and will be closed immediately. 83 | */ 84 | @Suppress("EXTENSION_SHADOWED_BY_MEMBER") 85 | public actual inline fun ViewModel.addCloseable(closeable: AutoCloseable): Unit = 86 | addCloseable(closeable) 87 | 88 | /** 89 | * Returns the [AutoCloseable] resource associated to the given [key], 90 | * or `null` if such a [key] is not present in this [ViewModel]. 91 | */ 92 | @Suppress("EXTENSION_SHADOWED_BY_MEMBER") 93 | public actual inline fun ViewModel.getCloseable(key: String): T? = 94 | getCloseable(key) 95 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.flow.* 5 | import kotlin.coroutines.CoroutineContext 6 | import kotlin.coroutines.EmptyCoroutineContext 7 | 8 | /** 9 | * @see kotlinx.coroutines.flow.MutableStateFlow 10 | */ 11 | public actual fun MutableStateFlow( 12 | viewModelScope: ViewModelScope, 13 | value: T 14 | ): MutableStateFlow = MutableStateFlowImpl(viewModelScope.asImpl(), MutableStateFlow(value)) 15 | 16 | /** 17 | * A [MutableStateFlow] that triggers [ViewModelScopeImpl.sendObjectWillChange] 18 | * and accounts for the [ViewModelScopeImpl.subscriptionCount]. 19 | */ 20 | @OptIn(ExperimentalForInheritanceCoroutinesApi::class) 21 | private class MutableStateFlowImpl( 22 | private val viewModelScope: ViewModelScopeImpl, 23 | private val stateFlow: MutableStateFlow 24 | ): MutableStateFlow { 25 | 26 | override var value: T 27 | get() = stateFlow.value 28 | set(value) { 29 | if (stateFlow.value != value) { 30 | viewModelScope.sendObjectWillChange() 31 | } 32 | stateFlow.value = value 33 | } 34 | 35 | override val replayCache: List 36 | get() = stateFlow.replayCache 37 | 38 | override val subscriptionCount: StateFlow = 39 | SubscriptionCountFlow(viewModelScope.subscriptionCount, stateFlow.subscriptionCount) 40 | 41 | override suspend fun collect(collector: FlowCollector): Nothing = 42 | stateFlow.collect(collector) 43 | 44 | override fun compareAndSet(expect: T, update: T): Boolean { 45 | if (stateFlow.value == expect && expect != update) { 46 | viewModelScope.sendObjectWillChange() 47 | } 48 | return stateFlow.compareAndSet(expect, update) 49 | } 50 | 51 | @ExperimentalCoroutinesApi 52 | override fun resetReplayCache() = stateFlow.resetReplayCache() 53 | 54 | // Same implementation as in StateFlowImpl, but we need to go through our own value property. 55 | // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/StateFlow.kt#L369 56 | override fun tryEmit(value: T): Boolean { 57 | this.value = value 58 | return true 59 | } 60 | 61 | // Same implementation as in StateFlowImpl, but we need to go through our own value property. 62 | // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/StateFlow.kt#L374 63 | override suspend fun emit(value: T) { 64 | this.value = value 65 | } 66 | } 67 | 68 | /** 69 | * A [StateFlow] that combines the subscription counts of a [ViewModelScopeImpl] and [StateFlow]. 70 | */ 71 | @OptIn(ExperimentalForInheritanceCoroutinesApi::class) 72 | private class SubscriptionCountFlow( 73 | private val viewModelScopeSubscriptionCount: StateFlow, 74 | private val stateFlowSubscriptionCount: StateFlow 75 | ): StateFlow { 76 | override val value: Int 77 | get() = viewModelScopeSubscriptionCount.value + stateFlowSubscriptionCount.value 78 | 79 | override val replayCache: List 80 | get() = listOf(value) 81 | 82 | override suspend fun collect(collector: FlowCollector): Nothing { 83 | viewModelScopeSubscriptionCount.combine(stateFlowSubscriptionCount) { count1, count2 -> 84 | count1 + count2 85 | }.collect(collector) 86 | throw IllegalStateException("SubscriptionCountFlow collect completed") 87 | } 88 | } 89 | 90 | /** 91 | * @see kotlinx.coroutines.flow.stateIn 92 | */ 93 | public actual fun Flow.stateIn( 94 | viewModelScope: ViewModelScope, 95 | started: SharingStarted, 96 | initialValue: T 97 | ): StateFlow { 98 | // Similar to kotlinx.coroutines, but using our custom MutableStateFlowImpl and CoroutineContext logic. 99 | // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/operators/Share.kt#L135 100 | val scope = viewModelScope.asImpl() 101 | val state = MutableStateFlowImpl(scope, MutableStateFlow(initialValue)) 102 | val job = scope.coroutineScope.launchSharing(EmptyCoroutineContext, this, state, started, initialValue) 103 | return ReadonlyStateFlow(state, job) 104 | } 105 | 106 | /** 107 | * Identical to the kotlinx.coroutines implementation, but without the SharedFlow logic. 108 | * https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/operators/Share.kt#L194 109 | */ 110 | private fun CoroutineScope.launchSharing( 111 | context: CoroutineContext, 112 | upstream: Flow, 113 | shared: MutableSharedFlow, 114 | started: SharingStarted, 115 | initialValue: T 116 | ): Job { 117 | val start = if (started == SharingStarted.Eagerly) CoroutineStart.DEFAULT else CoroutineStart.UNDISPATCHED 118 | return launch(context, start = start) { 119 | when { 120 | started === SharingStarted.Eagerly -> { 121 | upstream.collect(shared) 122 | } 123 | started === SharingStarted.Lazily -> { 124 | shared.subscriptionCount.first { it > 0 } 125 | upstream.collect(shared) 126 | } 127 | else -> started.command(shared.subscriptionCount) 128 | .distinctUntilChanged() 129 | .collectLatest { 130 | when (it) { 131 | SharingCommand.START -> upstream.collect(shared) 132 | SharingCommand.STOP -> { } 133 | SharingCommand.STOP_AND_RESET_REPLAY_CACHE -> shared.tryEmit(initialValue) 134 | } 135 | } 136 | } 137 | } 138 | } 139 | 140 | /** 141 | * Similar to the kotlinx.coroutines implementation, used to return a read-only StateFlow with an optional Job. 142 | * https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/operators/Share.kt#L379 143 | */ 144 | @OptIn(ExperimentalForInheritanceCoroutinesApi::class) 145 | private class ReadonlyStateFlow( 146 | flow: StateFlow, 147 | @Suppress("unused") 148 | private val job: Job? 149 | ): StateFlow by flow 150 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScope.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelScopeProtocol 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.flow.asStateFlow 8 | import kotlinx.coroutines.flow.update 9 | import platform.darwin.NSObject 10 | 11 | /** 12 | * Holds the [CoroutineScope] of a [ViewModel]. 13 | * @see coroutineScope 14 | */ 15 | public actual typealias ViewModelScope = KMPOVMViewModelScopeProtocol 16 | 17 | /** 18 | * Creates a new [ViewModelScope] for the provided [coroutineScope]. 19 | */ 20 | internal actual fun ViewModelScope(coroutineScope: CoroutineScope): ViewModelScope = 21 | ViewModelScopeImpl(coroutineScope) 22 | 23 | /** 24 | * Gets the [CoroutineScope] associated with the [ViewModel] of `this` [ViewModelScope]. 25 | */ 26 | public actual val ViewModelScope.coroutineScope: CoroutineScope 27 | get() = asImpl().coroutineScope 28 | 29 | /** 30 | * Casts `this` [ViewModelScope] to a [ViewModelScopeImpl]. 31 | */ 32 | @InternalKMPObservableViewModelApi 33 | public inline fun ViewModelScope.asImpl(): ViewModelScopeImpl = this as ViewModelScopeImpl 34 | 35 | /** 36 | * Implementation of [ViewModelScope]. 37 | * @property coroutineScope The [CoroutineScope] associated with the [ViewModel]. 38 | */ 39 | @InternalKMPObservableViewModelApi 40 | public class ViewModelScopeImpl internal constructor( 41 | public val coroutineScope: CoroutineScope 42 | ): NSObject(), ViewModelScope { 43 | 44 | private val _subscriptionCount = MutableStateFlow(0) 45 | /** 46 | * A [StateFlow] that emits the number of subscribers to the [ViewModel]. 47 | */ 48 | public val subscriptionCount: StateFlow = _subscriptionCount.asStateFlow() 49 | 50 | override fun increaseSubscriptionCount() { 51 | _subscriptionCount.update { it + 1 } 52 | } 53 | 54 | override fun decreaseSubscriptionCount() { 55 | _subscriptionCount.update { it - 1 } 56 | } 57 | 58 | private var sendObjectWillChange: (() -> Unit)? = null 59 | 60 | override fun setSendObjectWillChange(sendObjectWillChange: () -> Unit) { 61 | if (this.sendObjectWillChange != null) { 62 | throw IllegalStateException("ViewModel can't be wrapped more than once") 63 | } 64 | this.sendObjectWillChange = sendObjectWillChange 65 | } 66 | 67 | /** 68 | * Invokes the object will change listener set by [setSendObjectWillChange]. 69 | */ 70 | public fun sendObjectWillChange() { 71 | sendObjectWillChange?.invoke() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/DefaultCoroutineScope.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.MainCoroutineDispatcher 6 | import kotlinx.coroutines.SupervisorJob 7 | import kotlin.coroutines.EmptyCoroutineContext 8 | 9 | /** 10 | * Creates a default [CoroutineScope] for a ViewModel, 11 | * using the [Main.immediate][MainCoroutineDispatcher.immediate] dispatcher if available. 12 | * 13 | * [androidx/CloseableCoroutineScope.kt](https://cs.android.com/androidx/platform/frameworks/support/+/6a69101fd0edc8d02aa316df1f43e0552fd2d7c4:lifecycle/lifecycle-viewmodel/src/commonMain/kotlin/androidx/lifecycle/viewmodel/internal/CloseableCoroutineScope.kt;l=51-66) 14 | */ 15 | @Suppress("FunctionName") 16 | internal fun DefaultCoroutineScope(): CoroutineScope { 17 | val dispatcher = try { 18 | Dispatchers.Main.immediate 19 | } catch (_: NotImplementedError) { 20 | EmptyCoroutineContext 21 | } catch (_: IllegalStateException) { 22 | EmptyCoroutineContext 23 | } 24 | return CoroutineScope(SupervisorJob() + dispatcher) 25 | } 26 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/InternalKMPObservableViewModelApi.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | @RequiresOptIn( 4 | level = RequiresOptIn.Level.ERROR, 5 | message = "This is an internal KMP-ObservableViewModel API that shouldn't be used outside KMP-ObservableViewModel!" 6 | ) 7 | @Retention(value = AnnotationRetention.BINARY) 8 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) 9 | public annotation class InternalKMPObservableViewModelApi 10 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.SharingStarted 6 | import kotlinx.coroutines.flow.StateFlow 7 | 8 | /** 9 | * @see kotlinx.coroutines.flow.MutableStateFlow 10 | */ 11 | public expect fun MutableStateFlow( 12 | viewModelScope: ViewModelScope, 13 | value: T 14 | ): MutableStateFlow 15 | 16 | /** 17 | * @see kotlinx.coroutines.flow.stateIn 18 | */ 19 | public expect fun Flow.stateIn( 20 | viewModelScope: ViewModelScope, 21 | started: SharingStarted, 22 | initialValue: T 23 | ): StateFlow 24 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlowUtils.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.SharingStarted 5 | import kotlinx.coroutines.flow.StateFlow 6 | import kotlinx.coroutines.flow.flowOn 7 | import kotlin.coroutines.CoroutineContext 8 | 9 | /** 10 | * @see kotlinx.coroutines.flow.stateIn 11 | */ 12 | @Deprecated( 13 | message = "Please use the flowOn operator directly", 14 | replaceWith = ReplaceWith( 15 | expression = "this.flowOn(coroutineContext).stateIn(viewModelScope, started, initialValue)", 16 | imports = arrayOf("kotlinx.coroutines.flow.flowOn") 17 | ) 18 | ) 19 | public inline fun Flow.stateIn( 20 | viewModelScope: ViewModelScope, 21 | coroutineContext: CoroutineContext, 22 | started: SharingStarted, 23 | initialValue: T 24 | ): StateFlow = flowOn(coroutineContext).stateIn(viewModelScope, started, initialValue) 25 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | 5 | /** 6 | * A Kotlin Multiplatform Mobile ViewModel. 7 | */ 8 | public expect abstract class ViewModel { 9 | 10 | /** 11 | * The [ViewModelScope] containing the [CoroutineScope] of this ViewModel. 12 | */ 13 | public val viewModelScope: ViewModelScope 14 | 15 | public constructor() 16 | 17 | public constructor(coroutineScope: CoroutineScope) 18 | 19 | public constructor(vararg closeables: AutoCloseable) 20 | 21 | public constructor(coroutineScope: CoroutineScope, vararg closeables: AutoCloseable) 22 | 23 | /** 24 | * Called when this ViewModel is no longer used and will be destroyed. 25 | */ 26 | public open fun onCleared() 27 | } 28 | 29 | /** 30 | * Adds an [AutoCloseable] resource with an associated [key] to this [ViewModel]. 31 | * The resource will be closed right before the [onCleared][ViewModel.onCleared] method is called. 32 | * 33 | * If the [key] already has a resource associated with it, the old resource will be replaced and closed immediately. 34 | * 35 | * If [onCleared][ViewModel.onCleared] has already been called, 36 | * the provided resource will not be added and will be closed immediately. 37 | */ 38 | public expect fun ViewModel.addCloseable(key: String, closeable: AutoCloseable) 39 | 40 | /** 41 | * Adds an [AutoCloseable] resource to this [ViewModel]. 42 | * The resource will be closed right before the [onCleared][ViewModel.onCleared] method is called. 43 | * 44 | * If [onCleared][ViewModel.onCleared] has already been called, 45 | * the provided resource will not be added and will be closed immediately. 46 | */ 47 | public expect fun ViewModel.addCloseable(closeable: AutoCloseable) 48 | 49 | /** 50 | * Returns the [AutoCloseable] resource associated to the given [key], 51 | * or `null` if such a [key] is not present in this [ViewModel]. 52 | */ 53 | public expect fun ViewModel.getCloseable(key: String): T? 54 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScope.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | 5 | /** 6 | * Holds the [CoroutineScope] of a [ViewModel]. 7 | * @see coroutineScope 8 | */ 9 | public expect interface ViewModelScope 10 | 11 | /** 12 | * Creates a new [ViewModelScope] for the provided [coroutineScope]. 13 | */ 14 | internal expect fun ViewModelScope(coroutineScope: CoroutineScope): ViewModelScope 15 | 16 | /** 17 | * Gets the [CoroutineScope] associated with the [ViewModel] of `this` [ViewModelScope]. 18 | */ 19 | public expect val ViewModelScope.coroutineScope: CoroutineScope 20 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScopeUtils.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.channels.ProducerScope 5 | import kotlinx.coroutines.channels.ReceiveChannel 6 | import kotlinx.coroutines.channels.produce 7 | import kotlin.coroutines.CoroutineContext 8 | import kotlin.coroutines.EmptyCoroutineContext 9 | 10 | /** 11 | * @see kotlinx.coroutines.isActive 12 | */ 13 | public inline val ViewModelScope.isActive: Boolean 14 | get() = coroutineScope.isActive 15 | 16 | /** 17 | * @see kotlinx.coroutines.async 18 | */ 19 | public inline fun ViewModelScope.async( 20 | context: CoroutineContext = EmptyCoroutineContext, 21 | start: CoroutineStart = CoroutineStart.DEFAULT, 22 | noinline block: suspend CoroutineScope.() -> T 23 | ): Deferred = coroutineScope.async(context, start, block) 24 | 25 | /** 26 | * @see kotlinx.coroutines.ensureActive 27 | */ 28 | public inline fun ViewModelScope.ensureActive(): Unit = coroutineScope.ensureActive() 29 | 30 | /** 31 | * @see kotlinx.coroutines.launch 32 | */ 33 | public inline fun ViewModelScope.launch( 34 | context: CoroutineContext = EmptyCoroutineContext, 35 | start: CoroutineStart = CoroutineStart.DEFAULT, 36 | noinline block: suspend CoroutineScope.() -> Unit 37 | ): Job = coroutineScope.launch(context, start, block) 38 | 39 | /** 40 | * @see kotlinx.coroutines.channels.produce 41 | */ 42 | @ExperimentalCoroutinesApi 43 | public inline fun ViewModelScope.produce( 44 | context: CoroutineContext = EmptyCoroutineContext, 45 | capacity: Int = 0, 46 | noinline block: suspend ProducerScope.() -> Unit 47 | ): ReceiveChannel = coroutineScope.produce(context, capacity, block) 48 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def: -------------------------------------------------------------------------------- 1 | language = Objective-C 2 | package = com.rickclephas.kmp.observableviewmodel.objc 3 | headers = KMPOVMViewModelScope.h 4 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/nonAndroidxMain/kotlin/com/rickclephas/kmp/observableviewmodel/Closeables.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import kotlinx.atomicfu.locks.SynchronizedObject 4 | import kotlinx.atomicfu.locks.synchronized 5 | 6 | /** 7 | * A collection of [AutoCloseable]s behaving similar to the 8 | * [AndroidX ViewModel implementation](https://cs.android.com/androidx/platform/frameworks/support/+/58618bec2592c538b0a9e469f1492365ef2233e3:lifecycle/lifecycle-viewmodel/src/commonMain/kotlin/androidx/lifecycle/viewmodel/internal/ViewModelImpl.kt). 9 | */ 10 | internal class Closeables( 11 | private val closeables: MutableSet = mutableSetOf(), 12 | private val keyedCloseables: MutableMap = mutableMapOf() 13 | ): SynchronizedObject(), AutoCloseable { 14 | 15 | private var isClosed = false 16 | 17 | operator fun plusAssign(closeable: AutoCloseable): Unit = synchronized(this) { 18 | if (isClosed) { 19 | closeWithRuntimeException(closeable) 20 | return 21 | } 22 | closeables += closeable 23 | } 24 | 25 | operator fun set(key: String, closeable: AutoCloseable): Unit = synchronized(this) { 26 | if (isClosed) { 27 | closeWithRuntimeException(closeable) 28 | return 29 | } 30 | closeWithRuntimeException(keyedCloseables.put(key, closeable)) 31 | } 32 | 33 | operator fun get(key: String): AutoCloseable? = synchronized(this) { 34 | keyedCloseables[key] 35 | } 36 | 37 | override fun close(): Unit = synchronized(this) { 38 | if (isClosed) return 39 | isClosed = true 40 | for (closeable in keyedCloseables.values) { 41 | closeWithRuntimeException(closeable) 42 | } 43 | for (closeable in closeables) { 44 | closeWithRuntimeException(closeable) 45 | } 46 | closeables.clear() 47 | } 48 | 49 | private fun closeWithRuntimeException(closeable: AutoCloseable?) { 50 | try { 51 | closeable?.close() 52 | } catch (e: Exception) { 53 | throw RuntimeException(e) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/nonAndroidxMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.cancel 5 | 6 | /** 7 | * A Kotlin Multiplatform Mobile ViewModel. 8 | */ 9 | public actual abstract class ViewModel { 10 | 11 | /** 12 | * The [ViewModelScope] containing the [CoroutineScope] of this ViewModel. 13 | */ 14 | public actual val viewModelScope: ViewModelScope 15 | 16 | internal val closeables: Closeables 17 | 18 | public actual constructor(): this(DefaultCoroutineScope()) 19 | 20 | public actual constructor(coroutineScope: CoroutineScope) { 21 | viewModelScope = ViewModelScope(coroutineScope) 22 | closeables = Closeables() 23 | } 24 | 25 | public actual constructor(vararg closeables: AutoCloseable): this(DefaultCoroutineScope(), *closeables) 26 | 27 | public actual constructor( 28 | coroutineScope: CoroutineScope, 29 | vararg closeables: AutoCloseable 30 | ) { 31 | viewModelScope = ViewModelScope(coroutineScope) 32 | this.closeables = Closeables(closeables.toMutableSet()) 33 | } 34 | 35 | /** 36 | * Called when this ViewModel is no longer used and will be destroyed. 37 | */ 38 | public actual open fun onCleared() { } 39 | 40 | /** 41 | * Should be called to clear the ViewModel once it's no longer being used. 42 | */ 43 | public fun clear() { 44 | viewModelScope.coroutineScope.cancel() 45 | closeables.close() 46 | onCleared() 47 | } 48 | } 49 | 50 | /** 51 | * Adds an [AutoCloseable] resource with an associated [key] to this [ViewModel]. 52 | * The resource will be closed right before the [onCleared][ViewModel.onCleared] method is called. 53 | * 54 | * If the [key] already has a resource associated with it, the old resource will be replaced and closed immediately. 55 | * 56 | * If [onCleared][ViewModel.onCleared] has already been called, 57 | * the provided resource will not be added and will be closed immediately. 58 | */ 59 | public actual fun ViewModel.addCloseable(key: String, closeable: AutoCloseable) { 60 | closeables[key] = closeable 61 | } 62 | 63 | /** 64 | * Adds an [AutoCloseable] resource to this [ViewModel]. 65 | * The resource will be closed right before the [onCleared][ViewModel.onCleared] method is called. 66 | * 67 | * If [onCleared][ViewModel.onCleared] has already been called, 68 | * the provided resource will not be added and will be closed immediately. 69 | */ 70 | public actual fun ViewModel.addCloseable(closeable: AutoCloseable) { 71 | closeables += closeable 72 | } 73 | 74 | /** 75 | * Returns the [AutoCloseable] resource associated to the given [key], 76 | * or `null` if such a [key] is not present in this [ViewModel]. 77 | */ 78 | @Suppress("UNCHECKED_CAST") 79 | public actual fun ViewModel.getCloseable(key: String): T? = 80 | closeables[key] as T? 81 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/nonAppleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import kotlinx.coroutines.flow.* 4 | 5 | /** 6 | * @see kotlinx.coroutines.flow.MutableStateFlow 7 | */ 8 | public actual inline fun MutableStateFlow( 9 | viewModelScope: ViewModelScope, 10 | value: T 11 | ): MutableStateFlow = MutableStateFlow(value) 12 | 13 | /** 14 | * @see kotlinx.coroutines.flow.stateIn 15 | */ 16 | public actual inline fun Flow.stateIn( 17 | viewModelScope: ViewModelScope, 18 | started: SharingStarted, 19 | initialValue: T 20 | ): StateFlow = stateIn(viewModelScope.coroutineScope, started, initialValue) 21 | -------------------------------------------------------------------------------- /kmp-observableviewmodel-core/src/nonAppleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScope.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | 5 | /** 6 | * Holds the [CoroutineScope] of a [ViewModel]. 7 | * @see coroutineScope 8 | */ 9 | public actual interface ViewModelScope 10 | 11 | /** 12 | * Creates a new [ViewModelScope] for the provided [coroutineScope]. 13 | */ 14 | internal actual fun ViewModelScope(coroutineScope: CoroutineScope): ViewModelScope = 15 | ViewModelScopeImpl(coroutineScope) 16 | 17 | /** 18 | * Gets the [CoroutineScope] associated with the [ViewModel] of `this` [ViewModelScope]. 19 | */ 20 | public actual val ViewModelScope.coroutineScope: CoroutineScope 21 | get() = (this as ViewModelScopeImpl).coroutineScope 22 | 23 | private class ViewModelScopeImpl(val coroutineScope: CoroutineScope): ViewModelScope 24 | -------------------------------------------------------------------------------- /qodana.yaml: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | projectJDK: '17' 3 | profile: 4 | name: qodana.recommended 5 | -------------------------------------------------------------------------------- /sample/androidApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | android { 8 | namespace = "com.rickclephas.kmp.observableviewmodel.sample" 9 | compileSdk = 34 10 | defaultConfig { 11 | applicationId = "com.rickclephas.kmp.observableviewmodel.sample" 12 | minSdk = 28 13 | targetSdk = 33 14 | versionCode = 1 15 | versionName = "1.0" 16 | } 17 | buildFeatures { 18 | compose = true 19 | } 20 | packaging { 21 | resources { 22 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 23 | } 24 | } 25 | buildTypes { 26 | getByName("release") { 27 | isMinifyEnabled = false 28 | } 29 | } 30 | } 31 | 32 | kotlin { 33 | jvmToolchain(11) 34 | } 35 | 36 | dependencies { 37 | implementation(project(":shared")) 38 | implementation(platform(libs.androidx.compose.bom)) 39 | implementation(libs.androidx.compose.ui) 40 | implementation(libs.androidx.compose.ui.tooling) 41 | implementation(libs.androidx.compose.ui.tooling.preview) 42 | implementation(libs.androidx.compose.foundation) 43 | implementation(libs.androidx.compose.material) 44 | implementation(libs.androidx.fragment.ktx) 45 | implementation(libs.androidx.lifecycle.viewmodel.compose) 46 | } 47 | -------------------------------------------------------------------------------- /sample/androidApp/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /sample/androidApp/src/main/java/com/rickclephas/kmp/observableviewmodel/sample/ComposeFragment.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel.sample 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.material.* 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.collectAsState 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.platform.ComposeView 15 | import androidx.compose.ui.unit.dp 16 | import androidx.compose.ui.unit.sp 17 | import androidx.fragment.app.Fragment 18 | import androidx.lifecycle.viewmodel.compose.viewModel 19 | import com.rickclephas.kmp.observableviewmodel.sample.shared.TimeTravelViewModel 20 | 21 | class ComposeFragment: Fragment() { 22 | 23 | override fun onCreateView( 24 | inflater: LayoutInflater, 25 | container: ViewGroup?, 26 | savedInstanceState: Bundle? 27 | ): View = ComposeView(requireContext()).apply { 28 | setContent { 29 | Surface { 30 | TimeTravelScreen() 31 | } 32 | } 33 | } 34 | 35 | @Composable 36 | private fun TimeTravelScreen(viewModel: TimeTravelViewModel = viewModel()) { 37 | Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { 38 | Text("Actual time:") 39 | val actualTime by viewModel.actualTime.collectAsState() 40 | Text(actualTime, fontSize = 20.sp) 41 | 42 | Spacer(modifier = Modifier.height(24.dp)) 43 | 44 | Text("Travel effect:") 45 | val travelEffect by viewModel.travelEffect.collectAsState() 46 | Text(travelEffect.toString(), fontSize = 20.sp) 47 | 48 | Spacer(modifier = Modifier.height(24.dp)) 49 | 50 | Text("Current time:") 51 | val currentTime by viewModel.currentTime.collectAsState() 52 | Text(currentTime, fontSize = 20.sp) 53 | 54 | Spacer(modifier = Modifier.height(24.dp)) 55 | 56 | val isFixedTime by viewModel.isFixedTime.collectAsState() 57 | Row(verticalAlignment = Alignment.CenterVertically) { 58 | Checkbox(isFixedTime, { isChecked -> 59 | when (isChecked) { 60 | true -> viewModel.stopTime() 61 | false -> viewModel.startTime() 62 | } 63 | }) 64 | Text("Fixed time") 65 | } 66 | 67 | Spacer(modifier = Modifier.height(24.dp)) 68 | 69 | Button(viewModel::timeTravel) { 70 | Text("Time travel") 71 | } 72 | 73 | Spacer(modifier = Modifier.height(24.dp)) 74 | 75 | Button(viewModel::resetTime) { 76 | Text("Reset") 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /sample/androidApp/src/main/java/com/rickclephas/kmp/observableviewmodel/sample/ComposeMPFragment.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel.sample 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.compose.ui.platform.ComposeView 8 | import androidx.fragment.app.Fragment 9 | import androidx.lifecycle.viewmodel.compose.viewModel 10 | import com.rickclephas.kmp.observableviewmodel.sample.shared.TimeTravelScreen 11 | 12 | class ComposeMPFragment: Fragment() { 13 | 14 | override fun onCreateView( 15 | inflater: LayoutInflater, 16 | container: ViewGroup?, 17 | savedInstanceState: Bundle? 18 | ): View = ComposeView(requireContext()).apply { 19 | setContent { 20 | TimeTravelScreen(viewModel()) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /sample/androidApp/src/main/java/com/rickclephas/kmp/observableviewmodel/sample/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel.sample 2 | 3 | import androidx.fragment.app.FragmentActivity 4 | 5 | class MainActivity : FragmentActivity(R.layout.activity_main) 6 | -------------------------------------------------------------------------------- /sample/androidApp/src/main/java/com/rickclephas/kmp/observableviewmodel/sample/PickerFragment.kt: -------------------------------------------------------------------------------- 1 | package com.rickclephas.kmp.observableviewmodel.sample 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import android.widget.Button 8 | import androidx.fragment.app.Fragment 9 | import androidx.fragment.app.commit 10 | import androidx.fragment.app.replace 11 | 12 | class PickerFragment: Fragment(R.layout.fragment_picker) { 13 | 14 | override fun onCreateView( 15 | inflater: LayoutInflater, 16 | container: ViewGroup?, 17 | savedInstanceState: Bundle? 18 | ): View? { 19 | val view = super.onCreateView(inflater, container, savedInstanceState) ?: return null 20 | 21 | view.findViewById