Last active
February 4, 2023 22:52
-
-
Save clayellis/4a12828bc90599c331a320f77cf104ed to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// This gist demonstrates how ObservableObjects can be composed in a simple way by leveraging a new | |
// @Republished property wrapper. @Republished republishes child state objectWillChange messages on its | |
// parent's objectWillChange publisher, tying the two together. | |
// The example app is from PointFree Co's episode: https://www.pointfree.co/episodes/ep146-derived-behavior-the-problem | |
// The one major change besides @Republished is that the "favorites" state has been extracted from the | |
// Counter/ProfileViewModels into a single source of truth: FavoritesViewModel. | |
import Combine | |
import SwiftUI | |
// MARK: - App | |
@main | |
struct DerivedBehaviorRepublishedApp: App { | |
@ObservedObject var appViewModel: AppViewModel | |
init() { | |
let favorites = FavoritesViewModel() | |
appViewModel = AppViewModel( | |
counter: CounterViewModel(favorites: favorites), | |
profile: ProfileViewModel(favorites: favorites) | |
) | |
} | |
var body: some Scene { | |
WindowGroup { | |
VanillaContentView(viewModel: appViewModel) | |
} | |
} | |
} | |
// MARK: - View Models | |
class AppViewModel: ObservableObject { | |
@Republished var counter: CounterViewModel | |
@Republished var profile: ProfileViewModel | |
init(counter: CounterViewModel, profile: ProfileViewModel) { | |
self.counter = counter | |
self.profile = profile | |
} | |
init(favorites: FavoritesViewModel) { | |
self.counter = CounterViewModel(favorites: favorites) | |
self.profile = ProfileViewModel(favorites: favorites) | |
} | |
} | |
class FavoritesViewModel: ObservableObject { | |
@Published var favorites: Set<Int> = [] | |
} | |
class CounterViewModel: ObservableObject { | |
@Published var count = 0 | |
@Republished var favorites: FavoritesViewModel | |
init(favorites: FavoritesViewModel) { | |
self.favorites = favorites | |
} | |
} | |
class ProfileViewModel: ObservableObject { | |
@Republished var favorites: FavoritesViewModel | |
init(favorites: FavoritesViewModel) { | |
self.favorites = favorites | |
} | |
} | |
// MARK: - Views | |
struct VanillaContentView: View { | |
@ObservedObject var viewModel: AppViewModel | |
var body: some View { | |
TabView { | |
VanillaCounterView(viewModel: self.viewModel.counter) | |
.tabItem { Text("Counter \(self.viewModel.counter.count)") } | |
VanillaProfileView(viewModel: self.viewModel.profile) | |
.tabItem { Text("Profile \(self.viewModel.profile.favorites.favorites.count)") } | |
} | |
} | |
} | |
struct VanillaCounterView: View { | |
@ObservedObject var viewModel: CounterViewModel | |
var body: some View { | |
VStack { | |
HStack { | |
Button("-") { self.viewModel.count -= 1 } | |
Text("\(self.viewModel.count)") | |
Button("+") { self.viewModel.count += 1 } | |
} | |
if self.viewModel.favorites.favorites.contains(self.viewModel.count) { | |
Button("Remove") { | |
self.viewModel.favorites.favorites.remove(self.viewModel.count) | |
} | |
} else { | |
Button("Save") { | |
self.viewModel.favorites.favorites.insert(self.viewModel.count) | |
} | |
} | |
} | |
} | |
} | |
struct VanillaProfileView: View { | |
@ObservedObject var viewModel: ProfileViewModel | |
var body: some View { | |
List { | |
ForEach(self.viewModel.favorites.favorites.sorted(), id: \.self) { number in | |
HStack { | |
Text("\(number)") | |
Spacer() | |
Button("Remove") { | |
self.viewModel.favorites.favorites.remove(number) | |
} | |
} | |
} | |
} | |
} | |
} | |
// MARK: - Republished | |
@propertyWrapper | |
struct Republished<Object: ObservableObject> where Object.ObjectWillChangePublisher == ObservableObjectPublisher { | |
static subscript<T: ObservableObject>( | |
_enclosingInstance instance: T, | |
wrapped wrappedKeyPath: ReferenceWritableKeyPath<T, Object>, | |
storage storageKeyPath: ReferenceWritableKeyPath<T, Self> | |
) -> Object where T.ObjectWillChangePublisher: ObservableObjectPublisher { | |
get { | |
let value = instance[keyPath: storageKeyPath].wrappedValue | |
if instance[keyPath: storageKeyPath].subscription == nil { | |
instance[keyPath: storageKeyPath].subscription = value.republish(on: instance) | |
} | |
return value | |
} | |
set { | |
if instance[keyPath: storageKeyPath].wrappedValue !== newValue { | |
instance[keyPath: storageKeyPath].subscription = newValue.republish(on: instance) | |
} | |
instance[keyPath: storageKeyPath].wrappedValue = newValue | |
} | |
} | |
var wrappedValue: Object | |
private var subscription: AnyCancellable? = nil | |
init(wrappedValue: Object) { | |
self.wrappedValue = wrappedValue | |
} | |
} | |
extension ObservableObject where Self.ObjectWillChangePublisher: ObservableObjectPublisher { | |
func republish<Other: ObservableObject>(on other: Other) -> AnyCancellable where Other.ObjectWillChangePublisher: ObservableObjectPublisher { | |
objectWillChange.sink(receiveValue: { [weak other] in other?.objectWillChange.send() }) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment