From e795e9dc12a50359fdf7ee2ebbbd6526d6c2e182 Mon Sep 17 00:00:00 2001 From: Yi Wang Date: Fri, 16 Sep 2022 15:55:42 -0700 Subject: [PATCH] IMVVM foundational types --- .gitignore | 31 +++ Package.resolved | 14 ++ Package.swift | 33 +++ README.md | 3 +- Sources/IMVVM/AnyCancellable+Interactor.swift | 91 +++++++ Sources/IMVVM/CancellableBuilder.swift | 68 ++++++ Sources/IMVVM/Interactor.swift | 222 ++++++++++++++++++ Sources/IMVVM/SynchronizedCancelBag.swift | 107 +++++++++ Sources/IMVVM/TypeErasedView.swift | 70 ++++++ Sources/IMVVM/View+Lifecycle.swift | 67 ++++++ Sources/IMVVM/ViewModel.swift | 31 +++ Tests/IMVVMTests/InteractorTests.swift | 216 +++++++++++++++++ Tests/IMVVMTests/Mocks.swift | 54 +++++ 13 files changed, 1006 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 Sources/IMVVM/AnyCancellable+Interactor.swift create mode 100644 Sources/IMVVM/CancellableBuilder.swift create mode 100644 Sources/IMVVM/Interactor.swift create mode 100644 Sources/IMVVM/SynchronizedCancelBag.swift create mode 100644 Sources/IMVVM/TypeErasedView.swift create mode 100644 Sources/IMVVM/View+Lifecycle.swift create mode 100644 Sources/IMVVM/ViewModel.swift create mode 100644 Tests/IMVVMTests/InteractorTests.swift create mode 100644 Tests/IMVVMTests/Mocks.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edb8a7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Forked from https://github.com/github/gitignore/blob/master/Swift.gitignore + +# MacOS +*.DS_Store + +## User settings +xcuserdata/ + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +Packages/ +.build/ + +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm + +build/ + +# Xcode, since the project is generated using SPM. +*.xcodeproj +*.xcworkspace diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..09ceddd --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "48254824bb4248676bf7ce56014ff57b142b77eb", + "version" : "1.0.2" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..6cfdfe7 --- /dev/null +++ b/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.6 + +import PackageDescription + +let package = Package( + name: "IMVVM", + platforms: [ + .iOS(.v13), + ], + products: [ + .library( + name: "IMVVM", + targets: ["IMVVM"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.2"), + ], + targets: [ + .target( + name: "IMVVM", + dependencies: [ + .product(name: "OrderedCollections", package: "swift-collections"), + ] + ), + .testTarget( + name: "IMVVMTests", + dependencies: [ + "IMVVM", + ] + ), + ] +) diff --git a/README.md b/README.md index 87b7132..2b8d58a 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ -# imvvm \ No newline at end of file +# iMVVMFoundation +The foundational library for base MVVM+Interactor and corresponding utility implementations. diff --git a/Sources/IMVVM/AnyCancellable+Interactor.swift b/Sources/IMVVM/AnyCancellable+Interactor.swift new file mode 100644 index 0000000..3349f2e --- /dev/null +++ b/Sources/IMVVM/AnyCancellable+Interactor.swift @@ -0,0 +1,91 @@ +// MIT License +// +// Copyright (c) 2022 Yi Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Combine + +extension AnyCancellable { + /// When the interactor deinits, the subscription is cancelled. + /// + /// This function provides the utility to manage Combine subscriptions inside a `Interactor` implementation. For + /// example: + /// + /// class MyInteractor: Interactor { + /// func buttonDidTap() { + /// somePublisher + /// .sink { ... } + /// .cancelOnDeinit(of: self) + /// } + /// } + /// + /// - Note: Because this function causes the given interactor to stongly retain the subscription, this means the + /// subscription itself should not strongly retain the interactor. Otherwise a retain cycle would occur causing + /// memory leaks. + /// + /// This function is thread-safe. Invocations of this function to the same interactor instance can be performed on + /// the different threads. + /// + /// This function can only be invoked after the given interactor has loaded. This is done via the interactor's + /// `viewDidAppear` function. Generally speaking, this interactor should be bound to the lifecycle of a `View`. + /// See `ViewLifecycleObserver` for more details. + /// + /// - Parameters: + /// - interactor: The interactor to bind the subscription's lifecycle to. + public func cancelOnDeinit(of interactor: InteractorType) { + if !interactor.isLoaded { + fatalError("\(interactor) has not been loaded") + } + interactor.deinitCancelBag.store(self) + } + + /// When the interactor's view disappears, the subscription is cancelled. + /// + /// This function provides the utility to manage Combine subscriptions inside a `Interactor` implementation. For + /// example: + /// + /// class MyInteractor: Interactor { + /// func buttonDidTap() { + /// somePublisher + /// .sink { ... } + /// .cancelOnViewDidDisappear(of: self) + /// } + /// } + /// + /// - Note: Because this function causes the given interactor to stongly retain the subscription, this means the + /// subscription itself should not strongly retain the interactor. Otherwise a retain cycle would occur causing + /// memory leaks. + /// + /// This function is thread-safe. Invocations of this function to the same interactor instance can be performed on + /// the different threads. + /// + /// This function can only be invoked after the given interactor has received notification that its view has + /// appeared. This is done via the interactor's `viewDidAppear` function. Generally speaking, this interactor + /// should be bound to the lifecycle of a `View`. See `ViewLifecycleObserver` for more details. + /// + /// - Parameters: + /// - interactor: The interactor to bind the subscription's lifecycle to. + public func cancelOnViewDidDisappear(of interactor: InteractorType) { + if !interactor.hasViewAppeared { + fatalError("\(interactor)'s view has not appeared") + } + interactor.viewAppearanceCancelBag.store(self) + } +} diff --git a/Sources/IMVVM/CancellableBuilder.swift b/Sources/IMVVM/CancellableBuilder.swift new file mode 100644 index 0000000..a8e6d04 --- /dev/null +++ b/Sources/IMVVM/CancellableBuilder.swift @@ -0,0 +1,68 @@ +// MIT License +// +// Copyright (c) 2022 Yi Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Combine + +/// A result builder that allows a variadic number of `AnyCancellable` to be collected into an array. +@resultBuilder +public enum CancellableBuilder { + public static func buildBlock(_ components: [AnyCancellable]...) -> [AnyCancellable] { + components.reduce(into: [], +=) + } + + public static func buildExpression(_ expression: Void) -> [AnyCancellable] { + [] + } + + public static func buildExpression(_ expression: AnyCancellable) -> [AnyCancellable] { + [expression] + } + + /// Convert regular cancellables to AnyCancellable to dispose on deinit. + public static func buildExpression(_ expression: any Cancellable) -> [AnyCancellable] { + [AnyCancellable(expression.cancel)] + } + + public static func buildExpression(_ expression: [AnyCancellable]) -> [AnyCancellable] { + expression + } + + public static func buildEither(first component: [AnyCancellable]) -> [AnyCancellable] { + component + } + + public static func buildEither(second component: [AnyCancellable]) -> [AnyCancellable] { + component + } + + public static func buildArray(_ components: [[AnyCancellable]]) -> [AnyCancellable] { + components.reduce(into: [], +=) + } + + public static func buildOptional(_ component: [AnyCancellable]?) -> [AnyCancellable] { + if let component = component { + return component + } else { + return [] + } + } +} diff --git a/Sources/IMVVM/Interactor.swift b/Sources/IMVVM/Interactor.swift new file mode 100644 index 0000000..882ffe6 --- /dev/null +++ b/Sources/IMVVM/Interactor.swift @@ -0,0 +1,222 @@ +// MIT License +// +// Copyright (c) 2022 Yi Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Combine +import Foundation + +/// The observer of an `Interactor`'s lifecycle events. +/// +/// This allows the implementation of this protocol to bind to the interactor's lifecycle events. +public protocol InteractorLifecycleObserver { + /// Override this function to setup the subscriptions the receiver requires on start. + /// + /// All the created subscriptions returned from this function are bound to the deinit lifecycle of the interactor. + /// + /// class MyInteractor: Interactor { + /// @CancellableBuilder + /// override func onLoad() -> [AnyCancellable] { + /// myDataStream + /// .sink {...} + /// + /// mySecondDataStream + /// .sink {...} + /// } + /// } + /// + /// - Returns: An array of subscription `AnyCancellable`. + @CancellableBuilder + func onLoad() -> [AnyCancellable] +} + +/// The base class of an object providing data to a view's interactor and functionality to handle user events such as +/// button taps. +/// +/// An interactor is a helper object in the MVVM pattern that contains the application business logic. It should +/// perform network operations that supply data to a `ViewModel` for display. It also provides functionality to the +/// view to handle user events such as button taps. Because of this relationship, the view generally holds a strong +/// reference to the interactor to handle user interactions. The interactor holds a strong reference to the view model +/// to send network data for display. +/// +/// The `Interactor` base implementation provides utility functions that help manage business logic, such as handling +/// subscription cancellables. Subclasses should override the lifecycle functions such as `onLoad` and `onViewAppear` +/// to setup all the necessary subscriptions. +/// +/// An `interactor` is a `ViewLifecycleObserver`. It should be bound to the associated view via the view's +/// `interactable()` modifier. This allows the interactor to activate its lifecycle functions. +/// +/// - Note: An `interactor` is not an `ObservableObject` since it should NOT directly provide view data to the +/// associated view. Instead, it should provide network data to a `ViewModel` that transforms it into presentation +/// data for the view to display. +/// +/// - Important: An interactor must be bound to its corresponding view via the view's `bind(observer:)` function +/// in order for the interactor receive lifecycle events. This should be performed before the view is attached to the +/// view hierarchy. +open class Interactor { + /// Stores cancellables until deinit. + let deinitCancelBag = SynchronizedCancelBag() + /// Stores cancellables from view appearance and cancels all on view disappears. + let viewAppearanceCancelBag = SynchronizedCancelBag() + + public private(set) var isLoaded = false + // To avoid duplicate lifecycle function invocations. + public private(set) var hasViewAppeared = false + + public init() {} + /// Override this function to setup the subscriptions this interactor requires on start. + /// + /// All the created subscriptions returned from this function are bound to the deinit lifecycle of this interactor. + /// + /// class MyInteractor: Interactor { + /// @CancellableBuilder + /// override func onLoad() -> [AnyCancellable] { + /// myDataStream + /// .sink {...} + /// + /// mySecondDataStream + /// .sink {...} + /// } + /// } + /// + /// - Returns: An array of subscription `AnyCancellable`. + @CancellableBuilder + open func onLoad() -> [AnyCancellable] {} + + /// Override this function to perform logic or setup the subscriptions when the view has appeared. + /// + /// All the created subscriptions returned from this function are bound to the disappearance of this interactor's + /// corresponding view. This requires this interactor to be bound to the lifecycle of a view via the view's + /// `interactable()` modifier. See `InteractableView` for more details. + /// + /// class MyInteractor: Interactor { + /// @CancellableBuilder + /// override func onViewAppear() -> [AnyCancellable] { + /// myDataStream + /// .sink {...} + /// + /// mySecondDataStream + /// .sink {...} + /// } + /// } + /// + /// - Returns: An array of subscription `AnyCancellable`. + @CancellableBuilder + open func onViewAppear() -> [AnyCancellable] {} + + /// Override this function to perform logic when the interactor's view disappears. + open func onViewDisappear() {} + + deinit { + deinitCancelBag.cancelAll() + viewAppearanceCancelBag.cancelAll() + } +} + +// MARK: - ViewLifecycleObserver Conformance + +extension Interactor: ViewLifecycleObserver { + public final func viewDidAppear() { + guard !hasViewAppeared else { + return + } + hasViewAppeared = true + + if !isLoaded { + isLoaded = true + deinitCancelBag.store(onLoad()) + } + + viewAppearanceCancelBag.store(onViewAppear()) + } + + public final func viewDidDisappear() { + guard hasViewAppeared else { + return + } + hasViewAppeared = false + + viewAppearanceCancelBag.cancelAll() + + onViewDisappear() + } +} + +// MARK: - Why the responsibility of binding is left to the callsites + +/// Instead of leaving the responsibility of binding to the callsites, a few other alternative implementations have +/// been attempted: +/// +/// 1. Using a wrapper view. This largely functions the same way as the view modifier. The wrapper view takes in an +/// interactor as an initializer parameter, and invokes the `bind` function on the content view in the the wrapper +/// view's `body` property. The interactor is then passed into the view builder to allow the content view to use +/// without having to separately instantiate the interactor. This however resulted in the callsites being quite +/// confusing to read: +/// InteractableView(interactor) { interactor +/// ContentView(interactor: interactor) +/// } +/// +/// 2. Perform the binding using plugin structures such as `OneOfPlugin` and `ForEachPlugin`. This approach required +/// significant compromises to get it to work. +/// - One implementation strategy is to declare a `View` sub-protocol that provides an interactor instance. This +/// would allow the plugin structures to invoke `bind` on the view using the view's interactor property. However, +/// since if the plugin wrapper view's `viewBuilder` implementation may contain conditionals, the resulting SwiftUI +/// internal `_ConditionalContent` would be required to conform to the sub-protocol. And since the +/// `_ConditionalContent` structure is an internal type, this extension conformance cannot be implemented. +/// - A second implementation strategy is to pass in the view builder and interactor builder closures separately. +/// This allows the plugin wrapper view to internally stitch together the two by mapping with the feature flag as +/// keys. Even though this implementation can work, it does make the callsite somewhat confusing by separating the +/// instantiations of views and their corresponding interactors: +/// ForEachPlugin( +/// featureFlags: MyFeatureFlag.allCases, +/// viewBuilder: { featureFlag in +/// switch featureFlag{ +/// case .flag1: View1() +/// case .flag2: View2() +/// }, +/// interactorBuilder: { featureFlag in +/// switch featureFlag{ +/// case .flag1: Interactor1() +/// case .flag2: Interactor2() +/// } +/// ) +/// A a major drawback to this approach is that in order to avoid instantiating two separate instances of an +/// interactor without making the callsite much more difficult, the interactor needs be assigned to the +/// corresponding view internally in the plugin wrapper view. There are two issues that make this assignment +/// difficult: +/// 1. The `_ConditionalContent` issue of the plugin wrapper's view generic type described in the section above +/// still applies here. The view type cannot be constrained to anything other than the SwiftUI's native `View`. +/// 2. The interactor cannot be assigned to the view via `@EnvironmentObject` since it would require a concrete +/// interactor type. And the plugin wrapper view cannot declare a generic constrain type for the interactor as it +/// would lock the all the views to a single interactor type, +/// The only viable solution to assign an interactor to its view, internally within the plugin wrapper view, is to +/// use a global map that stores the interactors with their names as keys. Then the view can retrieve the interactor +/// instance by first declaring an `associatedtype` of the interactor, then converting that type into a string key, +/// and finally retrieve the interactor from the global map with that key. This is NOT a compile-time safe operation +/// and error-prone. +/// 3. Declare a `InteractableView` protocol and a default `interactable()` view modifier to perform the binding. +/// This unfortunately does not work due to Swift's type system. If the `InteractableView` protocol declares an +/// `associatedtype InteractorType: ViewLifecycleObserver`, the concrete view implementation is then required to +/// provide the `typealias` with a concrete class type. This means the view cannot be injected with a protocol. +/// In that case, the view is tightly coupled with the interactor implementation making use cases such as SwiftUI +/// previews impossible, since a concrete interactor with all of its dependencies must be instantiated to create the +/// preview. If the the `InteractableView` protocol declares the interactor property as +/// `interactor: ViewLifecycleObserver { get }`, then it cannot be used as an argument for the `bind` function. +/// This is due to https://github.com/apple/swift-evolution/blob/main/proposals/0352-implicit-open-existentials.md diff --git a/Sources/IMVVM/SynchronizedCancelBag.swift b/Sources/IMVVM/SynchronizedCancelBag.swift new file mode 100644 index 0000000..27d1ec5 --- /dev/null +++ b/Sources/IMVVM/SynchronizedCancelBag.swift @@ -0,0 +1,107 @@ +// MIT License +// +// Copyright (c) 2022 Yi Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Combine +import Foundation + +/// A collection providing thread-safe access to a set of cancellables. +/// +/// All the stored cancellabels are cancelled when this instance deinits. +public class SynchronizedCancelBag { + private var cancellables: [ObjectIdentifier: Cancellable] = [:] + private let cancellablesLock = NSRecursiveLock() + + public init() {} + + /// Store the given cancellable. + /// + /// - Parameters: + /// - cancellable: The cancellable to store. + public func store(_ cancellable: AnyCancellable) { + cancellablesLock.lock() + defer { + cancellablesLock.unlock() + } + + cancellables[cancellable.id] = cancellable + } + + /// Store the given cancellables. + /// + /// - Parameters: + /// - cancellables: The cancellables to store. + public func store(_ sequence: S) where S.Element == AnyCancellable { + cancellablesLock.lock() + defer { + cancellablesLock.unlock() + } + + for cancellable in sequence { + cancellables[cancellable.id] = cancellable + } + } + + public func cancelAll() { + cancellablesLock.lock() + defer { + cancellablesLock.unlock() + } + + for cancellable in cancellables.values { + cancellable.cancel() + } + cancellables.removeAll() + } + + public func remove(_ cancellable: C) where C.ID == ObjectIdentifier { + cancellablesLock.lock() + defer { + cancellablesLock.unlock() + } + + cancellables[cancellable.id] = nil + } + + public func contains(_ cancellable: C) -> Bool where C.ID == ObjectIdentifier { + cancellablesLock.lock() + defer { + cancellablesLock.unlock() + } + + return cancellables[cancellable.id] != nil + } + + public func isEmpty() -> Bool { + cancellablesLock.lock() + defer { + cancellablesLock.unlock() + } + + return cancellables.isEmpty + } +} + +extension AnyCancellable: Identifiable { + public func store(in bag: inout SynchronizedCancelBag) { + bag.store(self) + } +} diff --git a/Sources/IMVVM/TypeErasedView.swift b/Sources/IMVVM/TypeErasedView.swift new file mode 100644 index 0000000..1b36cb8 --- /dev/null +++ b/Sources/IMVVM/TypeErasedView.swift @@ -0,0 +1,70 @@ +// MIT License +// +// Copyright (c) 2022 Yi Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +import SwiftUI + +/// TypeErasedView is a drop in replacement for SwiftUI's `AnyView`. +/// +/// `AnyView` has two major issues that the `TypeErasedView` solves: +/// 1. A view hierarchy containing many `AnyView` can have rendering performance issues. +/// 2. Wrapping an `AnyView` within another `AnyView` can erase certain modifiers such as `onAppear` and +/// `onDisappear`. This is quite error-prone as different portions of the codebase can unintentionally cause more +/// than one wrapping. +/// +/// Using a TypeErasedView enables many architectural benefits: +/// 1. Each feature can be written in its own package without exposing the individual classes as `public`. A single +/// `public` factory can be used to return a TypeErasedView to the parent view package for presentation. +/// 2. A parent feature containing many child views does not need to be modified when new child views are added or +/// modified, when the child views are constructed as plugins. +/// 3. A view/feature's lifecycle can be managed by storing and releasing the TypeErasedView reference. +public struct TypeErasedView: View { + // swiftlint:disable:next no_any_view + private let content: AnyView + + public init(_ content: Content) { + // swiftlint:disable:next no_any_view + self.content = AnyView(content) + } + + public init(_ alreadyErased: TypeErasedView) { + self.content = alreadyErased.content + } + + // swiftlint:disable:next no_any_view + public init(_ anyView: AnyView) { + self.content = anyView + } + + public var body: some View { + content + } +} + +extension View { + /// Type erase this view. + /// + /// - Returns: The type erased view. + public func typeErased() -> TypeErasedView { + TypeErasedView(self) + } +} diff --git a/Sources/IMVVM/View+Lifecycle.swift b/Sources/IMVVM/View+Lifecycle.swift new file mode 100644 index 0000000..7553df2 --- /dev/null +++ b/Sources/IMVVM/View+Lifecycle.swift @@ -0,0 +1,67 @@ +// MIT License +// +// Copyright (c) 2022 Yi Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +import SwiftUI + +/// The observer of a SwiftUI view's lifecycle events. +/// +/// This protocol should be used in conjunction with the `bind(observer:)` function of a `View`. This allows the +/// implementation of this protocol to receive the view's various lifecycle events to perform business logic accordingly. +public protocol ViewLifecycleObserver { + /// Notify the observer when the bound `View` has appeared. + func viewDidAppear() + + /// Notify the observer when the bound `View` has disappeared. + func viewDidDisappear() +} + +extension View { + /// Bind the given lifecycle observer to this view. + /// + /// - Parameters: + /// - observer: The observer to be bound and receive this view's lifecycle events. + /// - Returns: This view with the observer bound. + public func bind(observer: Observer) -> some View { + onAppear { + observer.viewDidAppear() + } + .onDisappear { + observer.viewDidDisappear() + } + } + + /// Bind the given lifecycle observer to this view. + /// + /// - Parameters: + /// - observer: The observer to be bound and receive this view's lifecycle events. + /// - Returns: The type erased version of thisview with the observer bound. + public func bindTypeErased(observer: Observer) -> TypeErasedView { + onAppear { + observer.viewDidAppear() + } + .onDisappear { + observer.viewDidDisappear() + } + .typeErased() + } +} diff --git a/Sources/IMVVM/ViewModel.swift b/Sources/IMVVM/ViewModel.swift new file mode 100644 index 0000000..099efee --- /dev/null +++ b/Sources/IMVVM/ViewModel.swift @@ -0,0 +1,31 @@ +// MIT License +// +// Copyright (c) 2022 Yi Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +import SwiftUI + +/// An `ObservableObject` that provides presentation data to the corresponding `View`. +/// +/// A `ViewModel` acts as the middleware between an `Interactor` and a `View`. The interactor provides network data +/// to the view model. The view model transforms the data into presentation data stored within the view model. And +/// since the view model is an `ObservableObject`, the view observes the view model's and displays the data. +public protocol ViewModel: ObservableObject {} diff --git a/Tests/IMVVMTests/InteractorTests.swift b/Tests/IMVVMTests/InteractorTests.swift new file mode 100644 index 0000000..e33746e --- /dev/null +++ b/Tests/IMVVMTests/InteractorTests.swift @@ -0,0 +1,216 @@ +// MIT License +// +// Copyright (c) 2022 Yi Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Combine +import Foundation +@testable import IMVVM +import XCTest + +class InteractorTests: XCTestCase { + func test_viewLifecycleEvents() { + weak var interactorWeakRef: MockInteractor? + var onLoadCancellableCallCount = 0 + var onViewAppearCancellableCallCount = 0 + + autoreleasepool { + let onLoadCancellable = AnyCancellable { + onLoadCancellableCallCount += 1 + } + + let interactor = MockInteractor(onLoad: { [onLoadCancellable] }) + interactorWeakRef = interactor + + XCTAssertNotNil(interactorWeakRef) + + XCTAssertEqual(interactor.onLoadCallCount, 0) + XCTAssertEqual(interactor.onViewAppearCallCount, 0) + XCTAssertEqual(interactor.onViewDisappearCallCount, 0) + XCTAssertEqual(onLoadCancellableCallCount, 0) + XCTAssertEqual(onViewAppearCancellableCallCount, 0) + XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) + XCTAssertTrue(interactor.viewAppearanceCancelBag.isEmpty()) + + interactor.viewDidAppear() + + var didReceiveValue = false + let subject = PassthroughSubject() + subject + .handleEvents(receiveCancel: { + onViewAppearCancellableCallCount += 1 + }) + .sink { _ in + didReceiveValue = true + // Strongly retain interactor here creating a retain cycle of: + // interactor->cancelBag->cancellable->subscription->interactor. + XCTAssertNotNil(interactor) + } + .cancelOnViewDidDisappear(of: interactor) + + XCTAssertEqual(interactor.onLoadCallCount, 1) + XCTAssertEqual(interactor.onViewAppearCallCount, 1) + XCTAssertEqual(interactor.onViewDisappearCallCount, 0) + XCTAssertEqual(onLoadCancellableCallCount, 0) + XCTAssertEqual(onViewAppearCancellableCallCount, 0) + XCTAssertTrue(interactor.deinitCancelBag.contains(onLoadCancellable)) + XCTAssertFalse(interactor.viewAppearanceCancelBag.isEmpty()) + + subject.send(1) + + XCTAssertTrue(didReceiveValue) + + interactor.viewDidDisappear() + + XCTAssertEqual(interactor.onLoadCallCount, 1) + XCTAssertEqual(interactor.onViewAppearCallCount, 1) + XCTAssertEqual(interactor.onViewDisappearCallCount, 1) + XCTAssertEqual(onLoadCancellableCallCount, 0) + XCTAssertEqual(onViewAppearCancellableCallCount, 1) + XCTAssertTrue(interactor.deinitCancelBag.contains(onLoadCancellable)) + XCTAssertTrue(interactor.viewAppearanceCancelBag.isEmpty()) + } + + XCTAssertEqual(onLoadCancellableCallCount, 1) + XCTAssertEqual(onViewAppearCancellableCallCount, 1) + XCTAssertNil(interactorWeakRef) + } + + func test_sink_cancelOnDeinit_removeBoundCancellable() { + let interactor = MockInteractor() + interactor.viewDidAppear() + + XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) + + let subject = PassthroughSubject() + subject + .ignoreOutput() + .sink(receiveValue: { _ in }) + .cancelOnDeinit(of: interactor) + + XCTAssertFalse(interactor.deinitCancelBag.isEmpty()) + + subject.send(1) + + XCTAssertFalse(interactor.deinitCancelBag.isEmpty()) + } + + func test_sink_cancelOnDeinit_subscriptionRetainInteractor_NoRetainInteractor() { + weak var interactorWeakRef: MockInteractor? + + autoreleasepool { + let interactor = MockInteractor() + interactor.viewDidAppear() + interactorWeakRef = interactor + + XCTAssertNotNil(interactorWeakRef) + + var didReceiveValue = false + let subject = PassthroughSubject() + subject + .sink { _ in + didReceiveValue = true + // Strongly retain interactor here creating a retain cycle of: + // interactor->cancelBag->cancellable->subscription->interactor. + XCTAssertNotNil(interactor) + } + .cancelOnDeinit(of: interactor) + + subject.send(1) + // Completion event should break the retain cycle by removing the strong retain between cancelBag to cancellable. + subject.send(completion: .finished) + + XCTAssertTrue(didReceiveValue) + } + + XCTAssertNil(interactorWeakRef) + } + + func test_activate_shouldCallDidBecomeActive() { + let interactor = MockInteractor() + + XCTAssertEqual(interactor.onLoadCallCount, 0) + XCTAssertFalse(interactor.isLoaded) + + interactor.viewDidAppear() + + XCTAssertEqual(interactor.onLoadCallCount, 1) + XCTAssertTrue(interactor.isLoaded) + + interactor.viewDidDisappear() + + XCTAssertEqual(interactor.onLoadCallCount, 1) + XCTAssertTrue(interactor.isLoaded) + } + + func test_sink_cancelOnDisappear_removeBoundCancellable() { + let interactor = MockInteractor() + interactor.viewDidAppear() + + XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) + + let subject = PassthroughSubject() + subject + .ignoreOutput() + .sink(receiveValue: { _ in }) + .cancelOnViewDidDisappear(of: interactor) + + XCTAssertFalse(interactor.viewAppearanceCancelBag.isEmpty()) + + subject.send(1) + + XCTAssertFalse(interactor.viewAppearanceCancelBag.isEmpty()) + + subject.send(completion: .finished) + + XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) + } + + func test_sink_cancelOnDisappear_subscriptionRetainInteractor_NoRetainInteractor() { + weak var interactorWeakRef: MockInteractor? + + autoreleasepool { + let interactor = MockInteractor() + interactor.viewDidAppear() + interactorWeakRef = interactor + + XCTAssertNotNil(interactorWeakRef) + + var didReceiveValue = false + let subject = PassthroughSubject() + subject + .sink { _ in + didReceiveValue = true + // Strongly retain interactor here creating a retain cycle of: + // interactor->cancelBag->cancellable->subscription->interactor. + XCTAssertNotNil(interactor) + } + .cancelOnViewDidDisappear(of: interactor) + + subject.send(1) + // Completion event should break the retain cycle by removing the strong retain between cancelBag to cancellable. + subject.send(completion: .finished) + + XCTAssertTrue(didReceiveValue) + } + + XCTAssertNil(interactorWeakRef) + } +} diff --git a/Tests/IMVVMTests/Mocks.swift b/Tests/IMVVMTests/Mocks.swift new file mode 100644 index 0000000..150c1dd --- /dev/null +++ b/Tests/IMVVMTests/Mocks.swift @@ -0,0 +1,54 @@ +// MIT License +// +// Copyright (c) 2022 Yi Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Combine +import Foundation +@testable import IMVVM + +class MockInteractor: Interactor { + private let _onLoad: () -> [AnyCancellable] + private let _onViewAppear: () -> [AnyCancellable] + + init(onLoad: @escaping () -> [AnyCancellable] = { [] }, + onViewAppear: @escaping () -> [AnyCancellable] = { [] }) + { + self._onLoad = onLoad + self._onViewAppear = onViewAppear + } + + var onLoadCallCount = 0 + override func onLoad() -> [AnyCancellable] { + onLoadCallCount += 1 + return _onLoad() + } + + var onViewAppearCallCount = 0 + override func onViewAppear() -> [AnyCancellable] { + onViewAppearCallCount += 1 + return _onViewAppear() + } + + var onViewDisappearCallCount = 0 + override func onViewDisappear() { + onViewDisappearCallCount += 1 + } +}