├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── RxProperty │ └── RxProperty.swift └── Tests ├── LinuxMain.swift └── RxPropertyTests ├── RxPropertyTests.swift └── XCTestManifests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xcuserstate 23 | 24 | ## Obj-C/Swift specific 25 | *.hmap 26 | *.ipa 27 | *.dSYM.zip 28 | *.dSYM 29 | 30 | ## Playgrounds 31 | timeline.xctimeline 32 | playground.xcworkspace 33 | 34 | # Swift Package Manager 35 | # 36 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 37 | # Packages/ 38 | .build/ 39 | *.xcodeproj 40 | 41 | # CocoaPods 42 | # 43 | # We recommend against adding the Pods directory to your .gitignore. However 44 | # you should judge for yourself, the pros and cons are mentioned at: 45 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 46 | # 47 | # Pods/ 48 | 49 | # Carthage 50 | # 51 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 52 | # Carthage/Checkouts 53 | 54 | Carthage/Build 55 | 56 | # fastlane 57 | # 58 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 59 | # screenshots whenever they are needed. 60 | # For more information about the recommended setup visit: 61 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md 62 | 63 | fastlane/report.xml 64 | fastlane/Preview.html 65 | fastlane/screenshots 66 | fastlane/test_output 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Yasuhiro Inami 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "RxSwift", 6 | "repositoryURL": "https://github.com/ReactiveX/RxSwift", 7 | "state": { 8 | "branch": null, 9 | "revision": "7e01c05f25c025143073eaa3be3532f9375c614b", 10 | "version": "6.1.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | // Managed by ice 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "RxProperty", 8 | products: [ 9 | .library(name: "RxProperty", targets: ["RxProperty"]), 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/ReactiveX/RxSwift", from: "6.1.0"), 13 | ], 14 | targets: [ 15 | .target(name: "RxProperty", dependencies: [ 16 | "RxSwift", 17 | .product(name: "RxRelay", package: "RxSwift"), 18 | ]), 19 | .testTarget(name: "RxPropertyTests", dependencies: ["RxProperty"]), 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RxProperty 2 | 3 | A get-only `BehaviorRelay` that is (almost) equivalent to [ReactiveSwift](https://github.com/ReactiveCocoa/ReactiveSwift)'s [`Property`](https://github.com/ReactiveCocoa/ReactiveSwift/blob/1.1.0/Sources/Property.swift#L455). 4 | 5 | This class is highly useful to encapsulate `BehaviorRelay` inside ViewModel so that other classes cannot reach its setter (unbindable) and make state management safer. 6 | 7 | In short: 8 | 9 | ```swift 10 | public final class 💩ViewModel { 11 | // 💩 This is almost the same as `var state: T`. 12 | // DON'T EXPOSE VARIABLE! 13 | public let state = BehaviorRelay(...) 14 | 15 | // 💩 Exposing `Observable` is NOT a good solution either 16 | // because type doesn't tell us that it contains at least one value 17 | // and we cannot even "extract" it. 18 | public var stateObservable: Observable { 19 | return state.asObservable() 20 | } 21 | } 22 | ``` 23 | 24 | ```swift 25 | public final class 👍ViewModel { 26 | // 👍 Hide variable. 27 | private let _state = BehaviorRelay(...) 28 | 29 | // 👍 `Property` is a better type than `Observable`. 30 |    public let state: Property 31 | 32 | public init() { 33 | self.state = Property(_state) 34 | } 35 | } 36 | ``` 37 | 38 | ## Disclaimer 39 | 40 | Since this library should rather go to RxSwift-core, **there is no plan to release as a 3rd-party microframework** for CocoaPods/Carthage ~~/SwiftPackageManager~~. 41 | 42 | (Swift Package Manager is supported in [inamiy/RxProperty#5](https://github.com/inamiy/RxProperty/pull/5)) 43 | 44 | If you like it, please upvote [ReactiveX/RxSwift#1118](https://github.com/ReactiveX/RxSwift/pull/1118) and join the discussion. 45 | Current recommended way is to directly use the source code to your project. 46 | 47 | ## License 48 | 49 | [MIT](LICENSE) 50 | -------------------------------------------------------------------------------- /Sources/RxProperty/RxProperty.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Property.swift 3 | // RxProperty 4 | // 5 | // Created by Yasuhiro Inami on 2017-03-11. 6 | // Copyright © 2017 Yasuhiro Inami. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import RxRelay 11 | 12 | /// A get-only `BehaviorRelay` that works similar to ReactiveSwift's `Property`. 13 | /// 14 | /// - Note: 15 | /// From ver 0.3.0, this class will no longer send `.completed` when deallocated. 16 | /// 17 | /// - SeeAlso: 18 | /// https://github.com/ReactiveCocoa/ReactiveSwift/blob/1.1.0/Sources/Property.swift 19 | /// https://github.com/ReactiveX/RxSwift/pull/1118 (unmerged) 20 | public final class Property { 21 | 22 | public typealias E = Element 23 | 24 | private let _behaviorRelay: BehaviorRelay 25 | 26 | /// Gets current value. 27 | public var value: E { 28 | get { 29 | return _behaviorRelay.value 30 | } 31 | } 32 | 33 | /// Initializes with initial value. 34 | public init(_ value: E) { 35 | _behaviorRelay = BehaviorRelay(value: value) 36 | } 37 | 38 | /// Initializes with `BehaviorRelay`. 39 | public init(_ behaviorRelay: BehaviorRelay) { 40 | _behaviorRelay = behaviorRelay 41 | } 42 | 43 | /// Initializes with `Observable` that must send at least one value synchronously. 44 | /// 45 | /// - Warning: 46 | /// If `unsafeObservable` fails sending at least one value synchronously, 47 | /// a fatal error would be raised. 48 | /// 49 | /// - Warning: 50 | /// If `unsafeObservable` sends multiple values synchronously, 51 | /// the last value will be treated as initial value of `Property`. 52 | public convenience init(unsafeObservable: Observable) { 53 | let observable = unsafeObservable.share(replay: 1, scope: .whileConnected) 54 | var initial: E? = nil 55 | 56 | let initialDisposable = observable 57 | .subscribe(onNext: { initial = $0 }) 58 | 59 | guard let initial_ = initial else { 60 | fatalError("An unsafeObservable promised to send at least one value. Received none.") 61 | } 62 | 63 | self.init(initial: initial_, then: observable) 64 | 65 | initialDisposable.dispose() 66 | } 67 | 68 | /// Initializes with `initial` element and then `observable`. 69 | public init(initial: E, then observable: Observable) { 70 | _behaviorRelay = BehaviorRelay(value: initial) 71 | 72 | _ = observable 73 | .bind(to: _behaviorRelay) 74 | // .disposed(by: disposeBag) // Comment-Out: Don't dispose when `property` is deallocated 75 | } 76 | 77 | /// Observable that synchronously sends current element and then changed elements. 78 | /// This is same as `ReactiveSwift.Property.producer`. 79 | public func asObservable() -> Observable { 80 | return _behaviorRelay.asObservable() 81 | } 82 | 83 | /// Observable that only sends changed elements, ignoring current element. 84 | /// This is same as `ReactiveSwift.Property.signal`. 85 | public var changed: Observable { 86 | return asObservable().skip(1) 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import RxPropertyTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += RxPropertyTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/RxPropertyTests/RxPropertyTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import RxSwift 3 | import RxRelay 4 | @testable import RxProperty 5 | 6 | final class RxPropertyTests: XCTestCase { 7 | 8 | // MARK: - initWithBehaviorRelay 9 | 10 | func test_initWithBehaviorRelay_value() { 11 | 12 | let relay = BehaviorRelay(value: 0) 13 | let property = Property(relay) 14 | 15 | XCTAssertEqual(property.value, 0) 16 | 17 | relay.accept(1) 18 | XCTAssertEqual(property.value, 1) 19 | 20 | relay.accept(2) 21 | XCTAssertEqual(property.value, 2) 22 | 23 | } 24 | 25 | func test_initWithBehaviorRelay_asObservable() { 26 | 27 | let relay = BehaviorRelay(value: 0) 28 | var property: Property! = .init(relay) 29 | 30 | var events = [Event]() 31 | 32 | // `.asObservable()` test 33 | _ = property.asObservable().subscribe { event in 34 | events.append(event) 35 | } 36 | 37 | XCTAssertEqual(events, [.next(0)], 38 | "should observe initial value") 39 | 40 | relay.accept(1) 41 | XCTAssertEqual(events, [.next(0), .next(1)]) 42 | 43 | relay.accept(2) 44 | XCTAssertEqual(events, [.next(0), .next(1), .next(2)]) 45 | 46 | property = nil 47 | XCTAssertEqual(events, [.next(0), .next(1), .next(2)], 48 | "`.completed` should NOT be observed when `property` is deallocated") 49 | 50 | relay.accept(3) 51 | XCTAssertEqual(events, [.next(0), .next(1), .next(2), .next(3)], 52 | "`property`'s observable should still be alive even when `property` is deallocated.") 53 | 54 | } 55 | 56 | func test_initWithBehaviorRelay_changed() { 57 | 58 | let relay = BehaviorRelay(value: 0) 59 | var property: Property! = .init(relay) 60 | 61 | var events = [Event]() 62 | 63 | // `.changed` test 64 | _ = property.changed.subscribe { event in 65 | events.append(event) 66 | } 67 | 68 | XCTAssertEqual(events, [Event](), 69 | "should NOT observe initial value") 70 | 71 | relay.accept(1) 72 | XCTAssertEqual(events, [.next(1)]) 73 | 74 | relay.accept(2) 75 | XCTAssertEqual(events, [.next(1), .next(2)]) 76 | 77 | property = nil 78 | XCTAssertEqual(events, [.next(1), .next(2)], 79 | "`.completed` should NOT be observed when `property` is deallocated") 80 | 81 | relay.accept(3) 82 | XCTAssertEqual(events, [.next(1), .next(2), .next(3)], 83 | "`property`'s observable should still be alive even when `property` is deallocated.") 84 | 85 | } 86 | 87 | // MARK: - initWithUnsafe 88 | 89 | func test_initWithUnsafe_value() { 90 | 91 | let relay = BehaviorRelay(value: 0) 92 | let property = Property(unsafeObservable: relay.asObservable()) 93 | 94 | XCTAssertEqual(property.value, 0) 95 | 96 | relay.accept(1) 97 | XCTAssertEqual(property.value, 1) 98 | 99 | relay.accept(2) 100 | XCTAssertEqual(property.value, 2) 101 | 102 | } 103 | 104 | func test_initWithUnsafe_asObservable() { 105 | 106 | let relay = BehaviorRelay(value: 0) 107 | var property: Property! = .init(unsafeObservable: relay.asObservable()) 108 | 109 | var events = [Event]() 110 | 111 | // `.asObservable()` test 112 | _ = property.asObservable().subscribe { event in 113 | events.append(event) 114 | } 115 | 116 | XCTAssertEqual(events, [.next(0)], 117 | "should observe initial value") 118 | 119 | relay.accept(1) 120 | XCTAssertEqual(events, [.next(0), .next(1)]) 121 | 122 | relay.accept(2) 123 | XCTAssertEqual(events, [.next(0), .next(1), .next(2)]) 124 | 125 | property = nil 126 | XCTAssertEqual(events, [.next(0), .next(1), .next(2)], 127 | "`.completed` should NOT be observed when `property` is deallocated") 128 | 129 | relay.accept(3) 130 | XCTAssertEqual(events, [.next(0), .next(1), .next(2), .next(3)], 131 | "`property`'s observable should still be alive even when `property` is deallocated.") 132 | 133 | } 134 | 135 | func test_initWithUnsafe_changed() { 136 | 137 | let relay = BehaviorRelay(value: 0) 138 | var property: Property! = .init(unsafeObservable: relay.asObservable()) 139 | 140 | var events = [Event]() 141 | 142 | // `.changed` test 143 | _ = property.changed.subscribe { event in 144 | events.append(event) 145 | } 146 | 147 | XCTAssertEqual(events, [Event](), 148 | "should NOT observe initial value") 149 | 150 | relay.accept(1) 151 | XCTAssertEqual(events, [.next(1)]) 152 | 153 | relay.accept(2) 154 | XCTAssertEqual(events, [.next(1), .next(2)]) 155 | 156 | property = nil 157 | XCTAssertEqual(events, [.next(1), .next(2)]) 158 | 159 | relay.accept(3) 160 | XCTAssertEqual(events, [.next(1), .next(2), .next(3)], 161 | "`property`'s observable should still be alive even when `property` is deallocated.") 162 | 163 | } 164 | 165 | } 166 | 167 | // MARK: RxSwift.Event + Equatable 168 | 169 | extension RxSwift.Event: Equatable where Element: Equatable { 170 | 171 | /// - Warning: Dirty implementation. 172 | public static func == (l: RxSwift.Event, r: RxSwift.Event) -> Bool { 173 | switch (l, r) { 174 | case let (.next(l), .next(r)): 175 | return l == r 176 | case let (.error(l), .error(r)): 177 | return "\(l)" == "\(r)" // dirty 178 | case (.completed, .completed): 179 | return true 180 | default: 181 | return false 182 | } 183 | } 184 | 185 | } 186 | -------------------------------------------------------------------------------- /Tests/RxPropertyTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !os(macOS) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(RxPropertyTests.allTests), 7 | ] 8 | } 9 | #endif 10 | --------------------------------------------------------------------------------