From 05344d5fad456bead2a409ff98acc91695e86548 Mon Sep 17 00:00:00 2001 From: adamz Date: Thu, 24 Aug 2023 13:06:18 -0700 Subject: [PATCH] Enable nested Optionals and Arrays of ObservableObjects (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a wrapper layer which abstracts to allow containers of ObservableObjects — and adds an implementation for Optionals and Arrays. --- LICENSE | 2 +- Package.resolved | 23 ++++ Package.swift | 66 ++++++----- .../project.pbxproj | 52 ++++++++- .../RepublishedExampleApp/App.swift | 24 +++- .../Array/ArrayExampleContentView.swift | 63 ++++++++++ .../Array/ArrayExampleViewModel.swift | 55 +++++++++ .../RepublishedExampleApp/DomainModel.swift | 108 +++++++++--------- .../Optional/OptionalExampleContentView.swift | 67 +++++++++++ .../Optional/OptionalExampleViewModel.swift | 83 ++++++++++++++ .../Single/ContentView.swift | 67 +++++++++++ .../Single/ViewModel.swift | 67 +++++++++++ .../UtilityViews/CapsuleButton.swift | 34 ++++++ .../RepublishedExampleApp/ViewModel.swift | 61 ---------- .../Views/CapsuleButton.swift | 34 ------ .../Views/ContentView.swift | 67 ----------- Sources/Republished/ArrayProxy.swift | 56 +++++++++ Sources/Republished/Equate.swift | 20 ++++ Sources/Republished/ObservableProxy.swift | 51 +++++++++ Sources/Republished/OptionalProxy.swift | 47 ++++++++ Sources/Republished/PassthroughProxy.swift | 31 +++++ Sources/Republished/Republished.swift | 100 ++++++++++------ Tests/RepublishedTests/ArrayTests.swift | 72 ++++++++++++ Tests/RepublishedTests/OptionalTests.swift | 72 ++++++++++++ Tests/RepublishedTests/PassthroughTests.swift | 72 ++++++++++++ Tests/RepublishedTests/RepublishedTests.swift | 68 ----------- 26 files changed, 1105 insertions(+), 357 deletions(-) create mode 100644 Package.resolved create mode 100644 RepublishedExampleApp/RepublishedExampleApp/Array/ArrayExampleContentView.swift create mode 100644 RepublishedExampleApp/RepublishedExampleApp/Array/ArrayExampleViewModel.swift create mode 100644 RepublishedExampleApp/RepublishedExampleApp/Optional/OptionalExampleContentView.swift create mode 100644 RepublishedExampleApp/RepublishedExampleApp/Optional/OptionalExampleViewModel.swift create mode 100644 RepublishedExampleApp/RepublishedExampleApp/Single/ContentView.swift create mode 100644 RepublishedExampleApp/RepublishedExampleApp/Single/ViewModel.swift create mode 100644 RepublishedExampleApp/RepublishedExampleApp/UtilityViews/CapsuleButton.swift delete mode 100644 RepublishedExampleApp/RepublishedExampleApp/ViewModel.swift delete mode 100644 RepublishedExampleApp/RepublishedExampleApp/Views/CapsuleButton.swift delete mode 100644 RepublishedExampleApp/RepublishedExampleApp/Views/ContentView.swift create mode 100644 Sources/Republished/ArrayProxy.swift create mode 100644 Sources/Republished/Equate.swift create mode 100644 Sources/Republished/ObservableProxy.swift create mode 100644 Sources/Republished/OptionalProxy.swift create mode 100644 Sources/Republished/PassthroughProxy.swift create mode 100644 Tests/RepublishedTests/ArrayTests.swift create mode 100644 Tests/RepublishedTests/OptionalTests.swift create mode 100644 Tests/RepublishedTests/PassthroughTests.swift delete mode 100644 Tests/RepublishedTests/RepublishedTests.swift diff --git a/LICENSE b/LICENSE index 5528044..5db2764 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Adam Zethraeus +Copyright (c) 2023 Adam Zethraeus Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..4f5c1c0 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", + "version" : "1.2.3" + } + }, + { + "identity" : "swiftlintfix", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GoodHatsLLC/SwiftLintFix.git", + "state" : { + "revision" : "df971eda06ef78e0570a8feb5e03a04c692ef2b2", + "version" : "0.1.7" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 951ee16..9c18a35 100644 --- a/Package.swift +++ b/Package.swift @@ -1,32 +1,46 @@ -// swift-tools-version: 5.6 +// swift-tools-version: 5.8 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( - name: "Republished", - platforms: [.iOS(.v13), .macOS(.v12)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "Republished", - targets: ["Republished"] - ), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "Republished", - dependencies: [] - ), - .testTarget( - name: "RepublishedTests", - dependencies: ["Republished"] - ), - ] + name: "Republished", + platforms: [.iOS(.v15), .macOS(.v13)], + products: [ + .library( + name: "Republished", + targets: ["Republished"] + ), + ], + dependencies: [ + //.package(url: "https://github.com/GoodHatsLLC/SwiftLintFix.git", from: "0.1.7"), + ], + targets: [ + .target( + name: "Republished", + dependencies: [], + swiftSettings: Env.swiftSettings + ), + .testTarget( + name: "RepublishedTests", + dependencies: ["Republished"], + exclude: ["RepublishedTests.xctestplan"] + ), + ] ) + +// MARK: - Env + +private enum Env { + static let swiftSettings: [SwiftSetting] = { + var settings: [SwiftSetting] = [] + settings.append(contentsOf: [ + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("StrictConcurrency"), + .enableUpcomingFeature("ImplicitOpenExistentials"), + .enableUpcomingFeature("BareSlashRegexLiterals"), + ]) + return settings + }() +} diff --git a/RepublishedExampleApp/RepublishedExampleApp.xcodeproj/project.pbxproj b/RepublishedExampleApp/RepublishedExampleApp.xcodeproj/project.pbxproj index fd08bb8..2900460 100644 --- a/RepublishedExampleApp/RepublishedExampleApp.xcodeproj/project.pbxproj +++ b/RepublishedExampleApp/RepublishedExampleApp.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + F764635D2A97E5D9005497C3 /* OptionalExampleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F764635C2A97E5D9005497C3 /* OptionalExampleViewModel.swift */; }; + F764635F2A97E73C005497C3 /* OptionalExampleContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F764635E2A97E73C005497C3 /* OptionalExampleContentView.swift */; }; + F76463632A97E84C005497C3 /* ArrayExampleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76463612A97E84C005497C3 /* ArrayExampleViewModel.swift */; }; + F76463642A97E84C005497C3 /* ArrayExampleContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76463622A97E84C005497C3 /* ArrayExampleContentView.swift */; }; F7B575FB2942215200FF3D38 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F7B575FA2942215200FF3D38 /* Assets.xcassets */; }; F7B575FE2942215200FF3D38 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F7B575FD2942215200FF3D38 /* Preview Assets.xcassets */; }; F7B5760C294221D200FF3D38 /* DomainModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7B57606294221D200FF3D38 /* DomainModel.swift */; }; @@ -18,6 +22,10 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + F764635C2A97E5D9005497C3 /* OptionalExampleViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptionalExampleViewModel.swift; sourceTree = ""; }; + F764635E2A97E73C005497C3 /* OptionalExampleContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptionalExampleContentView.swift; sourceTree = ""; }; + F76463612A97E84C005497C3 /* ArrayExampleViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArrayExampleViewModel.swift; sourceTree = ""; }; + F76463622A97E84C005497C3 /* ArrayExampleContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArrayExampleContentView.swift; sourceTree = ""; }; F7B575F32942215100FF3D38 /* RepublishedExampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RepublishedExampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; F7B575FA2942215200FF3D38 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; F7B575FD2942215200FF3D38 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -41,6 +49,33 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + F76463582A97E588005497C3 /* Single */ = { + isa = PBXGroup; + children = ( + F7B57609294221D200FF3D38 /* ContentView.swift */, + F7B5760B294221D200FF3D38 /* ViewModel.swift */, + ); + path = Single; + sourceTree = ""; + }; + F76463592A97E5AA005497C3 /* Optional */ = { + isa = PBXGroup; + children = ( + F764635E2A97E73C005497C3 /* OptionalExampleContentView.swift */, + F764635C2A97E5D9005497C3 /* OptionalExampleViewModel.swift */, + ); + path = Optional; + sourceTree = ""; + }; + F76463602A97E84C005497C3 /* Array */ = { + isa = PBXGroup; + children = ( + F76463612A97E84C005497C3 /* ArrayExampleViewModel.swift */, + F76463622A97E84C005497C3 /* ArrayExampleContentView.swift */, + ); + path = Array; + sourceTree = ""; + }; F7B575EA2942215100FF3D38 = { isa = PBXGroup; children = ( @@ -62,10 +97,12 @@ F7B575F52942215100FF3D38 /* RepublishedExampleApp */ = { isa = PBXGroup; children = ( - F7B5760A294221D200FF3D38 /* App.swift */, + F76463602A97E84C005497C3 /* Array */, + F76463592A97E5AA005497C3 /* Optional */, + F76463582A97E588005497C3 /* Single */, F7B57606294221D200FF3D38 /* DomainModel.swift */, - F7B5760B294221D200FF3D38 /* ViewModel.swift */, - F7B57607294221D200FF3D38 /* Views */, + F7B5760A294221D200FF3D38 /* App.swift */, + F7B57607294221D200FF3D38 /* UtilityViews */, F7B575FA2942215200FF3D38 /* Assets.xcassets */, F7B575FC2942215200FF3D38 /* Preview Content */, ); @@ -88,13 +125,12 @@ name = Packages; sourceTree = ""; }; - F7B57607294221D200FF3D38 /* Views */ = { + F7B57607294221D200FF3D38 /* UtilityViews */ = { isa = PBXGroup; children = ( F7B57608294221D200FF3D38 /* CapsuleButton.swift */, - F7B57609294221D200FF3D38 /* ContentView.swift */, ); - path = Views; + path = UtilityViews; sourceTree = ""; }; F7B576112942220200FF3D38 /* Frameworks */ = { @@ -179,9 +215,13 @@ buildActionMask = 2147483647; files = ( F7B57610294221D200FF3D38 /* ViewModel.swift in Sources */, + F764635D2A97E5D9005497C3 /* OptionalExampleViewModel.swift in Sources */, F7B5760C294221D200FF3D38 /* DomainModel.swift in Sources */, F7B5760F294221D200FF3D38 /* App.swift in Sources */, + F764635F2A97E73C005497C3 /* OptionalExampleContentView.swift in Sources */, + F76463642A97E84C005497C3 /* ArrayExampleContentView.swift in Sources */, F7B5760D294221D200FF3D38 /* CapsuleButton.swift in Sources */, + F76463632A97E84C005497C3 /* ArrayExampleViewModel.swift in Sources */, F7B5760E294221D200FF3D38 /* ContentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/RepublishedExampleApp/RepublishedExampleApp/App.swift b/RepublishedExampleApp/RepublishedExampleApp/App.swift index 0a48b60..11b61fc 100644 --- a/RepublishedExampleApp/RepublishedExampleApp/App.swift +++ b/RepublishedExampleApp/RepublishedExampleApp/App.swift @@ -3,9 +3,25 @@ import SwiftUI @main struct RepublishTestApp: App { - var body: some Scene { - WindowGroup { - ContentView(viewModel: ViewModel(model: DomainModel())) - } + var body: some Scene { + WindowGroup { + // Example showing an outer ObservableObject (ViewModel) nesting an inner ObservableObject + // (DomainModel). + ContentView(viewModel: ViewModel(model: DomainModel())) + + // Example showing nesting of an optional, that may or may not contain an inner + // ObservableObject + OptionalExampleContentView(viewModel: OptionalExampleViewModel( + optionalModel: Bool.random() + ? DomainModel() + : nil + )) + + // Example showing nesting of an array, that may contain 0 to 5 inner ObservableObjects. + ArrayExampleContentView(viewModel: ArrayExampleViewModel(models: Array( + repeating: (), + count: Int.random(in: 0 ... 5) + ).map { _ in DomainModel() })) } + } } diff --git a/RepublishedExampleApp/RepublishedExampleApp/Array/ArrayExampleContentView.swift b/RepublishedExampleApp/RepublishedExampleApp/Array/ArrayExampleContentView.swift new file mode 100644 index 0000000..05de4b0 --- /dev/null +++ b/RepublishedExampleApp/RepublishedExampleApp/Array/ArrayExampleContentView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +// MARK: - ArrayExampleContentView + +struct ArrayExampleContentView: View { + + // Regular direct use of outer ObservableObject + + @StateObject var viewModel: ArrayExampleViewModel + + var body: some View { + ScrollView { + VStack(alignment: .center, spacing: 24) { + Spacer() + Text(viewModel.count) + .font(.title) + .fontWeight(.bold) + .scaledToFit() + Text(viewModel.info) + .font(.body.monospaced()) + Spacer() + VStack(alignment: .center, spacing: 24) { + Spacer() + CapsuleButton( + bg: (1, 0, 0, 0), + fg: 1, + text: "increment all models" + ) { + viewModel.incrementAll() + } + CapsuleButton( + bg: (0, 1, 0, 0), + fg: 1, + text: "decrement all models" + ) { + viewModel.decrementAll() + } + CapsuleButton( + bg: (0, 0, 0, 1), + fg: 1, + text: "zero out all models" + ) { + viewModel.zeroAll() + } + Spacer() + } + } + .frame(maxWidth: .infinity) + } + .background(.gray.opacity(0.6)) + } +} + +// MARK: - ArrayExampleContentView_Previews + +struct ArrayExampleContentView_Previews: PreviewProvider { + static var previews: some View { + ArrayExampleContentView(viewModel: ArrayExampleViewModel(models: Array( + repeating: (), + count: Int.random(in: 0 ... 5) + ).map { _ in DomainModel() })) + } +} diff --git a/RepublishedExampleApp/RepublishedExampleApp/Array/ArrayExampleViewModel.swift b/RepublishedExampleApp/RepublishedExampleApp/Array/ArrayExampleViewModel.swift new file mode 100644 index 0000000..6bb58cf --- /dev/null +++ b/RepublishedExampleApp/RepublishedExampleApp/Array/ArrayExampleViewModel.swift @@ -0,0 +1,55 @@ +import Republished +import SwiftUI + +@MainActor +final class ArrayExampleViewModel: ObservableObject { + + // MARK: Lifecycle + + init(models: [DomainModel]) { + _models = .init(wrappedValue: models) + } + + // MARK: Internal + + var info: String { + "across \(models.count) models" + } + + var count: String { + "\(models.map(\.count).reduce(0, +))" + } + + func incrementAll() { + for model in models { + model.set(count: model.count + 1) + } + } + + func decrementAll() { + for model in models { + model.set(count: model.count - 1) + } + } + + func zeroAll() { + for model in models { + model.set(count: 0) + } + } + + // MARK: Private + + // Here the @Republished property wrapper is used *instead* of + // an @Published property wrapper and hold the nested ObservableObjects. + // (Note that there are no @Published wrappers in this file.) + + // @Republished listens to all of its inner ObservableObjects's + // change notifications and propagates them to this containing ObservableObject. + + // SwiftUI views can use properties here derived from the inner object + // just as they would use an @Published field. + + @Republished private var models: [DomainModel] + +} diff --git a/RepublishedExampleApp/RepublishedExampleApp/DomainModel.swift b/RepublishedExampleApp/RepublishedExampleApp/DomainModel.swift index aa86139..1427a5b 100644 --- a/RepublishedExampleApp/RepublishedExampleApp/DomainModel.swift +++ b/RepublishedExampleApp/RepublishedExampleApp/DomainModel.swift @@ -2,61 +2,61 @@ import SwiftUI final class DomainModel: ObservableObject { - // A standard ObservableObject. - - // Updates to `count` makes the object fire a signal that - // consumers can listen to to know when to read — and that - // SwiftUI listens to by default. - - // However, if you nest this in another ObservableObject there's - // no inbuilt functionality to make the outer one fire for updates - // in response to this inner one firing. - - // An ObservableObject is a reference type, so an @Published field - // on the outer object (which contains this object) the field does - // not actually change. - - @Published private(set) var count = 0 - - var isEven: Bool { - count % 2 == 0 - } - - var isZero: Bool { - count == 0 - } - - var isNegative: Bool { - count < 0 - } - - var isPositive: Bool { - count > 0 - } - - var isMax: Bool { - count == Int.max - } - - var isMin: Bool { - count == Int.min - } - - var isPrime: Bool { - switch true { - case count < 2: return false - case count < 4: return true - default: - return (2...Int(Double(count).squareRoot())) - .lazy - .filter { [count] div in - count % div == 0 - } - .first == nil + // A standard ObservableObject. + + // Updates to `count` makes the object fire a signal that + // consumers can listen to to know when to read — and that + // SwiftUI listens to by default. + + // However, if you nest this in another ObservableObject there's + // no inbuilt functionality to make the outer one fire for updates + // in response to this inner one firing. + + // An ObservableObject is a reference type, so an @Published field + // on the outer object (which contains this object) the field does + // not actually change. + + @Published private(set) var count = 0 + + var isEven: Bool { + count % 2 == 0 + } + + var isZero: Bool { + count == 0 + } + + var isNegative: Bool { + count < 0 + } + + var isPositive: Bool { + count > 0 + } + + var isMax: Bool { + count == Int.max + } + + var isMin: Bool { + count == Int.min + } + + var isPrime: Bool { + switch true { + case count < 2: return false + case count < 4: return true + default: + return (2 ... Int(Double(count).squareRoot())) + .lazy + .filter { [count] div in + count % div == 0 } + .first == nil } + } - func set(count: Int) { - self.count = count - } + func set(count: Int) { + self.count = count + } } diff --git a/RepublishedExampleApp/RepublishedExampleApp/Optional/OptionalExampleContentView.swift b/RepublishedExampleApp/RepublishedExampleApp/Optional/OptionalExampleContentView.swift new file mode 100644 index 0000000..63dcf2b --- /dev/null +++ b/RepublishedExampleApp/RepublishedExampleApp/Optional/OptionalExampleContentView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +// MARK: - OptionalExampleContentView + +struct OptionalExampleContentView: View { + + // Regular direct use of outer ObservableObject + + @StateObject var viewModel: OptionalExampleViewModel + + var body: some View { + ScrollView { + VStack(alignment: .center, spacing: 24) { + Spacer() + Text(viewModel.countString) + .font(.title) + .fontWeight(.bold) + .scaledToFit() + Text(viewModel.info) + .font(.body.monospaced()) + Spacer() + VStack(alignment: .center, spacing: 24) { + Spacer() + CapsuleButton( + bg: (1, 0, 0, 0), + fg: 1, + text: "count += 1" + ) { + viewModel.increment() + } + CapsuleButton( + bg: (0, 1, 0, 0), + fg: 1, + text: "count -= 1" + ) { + viewModel.decrement() + } + CapsuleButton( + bg: (0, 0, 1, 0), + fg: 0, + text: "count = rand()" + ) { + viewModel.rand() + } + CapsuleButton( + bg: (0, 0, 0, 1), + fg: 1, + text: "count = 0" + ) { + viewModel.zero() + } + Spacer() + } + } + .frame(maxWidth: .infinity) + } + .background(.gray.opacity(0.6)) + } +} + +// MARK: - OptionalExampleContentView_Previews + +struct OptionalExampleContentView_Previews: PreviewProvider { + static var previews: some View { + OptionalExampleContentView(viewModel: OptionalExampleViewModel(optionalModel: nil)) + } +} diff --git a/RepublishedExampleApp/RepublishedExampleApp/Optional/OptionalExampleViewModel.swift b/RepublishedExampleApp/RepublishedExampleApp/Optional/OptionalExampleViewModel.swift new file mode 100644 index 0000000..3af483d --- /dev/null +++ b/RepublishedExampleApp/RepublishedExampleApp/Optional/OptionalExampleViewModel.swift @@ -0,0 +1,83 @@ +import Republished +import SwiftUI + +@MainActor +final class OptionalExampleViewModel: ObservableObject { + + // MARK: Lifecycle + + init(optionalModel: DomainModel?) { + _optionalModel = .init(wrappedValue: optionalModel) + } + + // MARK: Internal + + var info: String { + [ + optionalModel?.isEven ?? false ? "even" : nil, + optionalModel?.isZero ?? false ? "zero" : nil, + optionalModel?.isNegative ?? false ? "negative" : nil, + optionalModel?.isPositive ?? false ? "positive" : nil, + optionalModel?.isMax ?? false ? "MAXINT" : nil, + optionalModel?.isMin ?? false ? "MININT" : nil, + optionalModel?.isPrime ?? false ? "prime" : nil, + ] + .compactMap { $0 } + .sorted() + .joined(separator: ", ") + } + + var countString: String { + "\((optionalModel?.count).map(String.init) ?? "Optional model is absent!")" + } + + func increment() { + guard let model = optionalModel + else { + return + } + model.set(count: model.count + 1) + } + + func decrement() { + guard let model = optionalModel + else { + return + } + model.set(count: model.count - 1) + } + + func rand() { + guard let model = optionalModel + else { + return + } + model.set(count: Int.random(in: Int.min ... Int.max)) + } + + func zero() { + guard let model = optionalModel + else { + return + } + model.set(count: 0) + } + + // MARK: Private + + // Here the @Republished property wrapper is used *instead* of + // an @Published property wrapper and hold the nested ObservableObject. + // (Note that there are no @Published wrappers in this file.) + + // @Republished listens to the inner ObservableObject's + // change notifications and propagates them to the outer one. + + // SwiftUI views can use properties here derived from the inner object + // just as they would use an @Published field. + // + // This outer object could also provide @Binding surfaces into + // the inner object's data. + + @Republished private var optionalModel: DomainModel? = nil + +} diff --git a/RepublishedExampleApp/RepublishedExampleApp/Single/ContentView.swift b/RepublishedExampleApp/RepublishedExampleApp/Single/ContentView.swift new file mode 100644 index 0000000..0d1b33f --- /dev/null +++ b/RepublishedExampleApp/RepublishedExampleApp/Single/ContentView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +// MARK: - ContentView + +struct ContentView: View { + + // Regular direct use of outer ObservableObject + + @StateObject var viewModel: ViewModel + + var body: some View { + ScrollView { + VStack(alignment: .center, spacing: 24) { + Spacer() + Text(viewModel.countString) + .font(.title) + .fontWeight(.bold) + .scaledToFit() + Text(viewModel.info) + .font(.body.monospaced()) + Spacer() + VStack(alignment: .center, spacing: 24) { + Spacer() + CapsuleButton( + bg: (1, 0, 0, 0), + fg: 1, + text: "count += 1" + ) { + viewModel.increment() + } + CapsuleButton( + bg: (0, 1, 0, 0), + fg: 1, + text: "count -= 1" + ) { + viewModel.decrement() + } + CapsuleButton( + bg: (0, 0, 1, 0), + fg: 0, + text: "count = rand()" + ) { + viewModel.rand() + } + CapsuleButton( + bg: (0, 0, 0, 1), + fg: 1, + text: "count = 0" + ) { + viewModel.zero() + } + Spacer() + } + } + .frame(maxWidth: .infinity) + } + .background(.gray.opacity(0.6)) + } +} + +// MARK: - ContentView_Previews + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView(viewModel: ViewModel(model: DomainModel())) + } +} diff --git a/RepublishedExampleApp/RepublishedExampleApp/Single/ViewModel.swift b/RepublishedExampleApp/RepublishedExampleApp/Single/ViewModel.swift new file mode 100644 index 0000000..1b55f9a --- /dev/null +++ b/RepublishedExampleApp/RepublishedExampleApp/Single/ViewModel.swift @@ -0,0 +1,67 @@ +import Republished +import SwiftUI + +@MainActor +final class ViewModel: ObservableObject { + + // MARK: Lifecycle + + init(model: DomainModel) { + _model = .init(wrappedValue: model) + } + + // MARK: Internal + + var info: String { + [ + model.isEven ? "even" : nil, + model.isZero ? "zero" : nil, + model.isNegative ? "negative" : nil, + model.isPositive ? "positive" : nil, + model.isMax ? "MAXINT" : nil, + model.isMin ? "MININT" : nil, + model.isPrime ? "prime" : nil, + ] + .compactMap { $0 } + .sorted() + .joined(separator: ", ") + } + + var countString: String { + "\(model.count)" + } + + func increment() { + model.set(count: model.count + 1) + } + + func decrement() { + model.set(count: model.count - 1) + } + + func rand() { + model.set(count: Int.random(in: Int.min ... Int.max)) + } + + func zero() { + model.set(count: 0) + } + + // MARK: Private + + // Here the @Republished property wrapper is used *instead* of + // an @Published property wrapper and hold the nested ObservableObject. + // (Note that there are no @Published wrappers in this file.) + + // @Republished listens to the inner ObservableObject's + // change notifications and propagates them to the outer one. + + // SwiftUI views can use properties here derived from the inner object + // just as they would use an @Published field. + // + // This outer object could also provide @Binding surfaces into + // the inner object's data. + + @Republished private var model: DomainModel + +} diff --git a/RepublishedExampleApp/RepublishedExampleApp/UtilityViews/CapsuleButton.swift b/RepublishedExampleApp/RepublishedExampleApp/UtilityViews/CapsuleButton.swift new file mode 100644 index 0000000..6c546b0 --- /dev/null +++ b/RepublishedExampleApp/RepublishedExampleApp/UtilityViews/CapsuleButton.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct CapsuleButton: View { + + let bg: (c: CGFloat, m: CGFloat, y: CGFloat, k: CGFloat) + let fg: CGFloat + let text: String + let action: () -> Void + + var body: some View { + Button(text) { action() } + .padding(16) + .background( + Color( + CGColor( + genericCMYKCyan: bg.c, + magenta: bg.m, + yellow: bg.y, + black: bg.k, + alpha: 1 + ) + ) + ) + .foregroundColor( + Color( + hue: 0, + saturation: 0, + brightness: fg + ) + ) + .clipShape(Capsule()) + } + +} diff --git a/RepublishedExampleApp/RepublishedExampleApp/ViewModel.swift b/RepublishedExampleApp/RepublishedExampleApp/ViewModel.swift deleted file mode 100644 index c42a9a9..0000000 --- a/RepublishedExampleApp/RepublishedExampleApp/ViewModel.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Republished -import SwiftUI - -@MainActor -final class ViewModel: ObservableObject { - - // Here the @Republished property wrapper is used *instead* of - // an @Published property wrapper and hold the nested ObservableObject. - // (Note that there are no @Published wrappers in this file.) - - // @Republished listens to the inner ObservableObject's - // change notifications and propagates them to the outer one. - - // SwiftUI views can use properties here derived from the inner object - // just as they would use an @Published field. - // - // This outer object could also provide @Binding surfaces into - // the inner object's data. - - @Republished private var model: DomainModel - - init(model: DomainModel) { - _model = .init(wrappedValue: model) - } - - var info: String { - [ - model.isEven ? "even" : nil, - model.isZero ? "zero" : nil, - model.isNegative ? "negative" : nil, - model.isPositive ? "positive" : nil, - model.isMax ? "MAXINT" : nil, - model.isMin ? "MININT" : nil, - model.isPrime ? "prime" : nil, - ] - .compactMap { $0 } - .sorted() - .joined(separator: ", ") - } - - var countString: String { - "\(model.count)" - } - - func increment() { - model.set(count: model.count + 1) - } - - func decrement() { - model.set(count: model.count - 1) - } - - func rand() { - model.set(count: Int.random(in: Int.min...Int.max)) - } - - func zero() { - model.set(count: 0) - } - -} diff --git a/RepublishedExampleApp/RepublishedExampleApp/Views/CapsuleButton.swift b/RepublishedExampleApp/RepublishedExampleApp/Views/CapsuleButton.swift deleted file mode 100644 index 297f74e..0000000 --- a/RepublishedExampleApp/RepublishedExampleApp/Views/CapsuleButton.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftUI - -struct CapsuleButton: View { - - let bg: (c: CGFloat, m: CGFloat, y: CGFloat, k: CGFloat) - let fg: CGFloat - let text: String - let action: () -> Void - - var body: some View { - Button(text) { action() } - .padding(16) - .background( - Color( - CGColor( - genericCMYKCyan: bg.c, - magenta: bg.m, - yellow: bg.y, - black: bg.k, - alpha: 1 - ) - ) - ) - .foregroundColor( - Color( - hue: 0, - saturation: 0, - brightness: fg - ) - ) - .clipShape(Capsule()) - } - -} diff --git a/RepublishedExampleApp/RepublishedExampleApp/Views/ContentView.swift b/RepublishedExampleApp/RepublishedExampleApp/Views/ContentView.swift deleted file mode 100644 index bff9d68..0000000 --- a/RepublishedExampleApp/RepublishedExampleApp/Views/ContentView.swift +++ /dev/null @@ -1,67 +0,0 @@ -import SwiftUI - -// MARK: - ContentView - -struct ContentView: View { - - // Regular direct use of outer ObservableObject - - @StateObject var viewModel: ViewModel - - var body: some View { - ScrollView { - VStack(alignment: .center, spacing: 24) { - Spacer() - Text(viewModel.countString) - .font(.title) - .fontWeight(.bold) - .scaledToFit() - Text(viewModel.info) - .font(.body.monospaced()) - Spacer() - VStack(alignment: .center, spacing: 24) { - Spacer() - CapsuleButton( - bg: (1, 0, 0, 0), - fg: 1, - text: "count += 1" - ) { - viewModel.increment() - } - CapsuleButton( - bg: (0, 1, 0, 0), - fg: 1, - text: "count -= 1" - ) { - viewModel.decrement() - } - CapsuleButton( - bg: (0, 0, 1, 0), - fg: 0, - text: "count = rand()" - ) { - viewModel.rand() - } - CapsuleButton( - bg: (0, 0, 0, 1), - fg: 1, - text: "count = 0" - ) { - viewModel.zero() - } - Spacer() - } - } - .frame(maxWidth: .infinity) - } - .background(.gray.opacity(0.6)) - } -} - -// MARK: - ContentView_Previews - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView(viewModel: ViewModel(model: DomainModel())) - } -} diff --git a/Sources/Republished/ArrayProxy.swift b/Sources/Republished/ArrayProxy.swift new file mode 100644 index 0000000..01045a8 --- /dev/null +++ b/Sources/Republished/ArrayProxy.swift @@ -0,0 +1,56 @@ +import Combine +import Foundation +import SwiftUI + +/// Proxy a `objectWillChange` emissions from an array of `ObservableObject`s. +struct ArrayProxy: ObservableProxy { + + // MARK: Lifecycle + + init(_ initial: [Source]) { + self.subject = .init(initial) + } + + // MARK: Internal + + typealias Storage = [Source] + + var underlying: [Source] { + get { + subject.value + } + nonmutating set { + subject.value = newValue + } + } + + var objectWillChange: some Publisher { + // A publisher emitting based on 2 upstreams... + Publishers.Merge( + // ... one which publishes whenever any one of the underlying objects's objectWillChange + // publishes. + subject + .flatMap { allUnderlying in + // Make a publisher which emits when any one of the current underlying ObservableObjects's + // changes. + Publishers.MergeMany( + // Access each of the current ObservableObjects objectWillChange fields. + allUnderlying + .map { singleUnderlying in + singleUnderlying.objectWillChange + } + ) + } + .map { _ in () }, + // ... one which publishes every time the optional underlying object array changes. + subject + .removeDuplicates(by: Equate<[Source], [Source]>.it) + .map { _ in () } + ) + } + + // MARK: Private + + private let subject: CurrentValueSubject<[Source], Never> + +} diff --git a/Sources/Republished/Equate.swift b/Sources/Republished/Equate.swift new file mode 100644 index 0000000..d010f7e --- /dev/null +++ b/Sources/Republished/Equate.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Helper to equate various ObservableObject wrappers by the identity of their contained +/// ObservableObjects +enum Equate { + /// Equate arrays of Objects by the contained objects's identity. + static func it(_ lhs: [IdentityType], _ rhs: [IdentityType]) -> Bool { + lhs.map(ObjectIdentifier.init) == rhs.map(ObjectIdentifier.init) + } + + /// Equate optional Objects — considering two nil values equal. + static func it(_ lhs: IdentityType?, _ rhs: IdentityType?) -> Bool { + switch (lhs, rhs) { + case (.none, .none): return true + case (.none, .some), + (.some, .none): return false + case (.some(let lhsObject), .some(let rhsObject)): return lhsObject === rhsObject + } + } +} diff --git a/Sources/Republished/ObservableProxy.swift b/Sources/Republished/ObservableProxy.swift new file mode 100644 index 0000000..ebd009c --- /dev/null +++ b/Sources/Republished/ObservableProxy.swift @@ -0,0 +1,51 @@ +import Combine +import Foundation + +// MARK: - ObservableProxy + +/// A protocol describing entities which can proxy some underlying +/// `ObservableObject`(s) 'willChange' events, and exposes its +/// ownership of them as `Storage`. +/// +/// `Storage` could be a raw `ObservableObject`, or some sort of +/// wrapper. +protocol ObservableProxy { + associatedtype ObjectWillChangePublisher: Publisher + associatedtype Storage + var underlying: Storage { get nonmutating set } + var objectWillChange: ObjectWillChangePublisher { get } +} + +extension ObservableProxy { + func erase() -> ErasedProxy { + .init(self) + } +} + +// MARK: - ErasedProxy + +/// A type erased `ObservableProxy` +/// +/// This struct doesn't itself conform to `ObservableProxy` +/// because it's an implementation detail and doesn't +/// really need to! +struct ErasedProxy { + init(_ p: some ObservableProxy) { + self.objectWillChange = p.objectWillChange.eraseToAnyPublisher() + self.getUnderlyingFunc = { p.underlying } + self.setUnderlyingFunc = { p.underlying = $0 } + } + + let objectWillChange: AnyPublisher + var underlying: Storage { + get { + getUnderlyingFunc() + } + nonmutating set { + setUnderlyingFunc(newValue) + } + } + + private let getUnderlyingFunc: () -> Storage + private let setUnderlyingFunc: (Storage) -> Void +} diff --git a/Sources/Republished/OptionalProxy.swift b/Sources/Republished/OptionalProxy.swift new file mode 100644 index 0000000..279deb1 --- /dev/null +++ b/Sources/Republished/OptionalProxy.swift @@ -0,0 +1,47 @@ +import Combine +import Foundation +import SwiftUI + +/// Proxy an `Optional` `ObservableObject`'s objectWillChange emissions. +struct OptionalProxy: ObservableProxy { + + // MARK: Lifecycle + + init(_ initial: Source?) { + self.subject = .init(initial) + } + + // MARK: Internal + + typealias Storage = Source? + + var underlying: Source? { + get { + subject.value + } + nonmutating set { + subject.value = newValue + } + } + + var objectWillChange: some Publisher { + // A publisher emitting based on 2 upstreams... + Publishers.Merge( + // ... one which republishes the underlying observable object's objectWillChange (if it + // exists). + subject + .compactMap { $0 } + .flatMap(\.objectWillChange) + .map { _ in () }, + // ... one which publishes every time the optional underlying object changes. + subject + .removeDuplicates(by: Equate.it) + .map { _ in () } + ) + } + + // MARK: Private + + private let subject: CurrentValueSubject + +} diff --git a/Sources/Republished/PassthroughProxy.swift b/Sources/Republished/PassthroughProxy.swift new file mode 100644 index 0000000..ac341c5 --- /dev/null +++ b/Sources/Republished/PassthroughProxy.swift @@ -0,0 +1,31 @@ +import Combine +import Foundation +import SwiftUI + +/// Proxy an `ObservableObject`'s `objectWillChange` emissions. +struct PassthroughProxy: ObservableProxy { + typealias Storage = Source + init(_ initial: Source) { + self.subject = .init(initial) + } + + var underlying: Source { + get { subject.value } + nonmutating set { subject.value = newValue } + } + + private let subject: CurrentValueSubject + var objectWillChange: some Publisher { + // A publisher emitting based on 2 upstreams... + Publishers.Merge( + // ... one which republishes the underlying observable object's objectWillChange. + subject + .flatMap(\.objectWillChange) + .map { _ in () }, + // ... one which publishes every time the underlying object changes. + subject + .removeDuplicates(by: ===) + .map { _ in () } + ) + } +} diff --git a/Sources/Republished/Republished.swift b/Sources/Republished/Republished.swift index a1b0304..7cfc14e 100644 --- a/Sources/Republished/Republished.swift +++ b/Sources/Republished/Republished.swift @@ -20,52 +20,80 @@ import SwiftUI /// var infoFromInner: String { "\(inner.info)" } /// ``` /// -/// > Note: The outer `ObservableObject` will only republish notifications -/// > of inner `ObservableObjects` that it actually accesses. +/// You can republish from a single ObservableObject, an optional one, or an array of them. +/// +/// ```swift +/// @Republished private var inner: InnerObservableObject +/// @Republished private var innerOptional: InnerObservableObject? +/// @Republished private var innerArray: [InnerObservableObject] +/// ``` +/// +/// > Note: The outer `ObservableObject` will only publish if it is accessed at runtime. +/// > i.e. An unused `@Republished` field doesn't create any underlying subscriptions. @MainActor @propertyWrapper -public final class Republished - where Republishing.ObjectWillChangePublisher == ObservableObjectPublisher { +public final class Republished { - public init(wrappedValue republished: Republishing) { - self.republished = republished - } + // MARK: Lifecycle - public var wrappedValue: Republishing { - republished - } + public init(wrappedValue: Wrapped) where Wrapped: ObservableObject { + self.proxy = PassthroughProxy(wrappedValue).erase() + } - public var projectedValue: Binding { - Binding { - self.republished - } set: { newValue in - self.republished = newValue - } + public init(wrappedValue: T?) where Wrapped == T? { + self.proxy = OptionalProxy(wrappedValue).erase() + } + + public init(wrappedValue: [T]) where Wrapped == [T] { + self.proxy = ArrayProxy(wrappedValue).erase() + } + + // MARK: Public + + public var wrappedValue: Wrapped { + fatalError("Republished can only be used within an ObservableObject") + } + + public var projectedValue: Binding { + Binding { + self.proxy.underlying + } set: { newValue in + self.proxy.underlying = newValue } + } - public static subscript< - Instance: ObservableObject - >( - _enclosingInstance instance: Instance, - wrapped _: KeyPath, - storage storageKeyPath: KeyPath - ) - -> Republishing where Instance.ObjectWillChangePublisher == ObservableObjectPublisher { - let storage = instance[keyPath: storageKeyPath] + public static subscript< + Instance: ObservableObject + >( + _enclosingInstance instance: Instance, + wrapped _: KeyPath, + storage storageKeyPath: KeyPath + ) + -> Wrapped where Instance.ObjectWillChangePublisher == ObservableObjectPublisher + { + let storage = instance[keyPath: storageKeyPath] - if storage.cancellable == nil { - storage.cancellable = storage - .wrappedValue - .objectWillChange - .sink { [objectWillChange = instance.objectWillChange] in - objectWillChange.send() - } + if storage.cancellable == nil { + storage.cancellable = storage + .proxy + .objectWillChange + // Proxies publish first on subscribe, but that happens in a read — which is during a view + // update. + .dropFirst() + .handleEvents(receiveCompletion: { _ in + storage.cancellable = nil + }) + .sink { [objectWillChange = instance.objectWillChange] in + objectWillChange.send() } - - return storage.wrappedValue } - private var republished: Republishing - private var cancellable: AnyCancellable? + return storage.proxy.underlying + } + + // MARK: Private + + private let proxy: ErasedProxy + private var cancellable: AnyCancellable? } diff --git a/Tests/RepublishedTests/ArrayTests.swift b/Tests/RepublishedTests/ArrayTests.swift new file mode 100644 index 0000000..28e97a9 --- /dev/null +++ b/Tests/RepublishedTests/ArrayTests.swift @@ -0,0 +1,72 @@ +import Combine +import SwiftUI +import XCTest +@testable import Republished + +// MARK: - ArrayTests + +@MainActor +final class ArrayTests: XCTestCase { + + // MARK: Internal + + override func setUpWithError() throws { + willChangeCount = 0 + outerObject = OuterObject() + objects = outerObject.objects + cancellable = outerObject.objectWillChange + .sink { + self.willChangeCount += 1 + } + } + + override func tearDownWithError() throws { + willChangeCount = nil + objects = nil + cancellable = nil + } + + func test_isRepublishedOnChange() { + XCTAssertEqual(willChangeCount, 0) + objects.first?.x = 1 + XCTAssertEqual(willChangeCount, 1) + } + + func test_isRepublishedOnChange_whenPrivateSet() { + XCTAssertEqual(willChangeCount, 0) + objects.first?.setY("goodbye") + XCTAssertEqual(willChangeCount, 1) + } + + func test_isRepublishedOnChange_whenModifiedWithBinding() { + XCTAssertEqual(willChangeCount, 0) + let x = outerObject.$objects.first?.x + x?.wrappedValue = 20 + XCTAssertEqual(willChangeCount, 1) + } + + // MARK: Private + + @ObservedObject private var outerObject = OuterObject() + private var objects: [RepublishedObject]! + private var willChangeCount: Int! + private var cancellable: AnyCancellable! + +} + +// MARK: - OuterObject + +private final class OuterObject: ObservableObject { + @Republished var objects = [RepublishedObject(), RepublishedObject(), RepublishedObject()] +} + +// MARK: - RepublishedObject + +private final class RepublishedObject: ObservableObject { + @Published var x = 10 + @Published private(set) var y = "Hello" + + func setY(_ value: String) { + y = value + } +} diff --git a/Tests/RepublishedTests/OptionalTests.swift b/Tests/RepublishedTests/OptionalTests.swift new file mode 100644 index 0000000..5362418 --- /dev/null +++ b/Tests/RepublishedTests/OptionalTests.swift @@ -0,0 +1,72 @@ +import Combine +import SwiftUI +import XCTest +@testable import Republished + +// MARK: - OptionalTests + +@MainActor +final class OptionalTests: XCTestCase { + + // MARK: Internal + + override func setUpWithError() throws { + willChangeCount = 0 + outerObject = OuterObject() + object = outerObject.object + cancellable = outerObject.objectWillChange + .sink { + self.willChangeCount += 1 + } + } + + override func tearDownWithError() throws { + willChangeCount = nil + object = nil + cancellable = nil + } + + func test_isRepublishedOnChange() { + XCTAssertEqual(willChangeCount, 0) + object?.x = 100 + XCTAssertEqual(willChangeCount, 1) + } + + func test_isRepublishedOnChange_whenPrivateSet() { + XCTAssertEqual(willChangeCount, 0) + object?.setY("goodbye") + XCTAssertEqual(willChangeCount, 1) + } + + func test_isRepublishedOnChange_whenModifiedWithBinding() { + XCTAssertEqual(willChangeCount, 0) + let x = Binding(outerObject.$object)?.x + x?.wrappedValue = 20 + XCTAssertEqual(willChangeCount, 1) + } + + // MARK: Private + + @ObservedObject private var outerObject = OuterObject() + private var object: RepublishedObject? + private var willChangeCount: Int! + private var cancellable: AnyCancellable! + +} + +// MARK: - OuterObject + +private final class OuterObject: ObservableObject { + @Republished var object: RepublishedObject? = RepublishedObject() +} + +// MARK: - RepublishedObject + +private final class RepublishedObject: ObservableObject { + @Published var x = 10 + @Published private(set) var y = "Hello" + + func setY(_ value: String) { + y = value + } +} diff --git a/Tests/RepublishedTests/PassthroughTests.swift b/Tests/RepublishedTests/PassthroughTests.swift new file mode 100644 index 0000000..78cc157 --- /dev/null +++ b/Tests/RepublishedTests/PassthroughTests.swift @@ -0,0 +1,72 @@ +import Combine +import SwiftUI +import XCTest +@testable import Republished + +// MARK: - PassthroughTests + +@MainActor +final class PassthroughTests: XCTestCase { + + // MARK: Internal + + override func setUpWithError() throws { + willChangeCount = 0 + outerObject = OuterObject() + object = outerObject.object + cancellable = outerObject.objectWillChange + .sink { + self.willChangeCount += 1 + } + } + + override func tearDownWithError() throws { + willChangeCount = nil + object = nil + cancellable = nil + } + + func test_isRepublishedOnChange() { + XCTAssertEqual(willChangeCount, 0) + object.x = 100 + XCTAssertEqual(willChangeCount, 1) + } + + func test_isRepublishedOnChange_whenPrivateSet() { + XCTAssertEqual(willChangeCount, 0) + object.setY("goodbye") + XCTAssertEqual(willChangeCount, 1) + } + + func test_isRepublishedOnChange_whenModifiedWithBinding() { + XCTAssertEqual(willChangeCount, 0) + let x = outerObject.$object.x + x.wrappedValue = 20 + XCTAssertEqual(willChangeCount, 1) + } + + // MARK: Private + + @ObservedObject private var outerObject = OuterObject() + private var object: RepublishedObject! + private var willChangeCount: Int! + private var cancellable: AnyCancellable! + +} + +// MARK: - OuterObject + +private final class OuterObject: ObservableObject { + @Republished var object = RepublishedObject() +} + +// MARK: - RepublishedObject + +private final class RepublishedObject: ObservableObject { + @Published var x = 10 + @Published private(set) var y = "Hello" + + func setY(_ value: String) { + y = value + } +} diff --git a/Tests/RepublishedTests/RepublishedTests.swift b/Tests/RepublishedTests/RepublishedTests.swift deleted file mode 100644 index 1105e1f..0000000 --- a/Tests/RepublishedTests/RepublishedTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Combine -import SwiftUI -import XCTest -@testable import Republished - -// MARK: - RepublishedTests - -@MainActor -final class RepublishedTests: XCTestCase { - - @ObservedObject var outerObject = OuterObject() - var object: RepublishedObject! - var willChangeCount: Int! - var cancellable: AnyCancellable! - - override func setUpWithError() throws { - willChangeCount = 0 - outerObject = OuterObject() - object = outerObject.object - cancellable = outerObject.objectWillChange - .sink { - self.willChangeCount += 1 - } - } - - override func tearDownWithError() throws { - willChangeCount = nil - object = nil - cancellable = nil - } - - func test_isRepublishedOnChange() { - XCTAssertEqual(willChangeCount, 0) - object.x = 100 - XCTAssertEqual(willChangeCount, 1) - } - - func test_isRepublishedOnChange_whenPrivateSet() { - XCTAssertEqual(willChangeCount, 0) - object.setY("goodbye") - XCTAssertEqual(willChangeCount, 1) - } - - func test_isRepublishedOnChange_whenModifiedWithBinding() { - XCTAssertEqual(willChangeCount, 0) - let x = outerObject.$object.x - x.wrappedValue = 20 - XCTAssertEqual(willChangeCount, 1) - } - -} - -// MARK: - OuterObject - -final class OuterObject: ObservableObject { - @Republished var object = RepublishedObject() -} - -// MARK: - RepublishedObject - -final class RepublishedObject: ObservableObject { - @Published var x = 10 - @Published private(set) var y = "Hello" - - func setY(_ value: String) { - y = value - } -}