├── .github └── workflows │ └── swift.yml ├── .gitignore ├── CONTRIBUTING.md ├── Documentation └── Images │ └── add-package-dependency.png ├── Example ├── Documentation │ └── Images │ │ ├── CounterView.png │ │ └── TasksView.png ├── Example.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── README.md └── Sources │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Counter.swift │ ├── CounterViewController.swift │ ├── Info.plist │ ├── SceneDelegate.swift │ ├── TabBarController.swift │ ├── TaskList.swift │ └── TasksViewController.swift ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── Bindings │ ├── BindingOwner.swift │ ├── BindingSink.swift │ ├── BindingSubscriber.swift │ ├── Box.swift │ ├── README.md │ └── ReactiveExtensionProvider.swift ├── CombineViewModel │ ├── ClassHierarchy.swift │ ├── CombineExports.swift │ ├── DispatchQueue+EventSourceScheduler.swift │ ├── EventSource.swift │ ├── EventSourceScheduler.swift │ ├── MethodList.swift │ ├── ObjCRuntime.swift │ ├── ObjectDidChangePublisher.swift │ ├── RunLoop+EventSourceScheduler.swift │ ├── UIViewController+ViewDidLoadPublisher.swift │ ├── UnfairAtomic.swift │ ├── ViewModel.swift │ ├── ViewModelObserver.swift │ └── Weak.swift └── UIKitBindings │ ├── UIApplication.swift │ ├── UIBarButtonItem.swift │ ├── UIControl.swift │ ├── UILabel.swift │ ├── UIRefreshControl.swift │ ├── UISwitch.swift │ ├── UITextField.swift │ ├── UIView.swift │ └── UIViewController.swift └── Tests ├── CombineViewModelTests ├── DispatchQueueEventSourceSchedulerTests.swift ├── HookedViewDidLoadTests.swift ├── ObjectDidChangePublisherTests.swift └── ViewModelTests.swift └── ObjCTestSupport ├── TestObjCViewController.m └── include └── ObjCTestSupport.h /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: macos-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Build 15 | run: swift build -v 16 | - name: Run tests 17 | run: swift test -v 18 | 19 | build-release: 20 | runs-on: macos-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Build 24 | run: swift build -c release -v 25 | 26 | build-ios: 27 | runs-on: macos-latest 28 | steps: 29 | - uses: actions/checkout@v2 30 | - name: Build 31 | run: | 32 | xcodebuild build-for-testing \ 33 | -scheme CombineViewModel-Package \ 34 | -destination 'platform=iOS Simulator,name=iPhone 11' 35 | - name: Run tests 36 | run: | 37 | xcodebuild test-without-building \ 38 | -scheme CombineViewModel-Package \ 39 | -destination 'platform=iOS Simulator,name=iPhone 11' 40 | 41 | build-release-ios: 42 | runs-on: macos-latest 43 | steps: 44 | - uses: actions/checkout@v2 45 | - name: Build Bindings 46 | run: | 47 | xcodebuild -configuration Release \ 48 | -scheme Bindings \ 49 | -destination 'platform=iOS Simulator,name=iPhone 11' 50 | - name: Build UIKitBindings 51 | run: | 52 | xcodebuild -configuration Release \ 53 | -scheme UIKitBindings \ 54 | -destination 'platform=iOS Simulator,name=iPhone 11' 55 | - name: Build CombineViewModel 56 | run: | 57 | xcodebuild -configuration Release \ 58 | -scheme CombineViewModel \ 59 | -destination 'platform=iOS Simulator,name=iPhone 11' 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *~ 3 | /.build 4 | /.log 5 | /.swiftpm 6 | /Packages 7 | /*.xcodeproj 8 | xcuserdata/ 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love contributions from everyone. 4 | By participating in this project, 5 | you agree to abide by the thoughtbot [code of conduct][]. 6 | 7 | [code of conduct]: https://thoughtbot.com/open-source-code-of-conduct 8 | 9 | We expect everyone to follow the code of conduct 10 | anywhere in thoughtbot's project codebases, 11 | issue trackers, chatrooms, and mailing lists. 12 | 13 | ## Contributing Code 14 | 15 | Reactive extensions are usually small and straightforward to implement, 16 | making them a great way to share useful utilities for others to use. 17 | 18 | We consider all kinds of changes. If you have an idea but you're not sure 19 | whether it's likely to be accepted, feel free to [open an issue][] first to 20 | discuss it. 21 | 22 | [open an issue]: https://github.com/thoughtbot/CombineViewModel/issues 23 | 24 | First make your change, being sure to test that it compiles on all supported 25 | platforms. 26 | 27 | Push to your fork. Write a [good commit message][commit]. Submit a pull request. 28 | 29 | [commit]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 30 | 31 | Thank you for contributing! 32 | -------------------------------------------------------------------------------- /Documentation/Images/add-package-dependency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/CombineViewModel/c722546ed54ce528fc0089e72ea550839c8cf671/Documentation/Images/add-package-dependency.png -------------------------------------------------------------------------------- /Example/Documentation/Images/CounterView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/CombineViewModel/c722546ed54ce528fc0089e72ea550839c8cf671/Example/Documentation/Images/CounterView.png -------------------------------------------------------------------------------- /Example/Documentation/Images/TasksView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/CombineViewModel/c722546ed54ce528fc0089e72ea550839c8cf671/Example/Documentation/Images/TasksView.png -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 053B251424D516EE0081607D /* TasksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 053B251324D516EE0081607D /* TasksViewController.swift */; }; 11 | 053B251624D517320081607D /* TaskList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 053B251524D517320081607D /* TaskList.swift */; }; 12 | 0559CB5D24D51DD200E6FD23 /* TabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0559CB5C24D51DD200E6FD23 /* TabBarController.swift */; }; 13 | 05ADD8FA24D5097F006E17EA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05ADD8F924D5097F006E17EA /* AppDelegate.swift */; }; 14 | 05ADD8FC24D5097F006E17EA /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05ADD8FB24D5097F006E17EA /* SceneDelegate.swift */; }; 15 | 05ADD8FE24D5097F006E17EA /* CounterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05ADD8FD24D5097F006E17EA /* CounterViewController.swift */; }; 16 | 05ADD90124D5097F006E17EA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 05ADD8FF24D5097F006E17EA /* Main.storyboard */; }; 17 | 05ADD90324D5097F006E17EA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 05ADD90224D5097F006E17EA /* Assets.xcassets */; }; 18 | 05ADD90624D5097F006E17EA /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 05ADD90424D5097F006E17EA /* LaunchScreen.storyboard */; }; 19 | 05ADD91124D509BC006E17EA /* CombineViewModel in Frameworks */ = {isa = PBXBuildFile; productRef = 05ADD91024D509BC006E17EA /* CombineViewModel */; }; 20 | 05ADD91324D509F6006E17EA /* Counter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05ADD91224D509F6006E17EA /* Counter.swift */; }; 21 | /* End PBXBuildFile section */ 22 | 23 | /* Begin PBXFileReference section */ 24 | 053B251324D516EE0081607D /* TasksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TasksViewController.swift; sourceTree = ""; }; 25 | 053B251524D517320081607D /* TaskList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskList.swift; sourceTree = ""; }; 26 | 0559CB5C24D51DD200E6FD23 /* TabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarController.swift; sourceTree = ""; }; 27 | 05ADD8F624D5097F006E17EA /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 28 | 05ADD8F924D5097F006E17EA /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 29 | 05ADD8FB24D5097F006E17EA /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 30 | 05ADD8FD24D5097F006E17EA /* CounterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterViewController.swift; sourceTree = ""; }; 31 | 05ADD90024D5097F006E17EA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 32 | 05ADD90224D5097F006E17EA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 33 | 05ADD90524D5097F006E17EA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 34 | 05ADD90724D5097F006E17EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 35 | 05ADD90E24D509B1006E17EA /* CombineViewModel */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CombineViewModel; path = ..; sourceTree = ""; }; 36 | 05ADD91224D509F6006E17EA /* Counter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Counter.swift; sourceTree = ""; }; 37 | /* End PBXFileReference section */ 38 | 39 | /* Begin PBXFrameworksBuildPhase section */ 40 | 05ADD8F324D5097F006E17EA /* Frameworks */ = { 41 | isa = PBXFrameworksBuildPhase; 42 | buildActionMask = 2147483647; 43 | files = ( 44 | 05ADD91124D509BC006E17EA /* CombineViewModel in Frameworks */, 45 | ); 46 | runOnlyForDeploymentPostprocessing = 0; 47 | }; 48 | /* End PBXFrameworksBuildPhase section */ 49 | 50 | /* Begin PBXGroup section */ 51 | 05ADD8ED24D5097F006E17EA = { 52 | isa = PBXGroup; 53 | children = ( 54 | 05ADD8F824D5097F006E17EA /* Sources */, 55 | 05ADD90D24D5099E006E17EA /* Packages */, 56 | 05ADD8F724D5097F006E17EA /* Products */, 57 | 05ADD90F24D509BC006E17EA /* Frameworks */, 58 | ); 59 | sourceTree = ""; 60 | }; 61 | 05ADD8F724D5097F006E17EA /* Products */ = { 62 | isa = PBXGroup; 63 | children = ( 64 | 05ADD8F624D5097F006E17EA /* Example.app */, 65 | ); 66 | name = Products; 67 | sourceTree = ""; 68 | }; 69 | 05ADD8F824D5097F006E17EA /* Sources */ = { 70 | isa = PBXGroup; 71 | children = ( 72 | 05ADD8F924D5097F006E17EA /* AppDelegate.swift */, 73 | 05ADD91224D509F6006E17EA /* Counter.swift */, 74 | 05ADD8FD24D5097F006E17EA /* CounterViewController.swift */, 75 | 05ADD8FB24D5097F006E17EA /* SceneDelegate.swift */, 76 | 0559CB5C24D51DD200E6FD23 /* TabBarController.swift */, 77 | 053B251524D517320081607D /* TaskList.swift */, 78 | 053B251324D516EE0081607D /* TasksViewController.swift */, 79 | 05ADD8FF24D5097F006E17EA /* Main.storyboard */, 80 | 05ADD90224D5097F006E17EA /* Assets.xcassets */, 81 | 05ADD90424D5097F006E17EA /* LaunchScreen.storyboard */, 82 | 05ADD90724D5097F006E17EA /* Info.plist */, 83 | ); 84 | path = Sources; 85 | sourceTree = ""; 86 | }; 87 | 05ADD90D24D5099E006E17EA /* Packages */ = { 88 | isa = PBXGroup; 89 | children = ( 90 | 05ADD90E24D509B1006E17EA /* CombineViewModel */, 91 | ); 92 | name = Packages; 93 | sourceTree = ""; 94 | }; 95 | 05ADD90F24D509BC006E17EA /* Frameworks */ = { 96 | isa = PBXGroup; 97 | children = ( 98 | ); 99 | name = Frameworks; 100 | sourceTree = ""; 101 | }; 102 | /* End PBXGroup section */ 103 | 104 | /* Begin PBXNativeTarget section */ 105 | 05ADD8F524D5097F006E17EA /* Example */ = { 106 | isa = PBXNativeTarget; 107 | buildConfigurationList = 05ADD90A24D5097F006E17EA /* Build configuration list for PBXNativeTarget "Example" */; 108 | buildPhases = ( 109 | 05ADD8F224D5097F006E17EA /* Sources */, 110 | 05ADD8F324D5097F006E17EA /* Frameworks */, 111 | 05ADD8F424D5097F006E17EA /* Resources */, 112 | ); 113 | buildRules = ( 114 | ); 115 | dependencies = ( 116 | ); 117 | name = Example; 118 | packageProductDependencies = ( 119 | 05ADD91024D509BC006E17EA /* CombineViewModel */, 120 | ); 121 | productName = Example; 122 | productReference = 05ADD8F624D5097F006E17EA /* Example.app */; 123 | productType = "com.apple.product-type.application"; 124 | }; 125 | /* End PBXNativeTarget section */ 126 | 127 | /* Begin PBXProject section */ 128 | 05ADD8EE24D5097F006E17EA /* Project object */ = { 129 | isa = PBXProject; 130 | attributes = { 131 | LastSwiftUpdateCheck = 1200; 132 | LastUpgradeCheck = 1200; 133 | TargetAttributes = { 134 | 05ADD8F524D5097F006E17EA = { 135 | CreatedOnToolsVersion = 12.0; 136 | }; 137 | }; 138 | }; 139 | buildConfigurationList = 05ADD8F124D5097F006E17EA /* Build configuration list for PBXProject "Example" */; 140 | compatibilityVersion = "Xcode 9.3"; 141 | developmentRegion = en; 142 | hasScannedForEncodings = 0; 143 | knownRegions = ( 144 | en, 145 | Base, 146 | ); 147 | mainGroup = 05ADD8ED24D5097F006E17EA; 148 | packageReferences = ( 149 | ); 150 | productRefGroup = 05ADD8F724D5097F006E17EA /* Products */; 151 | projectDirPath = ""; 152 | projectRoot = ""; 153 | targets = ( 154 | 05ADD8F524D5097F006E17EA /* Example */, 155 | ); 156 | }; 157 | /* End PBXProject section */ 158 | 159 | /* Begin PBXResourcesBuildPhase section */ 160 | 05ADD8F424D5097F006E17EA /* Resources */ = { 161 | isa = PBXResourcesBuildPhase; 162 | buildActionMask = 2147483647; 163 | files = ( 164 | 05ADD90624D5097F006E17EA /* LaunchScreen.storyboard in Resources */, 165 | 05ADD90324D5097F006E17EA /* Assets.xcassets in Resources */, 166 | 05ADD90124D5097F006E17EA /* Main.storyboard in Resources */, 167 | ); 168 | runOnlyForDeploymentPostprocessing = 0; 169 | }; 170 | /* End PBXResourcesBuildPhase section */ 171 | 172 | /* Begin PBXSourcesBuildPhase section */ 173 | 05ADD8F224D5097F006E17EA /* Sources */ = { 174 | isa = PBXSourcesBuildPhase; 175 | buildActionMask = 2147483647; 176 | files = ( 177 | 053B251624D517320081607D /* TaskList.swift in Sources */, 178 | 05ADD8FE24D5097F006E17EA /* CounterViewController.swift in Sources */, 179 | 05ADD8FA24D5097F006E17EA /* AppDelegate.swift in Sources */, 180 | 053B251424D516EE0081607D /* TasksViewController.swift in Sources */, 181 | 0559CB5D24D51DD200E6FD23 /* TabBarController.swift in Sources */, 182 | 05ADD91324D509F6006E17EA /* Counter.swift in Sources */, 183 | 05ADD8FC24D5097F006E17EA /* SceneDelegate.swift in Sources */, 184 | ); 185 | runOnlyForDeploymentPostprocessing = 0; 186 | }; 187 | /* End PBXSourcesBuildPhase section */ 188 | 189 | /* Begin PBXVariantGroup section */ 190 | 05ADD8FF24D5097F006E17EA /* Main.storyboard */ = { 191 | isa = PBXVariantGroup; 192 | children = ( 193 | 05ADD90024D5097F006E17EA /* Base */, 194 | ); 195 | name = Main.storyboard; 196 | sourceTree = ""; 197 | }; 198 | 05ADD90424D5097F006E17EA /* LaunchScreen.storyboard */ = { 199 | isa = PBXVariantGroup; 200 | children = ( 201 | 05ADD90524D5097F006E17EA /* Base */, 202 | ); 203 | name = LaunchScreen.storyboard; 204 | sourceTree = ""; 205 | }; 206 | /* End PBXVariantGroup section */ 207 | 208 | /* Begin XCBuildConfiguration section */ 209 | 05ADD90824D5097F006E17EA /* Debug */ = { 210 | isa = XCBuildConfiguration; 211 | buildSettings = { 212 | ALWAYS_SEARCH_USER_PATHS = NO; 213 | CLANG_ANALYZER_NONNULL = YES; 214 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 215 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 216 | CLANG_CXX_LIBRARY = "libc++"; 217 | CLANG_ENABLE_MODULES = YES; 218 | CLANG_ENABLE_OBJC_ARC = YES; 219 | CLANG_ENABLE_OBJC_WEAK = YES; 220 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 221 | CLANG_WARN_BOOL_CONVERSION = YES; 222 | CLANG_WARN_COMMA = YES; 223 | CLANG_WARN_CONSTANT_CONVERSION = YES; 224 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 225 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 226 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 227 | CLANG_WARN_EMPTY_BODY = YES; 228 | CLANG_WARN_ENUM_CONVERSION = YES; 229 | CLANG_WARN_INFINITE_RECURSION = YES; 230 | CLANG_WARN_INT_CONVERSION = YES; 231 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 232 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 233 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 234 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 235 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 236 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 237 | CLANG_WARN_STRICT_PROTOTYPES = YES; 238 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 239 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 240 | CLANG_WARN_UNREACHABLE_CODE = YES; 241 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 242 | COPY_PHASE_STRIP = NO; 243 | DEBUG_INFORMATION_FORMAT = dwarf; 244 | ENABLE_STRICT_OBJC_MSGSEND = YES; 245 | ENABLE_TESTABILITY = YES; 246 | GCC_C_LANGUAGE_STANDARD = gnu11; 247 | GCC_DYNAMIC_NO_PIC = NO; 248 | GCC_NO_COMMON_BLOCKS = YES; 249 | GCC_OPTIMIZATION_LEVEL = 0; 250 | GCC_PREPROCESSOR_DEFINITIONS = ( 251 | "DEBUG=1", 252 | "$(inherited)", 253 | ); 254 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 255 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 256 | GCC_WARN_UNDECLARED_SELECTOR = YES; 257 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 258 | GCC_WARN_UNUSED_FUNCTION = YES; 259 | GCC_WARN_UNUSED_VARIABLE = YES; 260 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 261 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 262 | MTL_FAST_MATH = YES; 263 | ONLY_ACTIVE_ARCH = YES; 264 | SDKROOT = iphoneos; 265 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 266 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 267 | }; 268 | name = Debug; 269 | }; 270 | 05ADD90924D5097F006E17EA /* Release */ = { 271 | isa = XCBuildConfiguration; 272 | buildSettings = { 273 | ALWAYS_SEARCH_USER_PATHS = NO; 274 | CLANG_ANALYZER_NONNULL = YES; 275 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 276 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 277 | CLANG_CXX_LIBRARY = "libc++"; 278 | CLANG_ENABLE_MODULES = YES; 279 | CLANG_ENABLE_OBJC_ARC = YES; 280 | CLANG_ENABLE_OBJC_WEAK = YES; 281 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 282 | CLANG_WARN_BOOL_CONVERSION = YES; 283 | CLANG_WARN_COMMA = YES; 284 | CLANG_WARN_CONSTANT_CONVERSION = YES; 285 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 286 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 287 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 288 | CLANG_WARN_EMPTY_BODY = YES; 289 | CLANG_WARN_ENUM_CONVERSION = YES; 290 | CLANG_WARN_INFINITE_RECURSION = YES; 291 | CLANG_WARN_INT_CONVERSION = YES; 292 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 293 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 294 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 295 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 296 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 297 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 298 | CLANG_WARN_STRICT_PROTOTYPES = YES; 299 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 300 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 301 | CLANG_WARN_UNREACHABLE_CODE = YES; 302 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 303 | COPY_PHASE_STRIP = NO; 304 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 305 | ENABLE_NS_ASSERTIONS = NO; 306 | ENABLE_STRICT_OBJC_MSGSEND = YES; 307 | GCC_C_LANGUAGE_STANDARD = gnu11; 308 | GCC_NO_COMMON_BLOCKS = YES; 309 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 310 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 311 | GCC_WARN_UNDECLARED_SELECTOR = YES; 312 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 313 | GCC_WARN_UNUSED_FUNCTION = YES; 314 | GCC_WARN_UNUSED_VARIABLE = YES; 315 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 316 | MTL_ENABLE_DEBUG_INFO = NO; 317 | MTL_FAST_MATH = YES; 318 | SDKROOT = iphoneos; 319 | SWIFT_COMPILATION_MODE = wholemodule; 320 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 321 | VALIDATE_PRODUCT = YES; 322 | }; 323 | name = Release; 324 | }; 325 | 05ADD90B24D5097F006E17EA /* Debug */ = { 326 | isa = XCBuildConfiguration; 327 | buildSettings = { 328 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 329 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 330 | CODE_SIGN_STYLE = Automatic; 331 | INFOPLIST_FILE = Sources/Info.plist; 332 | LD_RUNPATH_SEARCH_PATHS = ( 333 | "$(inherited)", 334 | "@executable_path/Frameworks", 335 | ); 336 | PRODUCT_BUNDLE_IDENTIFIER = com.thoughtbot.CombineViewModel.Example; 337 | PRODUCT_NAME = "$(TARGET_NAME)"; 338 | SWIFT_VERSION = 5.0; 339 | TARGETED_DEVICE_FAMILY = "1,2"; 340 | }; 341 | name = Debug; 342 | }; 343 | 05ADD90C24D5097F006E17EA /* Release */ = { 344 | isa = XCBuildConfiguration; 345 | buildSettings = { 346 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 347 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 348 | CODE_SIGN_STYLE = Automatic; 349 | INFOPLIST_FILE = Sources/Info.plist; 350 | LD_RUNPATH_SEARCH_PATHS = ( 351 | "$(inherited)", 352 | "@executable_path/Frameworks", 353 | ); 354 | PRODUCT_BUNDLE_IDENTIFIER = com.thoughtbot.CombineViewModel.Example; 355 | PRODUCT_NAME = "$(TARGET_NAME)"; 356 | SWIFT_VERSION = 5.0; 357 | TARGETED_DEVICE_FAMILY = "1,2"; 358 | }; 359 | name = Release; 360 | }; 361 | /* End XCBuildConfiguration section */ 362 | 363 | /* Begin XCConfigurationList section */ 364 | 05ADD8F124D5097F006E17EA /* Build configuration list for PBXProject "Example" */ = { 365 | isa = XCConfigurationList; 366 | buildConfigurations = ( 367 | 05ADD90824D5097F006E17EA /* Debug */, 368 | 05ADD90924D5097F006E17EA /* Release */, 369 | ); 370 | defaultConfigurationIsVisible = 0; 371 | defaultConfigurationName = Release; 372 | }; 373 | 05ADD90A24D5097F006E17EA /* Build configuration list for PBXNativeTarget "Example" */ = { 374 | isa = XCConfigurationList; 375 | buildConfigurations = ( 376 | 05ADD90B24D5097F006E17EA /* Debug */, 377 | 05ADD90C24D5097F006E17EA /* Release */, 378 | ); 379 | defaultConfigurationIsVisible = 0; 380 | defaultConfigurationName = Release; 381 | }; 382 | /* End XCConfigurationList section */ 383 | 384 | /* Begin XCSwiftPackageProductDependency section */ 385 | 05ADD91024D509BC006E17EA /* CombineViewModel */ = { 386 | isa = XCSwiftPackageProductDependency; 387 | productName = CombineViewModel; 388 | }; 389 | /* End XCSwiftPackageProductDependency section */ 390 | }; 391 | rootObject = 05ADD8EE24D5097F006E17EA /* Project object */; 392 | } 393 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/README.md: -------------------------------------------------------------------------------- 1 | # CombineViewModel Example App 2 | 3 | | Counter View | Tasks View | 4 | |-|-| 5 | |Example app with the Counter tab selected, showing a counter value of 42, a stepper control to increment or decrement the count, and a reset button|Example app with the Tasks tab selected, showing a list of four tasks: Groceries, Call parents, Cook dinner, and Find a new coffee shop| 6 | -------------------------------------------------------------------------------- /Example/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | final class AppDelegate: UIResponder, UIApplicationDelegate { 5 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 6 | UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Example/Sources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Example/Sources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Sources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Example/Sources/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | -------------------------------------------------------------------------------- /Example/Sources/Counter.swift: -------------------------------------------------------------------------------- 1 | import CombineViewModel 2 | import Foundation 3 | 4 | final class Counter: ObservableObject { 5 | let defaultValue: Int 6 | @Published var value: Int 7 | 8 | init(value: Int, defaultValue: Int) { 9 | self.defaultValue = defaultValue 10 | self.value = value 11 | } 12 | 13 | var formattedValue: String { 14 | NumberFormatter.localizedString( 15 | from: NSNumber(value: value), 16 | number: .decimal 17 | ) 18 | } 19 | 20 | var isDefault: Bool { 21 | value == defaultValue 22 | } 23 | 24 | func reset() { 25 | value = defaultValue 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Example/Sources/CounterViewController.swift: -------------------------------------------------------------------------------- 1 | import CombineViewModel 2 | import UIKit 3 | 4 | final class CounterViewController: UITableViewController, ViewModelObserver { 5 | @IBOutlet private var resetItem: UIBarButtonItem! 6 | @IBOutlet private var stepper: UIStepper! 7 | @IBOutlet private var valueLabel: UILabel! 8 | 9 | @ViewModel private var counter: Counter 10 | 11 | required init?(coder: NSCoder) { 12 | super.init(coder: coder) 13 | 14 | counter = Counter( 15 | value: UserDefaults.standard.integer(forKey: "counterValue"), 16 | defaultValue: 0 17 | ) 18 | } 19 | 20 | override func viewDidLoad() { 21 | super.viewDidLoad() 22 | 23 | stepper.maximumValue = .infinity 24 | stepper.minimumValue = -.infinity 25 | } 26 | 27 | func updateView() { 28 | resetItem.isEnabled = !counter.isDefault 29 | stepper.value = Double(counter.value) 30 | valueLabel.text = counter.formattedValue 31 | UserDefaults.standard.set(counter.value, forKey: "counterValue") 32 | } 33 | 34 | @IBAction func reset(_ sender: Any?) { 35 | counter.reset() 36 | } 37 | 38 | @IBAction func step(_ stepper: UIStepper) { 39 | counter.value = Int(stepper.value) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Example/Sources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | UISceneStoryboardFile 37 | Main 38 | 39 | 40 | 41 | 42 | UIApplicationSupportsIndirectInputEvents 43 | 44 | UILaunchStoryboardName 45 | LaunchScreen 46 | UIMainStoryboardFile 47 | Main 48 | UIRequiredDeviceCapabilities 49 | 50 | armv7 51 | 52 | UISupportedInterfaceOrientations 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationLandscapeLeft 56 | UIInterfaceOrientationLandscapeRight 57 | 58 | UISupportedInterfaceOrientations~ipad 59 | 60 | UIInterfaceOrientationPortrait 61 | UIInterfaceOrientationPortraitUpsideDown 62 | UIInterfaceOrientationLandscapeLeft 63 | UIInterfaceOrientationLandscapeRight 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /Example/Sources/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 4 | var window: UIWindow? 5 | } 6 | -------------------------------------------------------------------------------- /Example/Sources/TabBarController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class TabBarController: UITabBarController, UITabBarControllerDelegate { 4 | override func viewDidLoad() { 5 | super.viewDidLoad() 6 | 7 | delegate = self 8 | selectedIndex = UserDefaults.standard.integer(forKey: "selectedTab") 9 | } 10 | 11 | func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { 12 | let index = tabBarController.viewControllers!.firstIndex(of: viewController)! 13 | UserDefaults.standard.set(index, forKey: "selectedTab") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Example/Sources/TaskList.swift: -------------------------------------------------------------------------------- 1 | import CombineViewModel 2 | import Foundation 3 | 4 | struct Task: Hashable, Codable { 5 | var id: UUID 6 | var title: String 7 | } 8 | 9 | final class TaskList: ObservableObject { 10 | @Published private(set) var tasks: [Task] 11 | 12 | init(tasks: [Task] = []) { 13 | self.tasks = tasks 14 | } 15 | 16 | @discardableResult 17 | func appendNew(title: String) -> Task { 18 | let task = Task(id: UUID(), title: title) 19 | tasks.append(task) 20 | return task 21 | } 22 | 23 | @discardableResult 24 | func delete(at index: Int) -> Task { 25 | tasks.remove(at: index) 26 | } 27 | 28 | enum Move { 29 | case append(Task, after: Task) 30 | case insert(Task, before: Task) 31 | } 32 | 33 | @discardableResult 34 | func move(_ newTasks: Tasks, to index: Int) -> [Move]? where Tasks.Element == Task { 35 | let oldIDs = newTasks.reduce(into: Set()) { $0.insert($1.id) } 36 | tasks.removeAll { oldIDs.contains($0.id) } 37 | 38 | guard !tasks.isEmpty else { 39 | tasks.append(contentsOf: newTasks) 40 | return nil 41 | } 42 | 43 | var moves: [Move] = [] 44 | 45 | if index >= tasks.endIndex { 46 | for task in newTasks { 47 | let oldTask = tasks.last! 48 | tasks.append(task) 49 | moves.append(.append(task, after: oldTask)) 50 | } 51 | } else { 52 | for task in newTasks.reversed() { 53 | let oldTask = tasks[index] 54 | tasks.insert(task, at: index) 55 | moves.append(.insert(task, before: oldTask)) 56 | } 57 | } 58 | 59 | return moves 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Example/Sources/TasksViewController.swift: -------------------------------------------------------------------------------- 1 | import CombineViewModel 2 | import UIKit 3 | import os.log 4 | 5 | final class TasksViewController: UITableViewController, ViewModelObserver { 6 | private class DataSource: UITableViewDiffableDataSource { 7 | override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { 8 | true 9 | } 10 | } 11 | 12 | private enum Section { 13 | case tasks 14 | } 15 | 16 | private var dataSource: UITableViewDiffableDataSource! 17 | private let decoder = JSONDecoder() 18 | private let encoder = JSONEncoder() 19 | private let url = try! FileManager.default 20 | .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) 21 | .appendingPathComponent("tasks.json", isDirectory: false) 22 | @ViewModel private var taskList: TaskList 23 | 24 | required init?(coder: NSCoder) { 25 | super.init(coder: coder) 26 | 27 | do { 28 | let data = try Data(contentsOf: url) 29 | let tasks = try decoder.decode([Task].self, from: data) 30 | taskList = TaskList(tasks: tasks) 31 | } catch { 32 | os_log(.error, "Error loading task list: %@", "\(error)") 33 | taskList = TaskList() 34 | } 35 | } 36 | 37 | override func viewDidLoad() { 38 | super.viewDidLoad() 39 | 40 | dataSource = DataSource(tableView: tableView) { tableView, indexPath, item in 41 | let cell = tableView.dequeueReusableCell(withIdentifier: "Task", for: indexPath) 42 | cell.textLabel?.text = item.title 43 | return cell 44 | } 45 | tableView.dragInteractionEnabled = true 46 | tableView.dragDelegate = self 47 | tableView.dropDelegate = self 48 | } 49 | 50 | func updateView() { 51 | let oldTasks = dataSource.snapshot().itemIdentifiers 52 | guard !taskList.tasks.difference(from: oldTasks).isEmpty else { return } 53 | 54 | var snapshot = NSDiffableDataSourceSnapshot() 55 | snapshot.appendSections([.tasks]) 56 | snapshot.appendItems(taskList.tasks) 57 | dataSource.apply(snapshot, animatingDifferences: view.window != nil) 58 | 59 | do { 60 | let data = try encoder.encode(taskList.tasks) 61 | try data.write(to: url) 62 | } catch { 63 | os_log(.error, "Error saving task list: %@", "\(error)") 64 | } 65 | } 66 | 67 | @IBAction func newTask(_ sender: Any?) { 68 | let alert = UIAlertController( 69 | title: "New Task", 70 | message: nil, 71 | preferredStyle: .alert 72 | ) 73 | 74 | alert.addTextField { textField in 75 | textField.autocapitalizationType = .sentences 76 | textField.enablesReturnKeyAutomatically = true 77 | textField.returnKeyType = .done 78 | } 79 | 80 | let save = UIAlertAction(title: "Save", style: .default) { [weak alert] _ in 81 | guard let textField = alert?.textFields?.first else { return } 82 | self.saveNewTask(textField) 83 | } 84 | alert.addAction(save) 85 | 86 | let cancel = UIAlertAction(title: "Cancel", style: .cancel) 87 | alert.addAction(cancel) 88 | 89 | present(alert, animated: true) 90 | } 91 | 92 | @objc func saveNewTask(_ textField: UITextField) { 93 | guard let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines), 94 | !text.isEmpty 95 | else { return } 96 | 97 | let task = taskList.appendNew(title: text) 98 | var snapshot = dataSource.snapshot() 99 | if snapshot.sectionIdentifiers.isEmpty { 100 | snapshot.appendSections([.tasks]) 101 | } 102 | snapshot.appendItems([task]) 103 | dataSource.apply(snapshot) 104 | } 105 | } 106 | 107 | private extension TasksViewController { 108 | func move(_ tasks: Tasks, to indexPath: IndexPath) where Tasks.Element == Task { 109 | if let moves = taskList.move(tasks, to: indexPath.row) { 110 | var snapshot = dataSource.snapshot() 111 | for move in moves { 112 | switch move { 113 | case let .append(task, after: otherTask): 114 | snapshot.moveItem(task, afterItem: otherTask) 115 | case let .insert(task, before: otherTask): 116 | snapshot.moveItem(task, beforeItem: otherTask) 117 | } 118 | } 119 | dataSource.apply(snapshot) 120 | } 121 | } 122 | } 123 | 124 | extension TasksViewController/*: UITableViewDelegate */ { 125 | override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { 126 | let delete = UIContextualAction(style: .destructive, title: "Delete") { [taskList] _, _, completion in 127 | let task = taskList.delete(at: indexPath.row) 128 | var snapshot = self.dataSource.snapshot() 129 | snapshot.deleteItems([task]) 130 | self.dataSource.apply(snapshot, animatingDifferences: true) 131 | completion(true) 132 | } 133 | let configuration = UISwipeActionsConfiguration(actions: [delete]) 134 | configuration.performsFirstActionWithFullSwipe = true 135 | return configuration 136 | } 137 | } 138 | 139 | extension TasksViewController: UITableViewDragDelegate { 140 | func tableView(_ tableView: UITableView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool { 141 | true 142 | } 143 | 144 | func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { 145 | [taskDragItem(at: indexPath)] 146 | } 147 | 148 | func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] { 149 | [taskDragItem(at: indexPath)] 150 | } 151 | 152 | private func taskDragItem(at indexPath: IndexPath) -> UIDragItem { 153 | let task = taskList.tasks[indexPath.row] 154 | let item = UIDragItem(itemProvider: NSItemProvider()) 155 | item.localObject = task 156 | return item 157 | } 158 | } 159 | 160 | extension TasksViewController: UITableViewDropDelegate { 161 | func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal { 162 | guard tableView.hasActiveDrag else { 163 | return UITableViewDropProposal(operation: .cancel) 164 | } 165 | 166 | if session.items.count > 1 { 167 | // .unspecified is required to support dropping multiple items: 168 | // https://twitter.com/smileyborg/status/879775985712975872 169 | return UITableViewDropProposal(operation: .move, intent: .unspecified) 170 | } else { 171 | return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) 172 | } 173 | } 174 | 175 | func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) { 176 | guard let indexPath = coordinator.destinationIndexPath else { return } 177 | let items = coordinator.session.items 178 | let tasks = items.map { $0.localObject as! Task } 179 | move(tasks, to: indexPath) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-20 thoughtbot, inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "CombineViewModel", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | ], 13 | products: [ 14 | .library(name: "CombineViewModel", targets: ["CombineViewModel"]), 15 | .library(name: "Bindings", targets: ["Bindings"]), 16 | .library(name: "UIKitBindings", targets: ["UIKitBindings"]), 17 | ], 18 | targets: [ 19 | .target(name: "CombineViewModel", dependencies: ["Bindings"]), 20 | .target(name: "Bindings"), 21 | .target(name: "UIKitBindings", dependencies: ["Bindings"]), 22 | 23 | .testTarget(name: "CombineViewModelTests", dependencies: ["CombineViewModel", "ObjCTestSupport"]), 24 | .target(name: "ObjCTestSupport", path: "Tests/ObjCTestSupport"), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CombineViewModel 2 | 3 | An implementation of the Model-View-ViewModel (MVVM) pattern using Combine. 4 | 5 | - [Introduction](#introduction) 6 | - [Installation](#installation) 7 | - [Bindings](#bindings) 8 | - [Contributing](#contributing) 9 | - [About](#about) 10 | 11 | ## Introduction 12 | 13 | CombineViewModel’s primary goal is to make view updates as easy in UIKit and 14 | AppKit as they are in SwiftUI. 15 | 16 | In SwiftUI, you write model and view-model classes that conform to Combine’s 17 | [`ObservableObject`][ObservableObject] protocol. SwiftUI: 18 | 19 | 1. Observes each model’s `objectWillChange` publisher via the 20 | [`@ObservedObject`][ObservedObject] property wrapper, and; 21 | 2. Automatically rerenders the appropriate portion of the view hierarchy. 22 | 23 | The problem with `objectWillChange` _outside_ of SwiftUI is that there's no 24 | built-in way of achieving (2) — being notified that an object _will_ change is 25 | not the same as knowing that it _did_ change and it’s time to update the view. 26 | 27 | [ObservableObject]: https://developer.apple.com/documentation/combine/observableobject 28 | [ObservedObject]: https://developer.apple.com/documentation/swiftui/observedobject 29 | 30 | ### `ObjectDidChangePublisher` 31 | 32 | Consider the following sketch of a view model for displaying a user’s social 33 | networking profile: 34 | 35 | ```swift 36 | // ProfileViewModel.swift 37 | 38 | import CombineViewModel 39 | import UIKit 40 | 41 | class ProfileViewModel: ObservableObject { 42 | @Published var profileImage: UIImage? 43 | @Published var topPosts: [Post] 44 | 45 | func refresh() { 46 | // Request updated profile info from the server. 47 | } 48 | } 49 | ``` 50 | 51 | With CombineViewModel, you can subscribe to _did change_ notifications using 52 | the `observe(on:)` operator: 53 | 54 | ```swift 55 | let profile = ProfileViewModel() 56 | 57 | profileSubscription = profile.observe(on: DispatchQueue.main).sink { profile in 58 | // Called on the main queue when either (or both) of `profileImage` 59 | // or `topPosts` have changed. 60 | } 61 | 62 | profile.refresh() 63 | ``` 64 | 65 | ### Automatic view updates 66 | 67 | Building on `ObjectDidChangePublisher` is the `ViewModelObserver` protocol and 68 | `@ViewModel` property wrapper. Instead of manually managing the 69 | `ObjectDidChangePublisher` subscription like above, we can have it managed 70 | automatically: 71 | 72 | ```swift 73 | // ProfileViewController.swift 74 | 75 | import CombineViewModel 76 | import UIKit 77 | 78 | // 1️⃣ Conform your view controller to the ViewModelObserver protocol. 79 | class ProfileViewController: UITableViewController, ViewModelObserver { 80 | enum Section: Int { 81 | case topPosts 82 | } 83 | 84 | @IBOutlet private var profileImageView: UIImageView! 85 | private var dataSource: UITableViewDiffableDataSource! 86 | 87 | // 2️⃣ Declare your view model using the `@ViewModel` property wrapper. 88 | @ViewModel private var profile: ProfileViewModel 89 | 90 | // 3️⃣ Initialize your view model in init(). 91 | required init?(profile: ProfileViewModel, coder: NSCoder) { 92 | super.init(coder: coder) 93 | self.profile = profile 94 | } 95 | 96 | // 4️⃣ The `updateView()` method is automatically called on the main queue 97 | // when the view model changes. It is always called after `viewDidLoad()`. 98 | func updateView() { 99 | profileImageView.image = profile.profileImage 100 | 101 | var snapshot = NSDiffableDataSourceSnapshot() 102 | snapshot.appendSections([.topPosts]) 103 | snapshot.appendItems(profile.topPosts) 104 | dataSource.apply(snapshot) 105 | } 106 | 107 | override func viewWillAppear(_ animated: Bool) { 108 | super.viewWillAppear(animated) 109 | 110 | profile.refresh() 111 | } 112 | } 113 | ``` 114 | 115 | ### Further reading 116 | 117 | In the [Example](/Example) directory you’ll find a complete iOS sample 118 | application that demonstrates how to integrate CombineViewModel into your 119 | application. 120 | 121 | ## Installation 122 | 123 | CombineViewModel is distributed via Swift Package Manager. To add it to your 124 | Xcode project, navigate to File > Add Package Dependency…, paste in the 125 | repository URL, and follow the prompts. 126 | 127 | Screen capture of Xcode on macOS Big Sur, with the Add Package Dependency menu item highlighted 128 | 129 | ## Bindings 130 | 131 | CombineViewModel also provides the complementary [`Bindings`](/Sources/Bindings) 132 | module. It provides two operators — `<~`, the **input binding operator**, and 133 | `~>`, the **output binding operator** — along with various types and protocols 134 | that support it. Note that the concept of a "binding" provided by the Bindings 135 | module is different to [SwiftUI's `Binding` type][Binding]. 136 | 137 | [Binding]: https://developer.apple.com/documentation/swiftui/binding 138 | 139 | Platform-specific binding helpers are also provided: 140 | 141 | - [UIKitBindings](/Sources/UIKitBindings) 142 | 143 | ## Contributing 144 | 145 | Have a useful reactive extension in your project? 146 | Please consider contributing it back to the community! 147 | 148 | For more details, see the [CONTRIBUTING][] document. 149 | Thank you, [contributors][]! 150 | 151 | [CONTRIBUTING]: CONTRIBUTING.md 152 | [contributors]: https://github.com/thoughtbot/Bindings/graphs/contributors 153 | 154 | ## License 155 | 156 | CombineViewModel is Copyright © 2019–20 thoughtbot, inc. 157 | It is free software, and may be redistributed 158 | under the terms specified in the [LICENSE][] file. 159 | 160 | [LICENSE]: /LICENSE 161 | 162 | ## About 163 | 164 | ![thoughtbot](http://presskit.thoughtbot.com/images/thoughtbot-logo-for-readmes.svg) 165 | 166 | CombineViewModel is maintained and funded by thoughtbot, inc. 167 | The names and logos for thoughtbot are trademarks of thoughtbot, inc. 168 | 169 | We love open source software! 170 | See [our other projects][community] 171 | or [hire us][hire] to help build your product. 172 | 173 | [community]: https://thoughtbot.com/community?utm_source=github 174 | [hire]: https://thoughtbot.com/hire-us?utm_source=github 175 | -------------------------------------------------------------------------------- /Sources/Bindings/BindingOwner.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | private var bindingOwnerSubscriptionsKey: UInt8 = 0 5 | 6 | public protocol BindingOwner: AnyObject, ReactiveExtensionProvider { 7 | associatedtype Subscriptions: Collection & ExpressibleByArrayLiteral = Set 8 | where Subscriptions.Element == AnyCancellable 9 | 10 | var subscriptions: Subscriptions { get set } 11 | func store(_ subcription: C) 12 | } 13 | 14 | extension NSObject: BindingOwner {} 15 | 16 | extension BindingOwner where Subscriptions: RangeReplaceableCollection { 17 | @inlinable 18 | public var subscriptions: Subscriptions { 19 | _read { yield _subscriptions } 20 | set { _subscriptions = newValue } 21 | _modify { yield &_subscriptions } 22 | } 23 | 24 | @inlinable 25 | public func store(_ subscription: C) { 26 | subscription.store(in: &subscriptions) 27 | } 28 | } 29 | 30 | extension BindingOwner where Subscriptions == Set { 31 | @inlinable 32 | public var subscriptions: Subscriptions { 33 | _read { yield _subscriptions } 34 | set { _subscriptions = newValue } 35 | _modify { yield &_subscriptions } 36 | } 37 | 38 | @inlinable 39 | public func store(_ subscription: C) { 40 | subscription.store(in: &subscriptions) 41 | } 42 | } 43 | 44 | extension BindingOwner { 45 | @usableFromInline 46 | var _subscriptions: Subscriptions { 47 | _read { 48 | yield _getBox().value 49 | } 50 | set { 51 | _getBox().value = newValue 52 | } 53 | _modify { 54 | yield &_getBox().value 55 | } 56 | } 57 | 58 | @usableFromInline 59 | func _getBox() -> Box { 60 | let box: Box 61 | if let object = objc_getAssociatedObject(self, &bindingOwnerSubscriptionsKey) { 62 | box = object as! Box 63 | } else { 64 | box = Box([]) 65 | objc_setAssociatedObject(self, &bindingOwnerSubscriptionsKey, box, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 66 | } 67 | return box 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/Bindings/BindingSink.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public final class BindingSink { 4 | public typealias Failure = Never 5 | 6 | private(set) weak var owner: Owner? 7 | private let receiveCompletion: (Owner, Subscribers.Completion) -> Void 8 | private let receiveValue: (Owner, Input) -> Void 9 | private var subscription: Subscription? 10 | 11 | public init(owner: Owner, receiveCompletion: @escaping (Owner, Subscribers.Completion) -> Void = { _, _ in }, receiveValue: @escaping (Owner, Input) -> Void) { 12 | self.owner = owner 13 | self.receiveCompletion = receiveCompletion 14 | self.receiveValue = receiveValue 15 | } 16 | 17 | private func withOwner(_ body: (Owner) -> Void) { 18 | if let owner = owner { 19 | body(owner) 20 | } else { 21 | cancel() 22 | } 23 | } 24 | } 25 | 26 | extension BindingSink where Input == Void { 27 | public convenience init(owner: Owner, receiveCompletion: @escaping (Owner, Subscribers.Completion) -> Void = { _, _ in }, receiveValue: @escaping (Owner) -> Void) { 28 | self.init(owner: owner, receiveCompletion: receiveCompletion, receiveValue: { owner, _ in receiveValue(owner) }) 29 | } 30 | } 31 | 32 | extension BindingSink where Input == Never { 33 | public convenience init(owner: Owner, receiveCompletion: @escaping (Owner, Subscribers.Completion) -> Void) { 34 | self.init(owner: owner, receiveCompletion: receiveCompletion, receiveValue: { _, _ in }) 35 | } 36 | } 37 | 38 | extension BindingSink: Subscriber { 39 | public func receive(subscription: Subscription) { 40 | if owner != nil { 41 | subscription.request(.unlimited) 42 | self.subscription = subscription 43 | } else { 44 | subscription.cancel() 45 | } 46 | } 47 | 48 | public func receive(_ input: Input) -> Subscribers.Demand { 49 | withOwner { receiveValue($0, input) } 50 | return .max(1) 51 | } 52 | 53 | public func receive(completion: Subscribers.Completion) { 54 | withOwner { receiveCompletion($0, completion) } 55 | } 56 | } 57 | 58 | extension BindingSink: Cancellable { 59 | public func cancel() { 60 | subscription?.cancel() 61 | subscription = nil 62 | owner = nil 63 | } 64 | } 65 | 66 | extension BindingSink: BindingSubscriber { 67 | @discardableResult 68 | public static func <~ (sink: BindingSink, publisher: P) -> AnyCancellable 69 | where P.Output == Input, P.Failure == Failure 70 | { 71 | guard let owner = sink.owner else { return AnyCancellable({}) } 72 | let cancellable = AnyCancellable(sink) 73 | owner.store(cancellable) 74 | publisher.subscribe(sink) 75 | return cancellable 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/Bindings/BindingSubscriber.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | infix operator <~: DefaultPrecedence 4 | 5 | public protocol BindingSubscriber: Subscriber, Cancellable { 6 | @discardableResult 7 | static func <~ (subscriber: Self, source: P) -> AnyCancellable 8 | where P.Output == Input, P.Failure == Failure 9 | } 10 | 11 | extension Publisher { 12 | @discardableResult 13 | public static func ~> (source: Self, subscriber: B) -> AnyCancellable 14 | where Output == B.Input, Failure == B.Failure 15 | { 16 | subscriber <~ source 17 | } 18 | } 19 | 20 | // MARK: Optional 21 | 22 | extension BindingSubscriber { 23 | @discardableResult 24 | public static func <~ (subscriber: Self, source: P) -> AnyCancellable 25 | where Input == P.Output?, Failure == P.Failure 26 | { 27 | subscriber <~ source.map(Optional.some) 28 | } 29 | } 30 | 31 | extension Publisher { 32 | @discardableResult 33 | public static func ~> (source: Self, subscriber: B) -> AnyCancellable 34 | where B.Input == Output?, B.Failure == Failure 35 | { 36 | subscriber <~ source 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Bindings/Box.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | final class Box { 3 | var value: T 4 | 5 | init(_ value: T) { 6 | self.value = value 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/Bindings/README.md: -------------------------------------------------------------------------------- 1 | # Bindings 2 | 3 | Unidirectional binding operators and reactive extensions for Cocoa 4 | classes. 5 | 6 | ## The operators 7 | 8 | *Bindings* provides two operators: `<~`, the **input binding operator**, and 9 | `~>`, the **output binding operator**. 10 | 11 | ### Input bindings update the state of your UI 12 | 13 | ```swift 14 | import Bindings 15 | import UIKitBindings 16 | 17 | nameLabel.reactive.text <~ viewModel.fullName 18 | ``` 19 | 20 | ### Output bindings respond to changes 21 | 22 | ```swift 23 | nameField.reactive.edited ~> viewModel.setName 24 | UIApplication.reactive.didBecomeActiveNotification ~> viewModel.refresh 25 | ``` 26 | 27 | ## Common bindings for system classes 28 | 29 | In addition to the core operator definitions, *Bindings* also provides 30 | conveniences for using bindings with many system classes. Take a look at the 31 | following modules to see what's provided (and consider 32 | [contributing][CONTRIBUTING]!): 33 | 34 | - [`UIKitBindings`][uikit] for apps using UIKit (iOS, iPadOS, Catalyst and more) 35 | - More to come... 36 | 37 | [uikit]: /Sources/UIKitBindings 38 | 39 | ## Reactive extensions 40 | 41 | It's convenient to group reactive extensions into their own namespace, 42 | especially when extending types you don't own with reactive APIs. 43 | 44 | Use the `ReactiveExtensionProvider` protocol to define the `.reactive` property 45 | on your types: 46 | 47 | ```swift 48 | extension MyViewModel: ReactiveExtensionProvider {} 49 | ``` 50 | 51 | Then add reactive extensions via the `Reactive` type: 52 | 53 | ```swift 54 | extension UserDefaults { 55 | var username: String? { 56 | get { string(forKey: "username") } 57 | set { set(newValue, forKey: "username") } 58 | } 59 | } 60 | 61 | extension Reactive where Base: UserDefaults { 62 | var username: NSObject.KeyValueObservingPublisher { 63 | base.publisher(for: \.username) 64 | } 65 | } 66 | 67 | UserDefaults.standard.reactive.username.sink { username in 68 | print("New username: \(username)") 69 | } 70 | ``` 71 | 72 | ## Bindings last for the lifetime of their owner 73 | 74 | In Combine, the `AnyCancellable` class controls the lifetime of a subscription. 75 | Subscriptions can be automatically cancelled by using the `store(in:)` method: 76 | 77 | ```swift 78 | import UIKit 79 | 80 | class MyViewController: UIViewController { 81 | @IBOutlet private var usernameLabel: UILabel! 82 | private var subscriptions: [AnyCancellable] = [] 83 | 84 | override func viewDidLoad() { 85 | super.viewDidLoad() 86 | 87 | UserDefaults.standard.reactive.username 88 | .sink { [usernameLabel] in usernameLabel?.text = $0 } 89 | .store(in: &subscriptions) 90 | } 91 | } 92 | ``` 93 | 94 | _Bindings_ encapsulates this pattern with the `BindingOwner` protocol, which 95 | automatically defines a `subscriptions` collection on all conforming classes. 96 | The `BindingSink` subscriber then automatically disposes of the binding 97 | subscription when its `owner` goes out of scope: 98 | 99 | ```swift 100 | extension Reactive where Base: UILabel { 101 | var text: BindingSink { 102 | BindingSink(owner: base) { label, newValue in 103 | label.text = newValue 104 | } 105 | } 106 | } 107 | 108 | // automatically cancelled when `usernameLabel` goes out of scope 109 | usernameLabel.reactive.text <~ UserDefaults.standard.reactive.username 110 | ``` 111 | -------------------------------------------------------------------------------- /Sources/Bindings/ReactiveExtensionProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ReactiveExtensionProvider {} 4 | 5 | public struct Reactive { 6 | public let base: Base 7 | 8 | fileprivate init(_ base: Base) { 9 | self.base = base 10 | } 11 | } 12 | 13 | extension ReactiveExtensionProvider { 14 | public var reactive: Reactive { 15 | Reactive(self) 16 | } 17 | 18 | public static var reactive: Reactive.Type { 19 | Reactive.self 20 | } 21 | } 22 | 23 | extension NSObject: ReactiveExtensionProvider {} 24 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/ClassHierarchy.swift: -------------------------------------------------------------------------------- 1 | #if canImport(ObjectiveC) 2 | import ObjectiveC.runtime 3 | 4 | struct ClassHierarchy: Sequence { 5 | struct Iterator: IteratorProtocol { 6 | var `class`: AnyClass? 7 | 8 | init(class: AnyClass) { 9 | self.class = `class` 10 | } 11 | 12 | mutating func next() -> AnyClass? { 13 | guard let next = `class` else { return nil } 14 | `class` = class_getSuperclass(next) 15 | return next 16 | } 17 | } 18 | 19 | var `class`: AnyClass 20 | 21 | func makeIterator() -> Iterator { 22 | Iterator(class: `class`) 23 | } 24 | } 25 | 26 | extension ClassHierarchy { 27 | init?(object: Any) { 28 | guard let `class` = object_getClass(object) else { return nil } 29 | self.init(class: `class`) 30 | } 31 | } 32 | #endif 33 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/CombineExports.swift: -------------------------------------------------------------------------------- 1 | @_exported import protocol Combine.Cancellable 2 | @_exported import protocol Combine.ObservableObject 3 | @_exported import struct Combine.Published 4 | @_exported import class Combine.AnyCancellable 5 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/DispatchQueue+EventSourceScheduler.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Dispatch 3 | 4 | extension DispatchQueue: EventSourceScheduler { 5 | public typealias EventSourceOptions = Never 6 | public typealias EventSourceType = EventSource 7 | 8 | public final class EventSource { 9 | @UnfairAtomic private var isCancelled = false 10 | private let source: DispatchSourceUserDataAdd 11 | 12 | init(queue: DispatchQueue, eventHandler: @escaping (DispatchQueue.EventSource) -> Void) { 13 | self.source = DispatchSource.makeUserDataAddSource(queue: queue) 14 | source.setEventHandler { [weak self] in 15 | guard let self = self else { return } 16 | eventHandler(self) 17 | } 18 | } 19 | 20 | deinit { 21 | cancel() 22 | } 23 | 24 | public var eventCount: Int { 25 | Int(source.data) 26 | } 27 | 28 | func resume() { 29 | source.resume() 30 | } 31 | } 32 | 33 | public func scheduleEventSource(options: EventSourceOptions? = nil, eventHandler: @escaping (EventSource) -> Void) -> EventSource { 34 | let source = EventSource(queue: self, eventHandler: eventHandler) 35 | source.resume() 36 | return source 37 | } 38 | } 39 | 40 | extension DispatchQueue.EventSource: EventSource { 41 | public func signal() { 42 | if !isCancelled { 43 | source.add(data: 1) 44 | } 45 | } 46 | 47 | public func cancel() { 48 | let wasCancelled = $isCancelled.swap(true) 49 | if !wasCancelled { 50 | source.cancel() 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/EventSource.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public protocol EventSource: AnyObject, Cancellable { 4 | func signal() 5 | } 6 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/EventSourceScheduler.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public protocol EventSourceScheduler: Scheduler { 4 | associatedtype EventSourceOptions 5 | associatedtype EventSourceType: EventSource 6 | 7 | func scheduleEventSource( 8 | options: EventSourceOptions?, 9 | eventHandler: @escaping (EventSourceType) -> Void 10 | ) -> EventSourceType 11 | } 12 | 13 | extension EventSourceScheduler { 14 | public func scheduleEventSource( 15 | eventHandler: @escaping (EventSourceType) -> Void 16 | ) -> EventSourceType { 17 | scheduleEventSource(options: nil, eventHandler: eventHandler) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/MethodList.swift: -------------------------------------------------------------------------------- 1 | #if canImport(ObjectiveC) 2 | import ObjectiveC.runtime 3 | 4 | struct MethodList { 5 | private let buffer: UnsafeBufferPointer 6 | 7 | init?(class: AnyClass) { 8 | var count = UInt32(0) 9 | guard let list = class_copyMethodList(`class`, &count) else { return nil } 10 | self.buffer = UnsafeBufferPointer(start: list, count: Int(count)) 11 | } 12 | } 13 | 14 | extension MethodList: RandomAccessCollection { 15 | var startIndex: Int { 16 | buffer.startIndex 17 | } 18 | 19 | var endIndex: Int { 20 | buffer.endIndex 21 | } 22 | 23 | subscript(position: Int) -> Method { 24 | buffer[position] 25 | } 26 | } 27 | #endif 28 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/ObjCRuntime.swift: -------------------------------------------------------------------------------- 1 | #if canImport(ObjectiveC) 2 | import ObjectiveC.runtime 3 | import Foundation 4 | 5 | private let _UIViewController: AnyClass? = NSClassFromString("UIViewController") 6 | private var _isHookedKey = UInt8(0) 7 | private var _shouldPostKey = UInt8(0) 8 | 9 | private typealias ViewDidLoadBlock = @convention(block) (Any) -> Void 10 | private typealias ViewDidLoadFunction = @convention(c) (Any, Selector) -> Void 11 | 12 | func combinevm_isHooked(_ object: Any) -> Bool { 13 | objc_getAssociatedObject(object, &_isHookedKey) as? Bool ?? false 14 | } 15 | 16 | private func combinevm_setIsHooked(_ object: Any, _ isHooked: Bool) { 17 | objc_setAssociatedObject(object, &_isHookedKey, isHooked, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 18 | } 19 | 20 | private func combinevm_shouldPost(_ object: Any) -> Bool { 21 | objc_getAssociatedObject(object, &_shouldPostKey) as? Bool ?? true 22 | } 23 | 24 | private func combinevm_setShouldPost(_ object: Any, _ shouldPost: Bool) { 25 | objc_setAssociatedObject(object, &_shouldPostKey, shouldPost, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 26 | } 27 | 28 | #if canImport(UIKit) && !os(watchOS) 29 | import class UIKit.UIViewController 30 | 31 | extension UIViewController { 32 | @nonobjc static let viewDidLoadNotification = Notification.Name("CombineViewModelViewDidLoad") 33 | 34 | @nonobjc func hookViewDidLoad() { 35 | guard !combinevm_isHooked(type(of: self)) else { return } 36 | 37 | var originalIMP: IMP! 38 | let `class`: Any 39 | let method: Method 40 | let selector = #selector(viewDidLoad) 41 | 42 | (method, `class`) = object_getInstanceMethod(self, name: selector)! 43 | 44 | let block: ViewDidLoadBlock = { `self` in 45 | let shouldPost = combinevm_shouldPost(self) 46 | let viewDidLoad = unsafeBitCast(originalIMP!, to: ViewDidLoadFunction.self) 47 | 48 | if shouldPost { 49 | combinevm_setShouldPost(self, false) 50 | } 51 | 52 | viewDidLoad(self, selector) 53 | 54 | if shouldPost { 55 | combinevm_setShouldPost(self, true) 56 | NotificationCenter.default.post(name: UIViewController.viewDidLoadNotification, object: self) 57 | } 58 | } 59 | 60 | originalIMP = method_setImplementation(method, imp_implementationWithBlock(block)) 61 | 62 | combinevm_setIsHooked(`class`, true) 63 | } 64 | } 65 | #endif // canImport(UIKit) && !os(watchOS) 66 | 67 | private func object_getInstanceMethod(_ object: Any, name: Selector) -> (method: Method, class: AnyClass)? { 68 | guard var hierarchy = ClassHierarchy(object: object)?.makeIterator() else { return nil } 69 | var stop = false 70 | 71 | while !stop, let `class` = hierarchy.next() { 72 | if `class` === _UIViewController { 73 | stop = true 74 | } 75 | 76 | guard let methods = MethodList(class: `class`) else { continue } 77 | 78 | if let method = methods.first(where: { method_getName($0) == name }) { 79 | return (method, `class`) 80 | } 81 | } 82 | 83 | return nil 84 | } 85 | 86 | private func object_isHooked(_ object: Any) -> Bool { 87 | guard let hierarchy = ClassHierarchy(object: object) else { return false } 88 | 89 | for `class` in hierarchy { 90 | if combinevm_isHooked(`class`) { 91 | return true 92 | } 93 | 94 | if `class` === _UIViewController { 95 | break 96 | } 97 | } 98 | 99 | return false 100 | } 101 | #endif 102 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/ObjectDidChangePublisher.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | extension ObservableObject { 4 | public func observe( 5 | on scheduler: S, 6 | options: S.EventSourceOptions? = nil 7 | ) -> ObjectDidChangePublisher { 8 | ObjectDidChangePublisher(object: self, scheduler: scheduler) 9 | } 10 | } 11 | 12 | public struct ObjectDidChangePublisher: Publisher { 13 | public typealias Output = Object 14 | public typealias Failure = Never 15 | 16 | public let object: Object 17 | public let scheduler: Context 18 | 19 | public func receive(subscriber: S) where S.Input == Output, S.Failure == Failure { 20 | let subscription = Subscription(object: object, scheduler: scheduler, subscriber: subscriber) 21 | subscriber.receive(subscription: subscription) 22 | } 23 | } 24 | 25 | private extension ObjectDidChangePublisher { 26 | final class Subscription where S.Input == Output, S.Failure == Failure { 27 | private weak var object: Object? 28 | 29 | @UnfairAtomic private var state: ( 30 | demand: Subscribers.Demand, 31 | subscriber: S?, 32 | subscription: AnyCancellable? 33 | ) 34 | 35 | init(object: Object, scheduler: Context, subscriber: S) { 36 | weak var weakSelf: Subscription? 37 | 38 | let source = scheduler.scheduleEventSource { _ in 39 | weakSelf?.serviceDemand() 40 | } 41 | 42 | let subscription = object.objectWillChange.sink( 43 | receiveCompletion: { _ in weakSelf?.finish() }, 44 | receiveValue: { _ in source.signal() } 45 | ) 46 | 47 | self.object = object 48 | self._state = UnfairAtomic((.none, subscriber, subscription)) 49 | weakSelf = self 50 | } 51 | 52 | func finish() { 53 | if case let (_, subscriber?, _) = $state.swap((.none, nil, nil)) { 54 | subscriber.receive(completion: .finished) 55 | } 56 | } 57 | 58 | func serviceDemand() { 59 | guard let output = object else { 60 | finish() 61 | return 62 | } 63 | 64 | let subscriber = $state.withLock { state -> S? in 65 | guard let subscriber = state.subscriber, state.demand > 0 else { 66 | return nil 67 | } 68 | 69 | state.demand -= 1 70 | return subscriber 71 | } 72 | 73 | if let subscriber = subscriber { 74 | let newDemand = subscriber.receive(output) 75 | 76 | if newDemand > 0 { 77 | state.demand += newDemand 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | extension ObjectDidChangePublisher.Subscription: Cancellable { 85 | func cancel() { 86 | state = (.none, nil, nil) 87 | } 88 | } 89 | 90 | extension ObjectDidChangePublisher.Subscription: Subscription { 91 | func request(_ demand: Subscribers.Demand) { 92 | assert(demand > 0, "Demand must be positive") 93 | state.demand += demand 94 | serviceDemand() 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/RunLoop+EventSourceScheduler.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | extension RunLoop: EventSourceScheduler { 5 | public struct EventSourceOptions { 6 | public var mode: RunLoop.Mode 7 | 8 | public init(mode: RunLoop.Mode) { 9 | self.mode = mode 10 | } 11 | } 12 | 13 | public typealias EventSourceType = EventSource 14 | 15 | public final class EventSource { 16 | private typealias Ref = Weak 17 | 18 | private static let eventHandler: @convention(c) (UnsafeMutableRawPointer?) -> Void = { context in 19 | Unmanaged.fromOpaque(context!).takeUnretainedValue().object?.handleEvent() 20 | } 21 | 22 | private static let cancelHandler: @convention(c) (UnsafeMutableRawPointer?, CFRunLoop?, CFRunLoopMode?) -> Void = { context, _, _ in 23 | Unmanaged.fromOpaque(context!).release() 24 | } 25 | 26 | private let eventHandler: (EventSource) -> Void 27 | private let mode: CFRunLoopMode 28 | private let runLoop: CFRunLoop 29 | private var source: CFRunLoopSource! 30 | @UnfairAtomic private var isCancelled = false 31 | 32 | init(runLoop: RunLoop, mode: RunLoop.Mode, eventHandler: @escaping (EventSource) -> Void) { 33 | self.eventHandler = eventHandler 34 | self.mode = CFRunLoopMode(mode.rawValue as CFString) 35 | self.runLoop = runLoop.getCFRunLoop() 36 | 37 | let ref = Weak(self) 38 | var context = CFRunLoopSourceContext() 39 | context.info = Unmanaged.passRetained(ref).toOpaque() 40 | context.perform = EventSource.eventHandler 41 | context.cancel = EventSource.cancelHandler 42 | self.source = CFRunLoopSourceCreate(nil, 0, &context)! 43 | } 44 | 45 | deinit { 46 | cancel() 47 | } 48 | 49 | func handleEvent() { 50 | eventHandler(self) 51 | } 52 | 53 | func schedule() { 54 | CFRunLoopAddSource(runLoop, source, mode) 55 | } 56 | } 57 | 58 | public func scheduleEventSource(options: EventSourceOptions? = nil, eventHandler: @escaping (EventSource) -> Void) -> EventSource { 59 | let mode = options?.mode ?? .default 60 | let source = EventSource(runLoop: self, mode: mode, eventHandler: eventHandler) 61 | source.schedule() 62 | return source 63 | } 64 | } 65 | 66 | extension RunLoop.EventSource: EventSource { 67 | public func signal() { 68 | guard !isCancelled else { return } 69 | CFRunLoopSourceSignal(source) 70 | CFRunLoopWakeUp(runLoop) 71 | } 72 | 73 | public func cancel() { 74 | let wasCancelled = $isCancelled.swap(true) 75 | if !wasCancelled { 76 | CFRunLoopRemoveSource(runLoop, source, mode) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/UIViewController+ViewDidLoadPublisher.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) && !os(watchOS) 2 | import Combine 3 | import UIKit 4 | 5 | extension UIViewController { 6 | var viewDidLoadPublisher: AnyPublisher { 7 | if let view = viewIfLoaded { 8 | return Just(view).eraseToAnyPublisher() 9 | } else { 10 | return NotificationCenter.default 11 | .publisher(for: UIViewController.viewDidLoadNotification, object: self) 12 | .first() 13 | .map { ($0.object as! UIViewController).view } 14 | .eraseToAnyPublisher() 15 | } 16 | } 17 | } 18 | #endif // canImport(UIKit) && !os(watchOS) 19 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/UnfairAtomic.swift: -------------------------------------------------------------------------------- 1 | import os.lock 2 | 3 | @propertyWrapper 4 | struct UnfairAtomic { 5 | private class Buffer: ManagedBuffer { 6 | deinit { 7 | _ = withUnsafeMutablePointerToElements { 8 | $0.deinitialize(count: 1) 9 | } 10 | } 11 | } 12 | 13 | private let buffer: Buffer 14 | 15 | init(_ wrappedValue: Value) { 16 | self.init(wrappedValue: wrappedValue) 17 | } 18 | 19 | init(wrappedValue: Value) { 20 | self.buffer = Buffer.create(minimumCapacity: 1) { buffer in 21 | buffer.withUnsafeMutablePointerToElements { $0.initialize(to: wrappedValue) } 22 | return os_unfair_lock() 23 | } as! Buffer 24 | } 25 | 26 | var projectedValue: UnfairAtomic { 27 | self 28 | } 29 | 30 | var wrappedValue: Value { 31 | get { 32 | buffer.withUnsafeMutablePointers { lock, value in 33 | os_unfair_lock_lock(lock) 34 | defer { os_unfair_lock_unlock(lock) } 35 | return value.pointee 36 | } 37 | } 38 | nonmutating set { 39 | buffer.withUnsafeMutablePointers { lock, value in 40 | os_unfair_lock_lock(lock) 41 | value.pointee = newValue 42 | os_unfair_lock_unlock(lock) 43 | } 44 | } 45 | nonmutating _modify { 46 | var temporaryValue = buffer.withUnsafeMutablePointers { lock, value -> Value in 47 | os_unfair_lock_lock(lock) 48 | return value.move() 49 | } 50 | defer { 51 | buffer.withUnsafeMutablePointers { lock, value in 52 | value.initialize(to: temporaryValue) 53 | os_unfair_lock_unlock(lock) 54 | } 55 | } 56 | yield &temporaryValue 57 | } 58 | } 59 | 60 | func withLock(_ body: (inout Value) throws -> Result) rethrows -> Result { 61 | try body(&wrappedValue) 62 | } 63 | 64 | func swap(_ newValue: Value) -> Value { 65 | buffer.withUnsafeMutablePointers { lock, value in 66 | os_unfair_lock_lock(lock) 67 | let oldValue = value.move() 68 | value.initialize(to: newValue) 69 | os_unfair_lock_unlock(lock) 70 | return oldValue 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/ViewModel.swift: -------------------------------------------------------------------------------- 1 | import Bindings 2 | import Combine 3 | import Dispatch 4 | #if canImport(UIKit) 5 | import UIKit 6 | #endif 7 | 8 | @propertyWrapper 9 | public struct ViewModel { 10 | private var object: Object! 11 | 12 | public init() {} 13 | 14 | @available(*, unavailable, message: "ViewModel can only be used by reference types.") 15 | public var wrappedValue: Object { 16 | get { fatalError("Accessing wrappedValue directly is not permitted.") } 17 | set { fatalError("Accessing wrappedValue directly is not permitted.") } 18 | } 19 | 20 | public static subscript( 21 | _enclosingInstance observer: Observer, 22 | wrapped wrappedValueKeyPath: ReferenceWritableKeyPath, 23 | storage storageKeyPath: ReferenceWritableKeyPath 24 | ) -> Object { 25 | get { 26 | observer[keyPath: storageKeyPath].object 27 | } 28 | set { 29 | guard observer[keyPath: storageKeyPath].object == nil else { 30 | preconditionFailure("ViewModel can only be assigned once.") 31 | } 32 | 33 | observer[keyPath: storageKeyPath].object = newValue 34 | 35 | var objectDidChange: AnyPublisher<(), Never>! 36 | 37 | #if canImport(UIKit) && !os(watchOS) 38 | if let viewController = observer as? UIViewController { 39 | dispatchPrecondition(condition: .onQueue(.main)) 40 | viewController.hookViewDidLoad() 41 | 42 | objectDidChange = newValue.observe(on: DispatchQueue.main) 43 | .combineLatest(viewController.viewDidLoadPublisher) { _, _ in } 44 | .eraseToAnyPublisher() 45 | } 46 | #endif // canImport(UIKit) && !os(watchOS) 47 | 48 | if objectDidChange == nil { 49 | objectDidChange = newValue.observe(on: DispatchQueue.main) 50 | .map { _ in } 51 | .eraseToAnyPublisher() 52 | } 53 | 54 | observer.reactive._updateView <~ objectDidChange 55 | } 56 | } 57 | } 58 | 59 | extension ViewModel { 60 | @available(*, unavailable, message: "ViewModel can't be initialized with a default value — set the initial value inside an initializer instead.") 61 | public init(wrappedValue: Object) { 62 | fatalError() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/ViewModelObserver.swift: -------------------------------------------------------------------------------- 1 | @_exported import protocol Bindings.BindingOwner 2 | import Bindings 3 | import Combine 4 | 5 | public protocol ViewModelObserver: BindingOwner { 6 | func updateView() 7 | } 8 | 9 | extension Reactive where Base: ViewModelObserver { 10 | var _updateView: BindingSink { 11 | BindingSink(owner: base) { observer in 12 | observer.updateView() 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/CombineViewModel/Weak.swift: -------------------------------------------------------------------------------- 1 | final class Weak { 2 | weak var object: Object? 3 | 4 | init(_ object: Object) { 5 | self.object = object 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/UIKitBindings/UIApplication.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) && !os(watchOS) 2 | import Bindings 3 | import Combine 4 | import UIKit 5 | 6 | extension Reactive where Base: UIApplication { 7 | public static var didBecomeActiveNotification: Publishers.Map { 8 | NotificationCenter.default.publisher(for: Base.didBecomeActiveNotification).map { _ in } 9 | } 10 | } 11 | #endif 12 | -------------------------------------------------------------------------------- /Sources/UIKitBindings/UIBarButtonItem.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) && !os(watchOS) 2 | import Bindings 3 | import UIKit 4 | 5 | extension Reactive where Base: UIBarButtonItem { 6 | public var isEnabled: BindingSink { 7 | BindingSink(owner: base) { $0.isEnabled = $1 } 8 | } 9 | } 10 | #endif 11 | -------------------------------------------------------------------------------- /Sources/UIKitBindings/UIControl.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) && !os(watchOS) 2 | import Bindings 3 | import UIKit 4 | 5 | extension Reactive where Base: UIControl { 6 | public var isEnabled: BindingSink { 7 | BindingSink(owner: base) { $0.isEnabled = $1 } 8 | } 9 | } 10 | #endif // canImport(UIKit) && !os(watchOS) 11 | -------------------------------------------------------------------------------- /Sources/UIKitBindings/UILabel.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) && !os(watchOS) 2 | import Bindings 3 | import UIKit 4 | 5 | extension Reactive where Base: UILabel { 6 | public var text: BindingSink { 7 | BindingSink(owner: base) { $0.text = $1 } 8 | } 9 | } 10 | #endif // canImport(UIKit) && !os(watchOS) 11 | -------------------------------------------------------------------------------- /Sources/UIKitBindings/UIRefreshControl.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) && !os(watchOS) 2 | import Bindings 3 | import UIKit 4 | 5 | @available(tvOS, unavailable) 6 | extension Reactive where Base: UIRefreshControl { 7 | public var endRefreshing: BindingSink { 8 | BindingSink(owner: base) { $0.endRefreshing() } 9 | } 10 | } 11 | #endif 12 | -------------------------------------------------------------------------------- /Sources/UIKitBindings/UISwitch.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) && !os(watchOS) 2 | import Bindings 3 | import UIKit 4 | 5 | @available(tvOS, unavailable) 6 | extension Reactive where Base: UISwitch { 7 | public var isOn: BindingSink { 8 | BindingSink(owner: base) { $0.isOn = $1 } 9 | } 10 | } 11 | #endif // canImport(UIKit) && !os(watchOS) 12 | -------------------------------------------------------------------------------- /Sources/UIKitBindings/UITextField.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) && !os(watchOS) 2 | import Bindings 3 | import UIKit 4 | 5 | extension Reactive where Base: UITextField { 6 | public var text: BindingSink { 7 | BindingSink(owner: base) { $0.text = $1 } 8 | } 9 | 10 | public var attributedText: BindingSink { 11 | BindingSink(owner: base) { $0.attributedText = $1 } 12 | } 13 | 14 | public var placeholder: BindingSink { 15 | BindingSink(owner: base) { $0.placeholder = $1 } 16 | } 17 | 18 | public var attributedPlaceholder: BindingSink { 19 | BindingSink(owner: base) { $0.attributedPlaceholder = $1 } 20 | } 21 | } 22 | #endif // canImport(UIKit) && !os(watchOS) 23 | -------------------------------------------------------------------------------- /Sources/UIKitBindings/UIView.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) && !os(watchOS) 2 | import Bindings 3 | import UIKit 4 | 5 | extension Reactive where Base: UIView { 6 | public var isHidden: BindingSink { 7 | BindingSink(owner: base) { $0.isHidden = $1 } 8 | } 9 | } 10 | #endif // canImport(UIKit) && !os(watchOS) 11 | -------------------------------------------------------------------------------- /Sources/UIKitBindings/UIViewController.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) && !os(watchOS) 2 | import Bindings 3 | import UIKit 4 | 5 | extension Reactive where Base: UIViewController { 6 | @available(tvOS, unavailable) 7 | public var toolbarItems: BindingSink { 8 | BindingSink(owner: base) { $0.toolbarItems = $1 } 9 | } 10 | } 11 | #endif 12 | -------------------------------------------------------------------------------- /Tests/CombineViewModelTests/DispatchQueueEventSourceSchedulerTests.swift: -------------------------------------------------------------------------------- 1 | import CombineViewModel 2 | import XCTest 3 | 4 | final class DispatchQueueEventSourceSchedulerTests: XCTestCase { 5 | var source: DispatchQueue.EventSource! 6 | lazy var sourceFired: XCTestExpectation! = expectation(description: "event source fired") 7 | 8 | override func tearDown() { 9 | source = nil 10 | super.tearDown() 11 | } 12 | 13 | func testItCoalescesEvents() { 14 | var eventCount: Int? 15 | source = DispatchQueue.main.scheduleEventSource { source in 16 | eventCount = source.eventCount 17 | self.sourceFired.fulfill() 18 | } 19 | 20 | source.signal() 21 | source.signal() 22 | wait(for: [sourceFired], timeout: 0.1) 23 | 24 | XCTAssertEqual(eventCount, 2) 25 | } 26 | 27 | func testItStopsDeliveringEventsWhenCancelled() { 28 | var fireCount = 0 29 | 30 | source = DispatchQueue.main.scheduleEventSource { _ in 31 | fireCount += 1 32 | } 33 | 34 | source.signal() 35 | CFRunLoopRunInMode(.defaultMode, 0, true) 36 | 37 | source.signal() 38 | source.cancel() 39 | CFRunLoopRunInMode(.defaultMode, 0, true) 40 | 41 | XCTAssertEqual(fireCount, 1) 42 | } 43 | 44 | func testItStopsDeliveringEventsDeinitialized() { 45 | var fireCount = 0 46 | 47 | source = DispatchQueue.main.scheduleEventSource { _ in 48 | fireCount += 1 49 | } 50 | 51 | source.signal() 52 | CFRunLoopRunInMode(.defaultMode, 0, true) 53 | 54 | source.signal() 55 | source = nil 56 | CFRunLoopRunInMode(.defaultMode, 0, true) 57 | 58 | XCTAssertEqual(fireCount, 1) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tests/CombineViewModelTests/HookedViewDidLoadTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | @testable import CombineViewModel 3 | import ObjCTestSupport 4 | import UIKit 5 | import XCTest 6 | 7 | final class HookedViewDidLoadTests: XCTestCase { 8 | func testItHooksOverriddenViewDidLoad() { 9 | class ViewController: UIViewController { 10 | override func viewDidLoad() { 11 | super.viewDidLoad() 12 | } 13 | } 14 | 15 | let object = ViewController() 16 | object.hookViewDidLoad() 17 | XCTAssertTrue(combinevm_isHooked(ViewController.self)) 18 | } 19 | 20 | func testItHooksBaseClassViewDidLoad() { 21 | class Base: UIViewController { 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | } 25 | } 26 | 27 | class Sub: Base {} 28 | 29 | let object = Sub() 30 | object.hookViewDidLoad() 31 | XCTAssertTrue(combinevm_isHooked(Base.self), "Expected base class to be hooked") 32 | XCTAssertFalse(combinevm_isHooked(Sub.self), "Expected sub class not to be hooked") 33 | } 34 | 35 | func testViewDidLoadSelector() { 36 | let controller = TestObjCViewController() 37 | controller.hookViewDidLoad() 38 | 39 | _ = controller.view 40 | 41 | XCTAssertEqual(controller.viewDidLoadSelector, #selector(UIViewController.viewDidLoad)) 42 | } 43 | 44 | func testRecursiveViewDidLoad() { 45 | class ViewModelObserver: UIViewController { 46 | override func viewDidLoad() { 47 | super.viewDidLoad() 48 | } 49 | } 50 | 51 | class Unrelated: UIViewController {} 52 | 53 | let observer = ViewModelObserver() 54 | observer.hookViewDidLoad() 55 | Unrelated().hookViewDidLoad() 56 | 57 | var notificationCount = 0 58 | 59 | let token = NotificationCenter.default.addObserver(forName: UIViewController.viewDidLoadNotification, object: observer, queue: nil) { _ in 60 | notificationCount += 1 61 | } 62 | defer { NotificationCenter.default.removeObserver(token) } 63 | 64 | _ = observer.view 65 | 66 | XCTAssertEqual(notificationCount, 1, "Expected recursively-hooked viewDidLoad() to fire only a single notification.") 67 | } 68 | } 69 | #endif 70 | -------------------------------------------------------------------------------- /Tests/CombineViewModelTests/ObjectDidChangePublisherTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineViewModel 3 | import XCTest 4 | 5 | private final class ViewModel: ObservableObject { 6 | @Published var counter = 0 7 | } 8 | 9 | private final class CustomPublisherViewModel: ObservableObject { 10 | let objectWillChange = PassthroughSubject() 11 | 12 | func finish() { 13 | objectWillChange.send(completion: .finished) 14 | } 15 | } 16 | 17 | final class ObjectDidChangePublisherTests: XCTestCase { 18 | private var subscriptions: Set = [] 19 | 20 | override func tearDown() { 21 | subscriptions.removeAll() 22 | super.tearDown() 23 | } 24 | 25 | func testObjectDidChangeOnRunLoop() { 26 | let viewModel = ViewModel() 27 | 28 | var counterObservations: [Int] = [] 29 | viewModel.observe(on: RunLoop.main) 30 | .sink { counterObservations.append($0.counter) } 31 | .store(in: &subscriptions) 32 | 33 | XCTAssertEqual(counterObservations, [0]) 34 | for _ in 1...10 { 35 | viewModel.counter += 1 36 | } 37 | CFRunLoopRunInMode(.defaultMode, 0, false) 38 | XCTAssertEqual(counterObservations, [0, 10]) 39 | } 40 | 41 | func testObjectWillChangeCompletion() { 42 | let finished = expectation(description: "ObjectDidChangePublisher received finished") 43 | let viewModel = CustomPublisherViewModel() 44 | 45 | let subscription = viewModel.observe(on: DispatchQueue.main) 46 | .handleEvents( 47 | receiveCompletion: { _ in finished.fulfill() }, 48 | receiveCancel: { XCTFail("Received cancel after completion") } 49 | ) 50 | .sink { _ in } 51 | viewModel.finish() 52 | 53 | wait(for: [finished], timeout: 0.001) 54 | subscription.cancel() 55 | } 56 | 57 | func testObjectDidChangePublisherThreadSafety() { 58 | let viewModel = ViewModel() 59 | 60 | let subscription = viewModel 61 | .observe(on: DispatchQueue.global()) 62 | .map(\.counter) 63 | .sink(receiveValue: { _ in }) 64 | 65 | DispatchQueue.concurrentPerform(iterations: 1000) { i in 66 | if Int.random(in: 1...10) == 10 { 67 | subscription.cancel() 68 | } else { 69 | viewModel.counter += 1 70 | } 71 | } 72 | 73 | subscription.cancel() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Tests/CombineViewModelTests/ViewModelTests.swift: -------------------------------------------------------------------------------- 1 | @testable import CombineViewModel 2 | import XCTest 3 | 4 | private final class TestViewModel: ObservableObject { 5 | @Published var counter = 0 6 | } 7 | 8 | private final class Controller: ViewModelObserver { 9 | var observations: [Int] = [] 10 | @ViewModel var viewModel: TestViewModel 11 | 12 | init() { 13 | self.viewModel = TestViewModel() 14 | } 15 | 16 | func updateView() { 17 | observations.append(viewModel.counter) 18 | } 19 | } 20 | 21 | final class ViewModelTests: XCTestCase { 22 | func testViewModel() { 23 | let controller = Controller() 24 | XCTAssertEqual(controller.observations, [0]) 25 | for _ in 1...5 { 26 | controller.viewModel.counter += 1 27 | } 28 | CFRunLoopRunInMode(.defaultMode, 0, true) 29 | XCTAssertEqual(controller.observations, [0, 5]) 30 | } 31 | } 32 | 33 | #if canImport(UIKit) 34 | import UIKit 35 | 36 | private final class TestViewController: UIViewController, ViewModelObserver { 37 | @ViewModel var viewModel: TestViewModel 38 | var observations: [Int] = [] 39 | 40 | init() { 41 | super.init(nibName: nil, bundle: nil) 42 | self.viewModel = TestViewModel() 43 | } 44 | 45 | required init?(coder: NSCoder) { 46 | fatalError("init(coder:) has not been implemented") 47 | } 48 | 49 | func updateView() { 50 | observations.append(viewModel.counter) 51 | } 52 | } 53 | 54 | extension ViewModelTests { 55 | func testViewModelObservedOnViewDidLoad() { 56 | let controller = TestViewController() 57 | XCTAssertFalse(controller.isViewLoaded) 58 | XCTAssertEqual(controller.observations, []) 59 | 60 | DispatchQueue.main.async { 61 | _ = controller.view 62 | } 63 | CFRunLoopRunInMode(.defaultMode, 0, true) 64 | XCTAssertEqual(controller.observations, [0]) 65 | } 66 | } 67 | #endif 68 | -------------------------------------------------------------------------------- /Tests/ObjCTestSupport/TestObjCViewController.m: -------------------------------------------------------------------------------- 1 | #import "ObjCTestSupport.h" 2 | 3 | #if COMBINEVM_HAS_UIKIT && COMBINEVM_HAS_UIVIEWCONROLLER 4 | 5 | @implementation TestObjCViewController 6 | 7 | - (void)viewDidLoad { 8 | [super viewDidLoad]; 9 | 10 | _viewDidLoadSelector = _cmd; 11 | } 12 | 13 | @end 14 | 15 | #endif // COMBINEVM_HAS_UIKIT && COMBINEVM_HAS_UIVIEWCONROLLER 16 | -------------------------------------------------------------------------------- /Tests/ObjCTestSupport/include/ObjCTestSupport.h: -------------------------------------------------------------------------------- 1 | #if defined(__has_include) 2 | # if __has_include() 3 | # define COMBINEVM_HAS_UIKIT 1 4 | # endif 5 | # 6 | # if __has_include() 7 | # define COMBINEVM_HAS_UIVIEWCONROLLER 1 8 | # endif 9 | #endif 10 | 11 | #if COMBINEVM_HAS_UIKIT && COMBINEVM_HAS_UIVIEWCONROLLER 12 | #import 13 | 14 | @interface TestObjCViewController : UIViewController 15 | 16 | @property (nonatomic, readonly, nullable) SEL viewDidLoadSelector; 17 | 18 | @end 19 | 20 | #endif // COMBINEVM_HAS_UIKIT && COMBINEVM_HAS_UIVIEWCONROLLER 21 | --------------------------------------------------------------------------------