Detect change in NSMutableOrderedSet with Swift Combine - swift

I'm trying to observe change of an NSMutableOrderedSet in my ViewModel with combine.
I want to know when some element is added or removed of NSMutableOrderedSet
Some code of my ViewModel :
class TrainingAddExerciceViewModel: ObservableObject {
#Published var exercice: Exercice?
#Published var serieHistories = NSMutableOrderedSet()
...
init(...) {
...
//Where i'm trying to observe
$serieHistories
.sink { (value) in
print(value)
}
.store(in: &self.cancellables)
}
}
This is the function I use in my ViewModel to add element to NSMutableOrderedSet :
func add(managedObjectContext: NSManagedObjectContext) {
let newSerieHistory = ExerciceSerieHistory(context: managedObjectContext)
self.serieHistories.add(newSerieHistory)
self.updateView()
}
I have some other publisher working well with an other type (custom class).
Did I miss something ?

If I correctly understood logic of your code try the following (that init not needed)
variant 1 - add force update
func updateView() {
// ... other code
self.objectWillChange.send()
}
variant 2 - recreate storage
func add(managedObjectContext: NSManagedObjectContext) {
let newSerieHistory = ExerciceSerieHistory(context: managedObjectContext)
let newStorage = NSMutableOrderedSet(orderedSet: self.serieHistories)
newStorage.add(newSerieHistory)
self.serieHistories = newStorage // << fires publisher
self.updateView()
}

Related

Core Data with SwiftUI MVVM Feedback

I am looking for a way to use CoreData Objects using MVVM (ditching #FetchRequest). After experimenting, I have arrived at the following implementation:
Package URL: https://github.com/TimmysApp/DataStruct
Datable.swift:
protocol Datable {
associatedtype Object: NSManagedObject
//MARK: - Mapping
static func map(from object: Object) -> Self
func map(from object: Object) -> Self
//MARK: - Entity
var object: Object {get}
//MARK: - Fetching
static var modelData: ModelData<Self> {get}
//MARK: - Writing
func save()
}
extension Datable {
static var modelData: ModelData<Self> {
return ModelData()
}
func map(from object: Object) -> Self {
return Self.map(from: object)
}
func save() {
_ = object
let viewContext = PersistenceController.shared.container.viewContext
do {
try viewContext.save()
}catch {
print(String(describing: error))
}
}
}
extension Array {
func model<T: Datable>() -> [T] {
return self.map({T.map(from: $0 as! T.Object)})
}
}
ModelData.swift:
class ModelData<T: Datable>: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
var publishedData = CurrentValueSubject<[T], Never>([])
private let fetchController: NSFetchedResultsController<NSFetchRequestResult>
override init() {
let fetchRequest = T.Object.fetchRequest()
fetchRequest.sortDescriptors = []
fetchController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: PersistenceController.shared.container.viewContext, sectionNameKeyPath: nil, cacheName: nil)
super.init()
fetchController.delegate = self
do {
try fetchController.performFetch()
publishedData.value = (fetchController.fetchedObjects as? [T.Object] ?? []).model()
}catch {
print(String(describing: error))
}
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
guard let data = controller.fetchedObjects as? [T.Object] else {return}
self.publishedData.value = data.model()
}
}
Attempt.swift:
struct Attempt: Identifiable, Hashable {
var id: UUID?
var password: String
var timestamp: Date
var image: Data?
}
//MARK: - Datable
extension Attempt: Datable {
var object: AttemptData {
let viewContext = PersistenceController.shared.container.viewContext
let newAttemptData = AttemptData(context: viewContext)
newAttemptData.password = password
newAttemptData.timestamp = timestamp
newAttemptData.image = image
return newAttemptData
}
static func map(from object: AttemptData) -> Attempt {
return Attempt(id: object.aid ?? UUID(), password: object.password ?? "", timestamp: object.timestamp ?? Date(), image: object.image)
}
}
ViewModel.swift:
class HomeViewModel: BaseViewModel {
#Published var attempts = [Attempt]()
required init() {
super.init()
Attempt.modelData.publishedData.eraseToAnyPublisher()
.sink { [weak self] attempts in
self?.attempts = attempts
}.store(in: &cancellables)
}
}
So far this is working like a charm, however I wanted to check if this is the best way to do it, and improve it if possible. Please note that I have been using #FetchRequest with SwiftUI for over a year now and decided to move to MVVM since I am using it in all my Storyboard projects.
For a cutting edge way to wrap the NSFetchedResultsController in SwiftUI compatible code you might want to take a look at AsyncStream.
However, #FetchRequest currently is implemented as a DynamicProperty so if you did that too it would allow access the managed object context from the #Environment in the update func which is called on the DynamicProperty before body is called on the View. You can use an #StateObject internally as the FRC delegate.
Be careful with MVVM because it uses objects where as SwiftUI is designed to work with value types to eliminate the kinds of consistency bugs you can get with objects. See the doc Choosing Between Structures and Classes. If you build an MVVM object layer on top of SwiftUI you risk reintroducing those bugs. You're better off using the View data struct as it's designed and leave MVVM for when coding legacy view controllers. But to be perfectly honest, if you learn the child view controller pattern and understand the responder chain then there is really no need for MVVM view model objects at all.
And FYI, when using Combine's ObservableObject we don't sink the pipeline or use cancellables. Instead, assign the output of the pipeline to an #Published. However, if you aren't using CombineLatest, then perhaps reconsider if you should really be using Combine at all.

How to pass a parent ViewModel #Published property to a child ViewModel #Binding using MVVM

I'm using an approach similar to the one described on mockacoding - Dependency Injection in SwiftUI where my main ViewModel has the responsibility to create child viewModels.
In the code below I am not including the Factory, as it's very similar to the contents of the post above: it creates the ParentViewModel, passes to it dependencies and closures that construct the child view models.
struct Book { ... } // It's a struct, not a class
struct ParentView: View {
#StateObject var viewModel: ParentViewModel
var body: some View {
VStack {
if viewModel.book.bookmarked {
BookmarkedView(viewModel: viewModel.makeBookMarkedViewModel())
} else {
RegularView(viewModel: viewModel.makeBookMarkedViewModel())
}
}
}
}
class ParentViewModel: ObservableObject {
#Published var book: Book
// THIS HERE - This is how I am passing the #Published to #Binding
// Problem is I don't know if this is correct.
//
// Before, I was not using #Binding at all. All where #Published
// and I just pass the reference. But doing that would cause for
// the UI to NEVER update. That's why I changed it to use #Binding
private var boundBook: Binding<Book> {
Binding(get: { self.book }, set: { self.book = $0 })
}
// The Factory object passes down these closures
private let createBookmarkedVM: (_ book: Binding<Book>) -> BookmarkedViewModel
private let createRegularVM: (_ book: Binding<Book>) -> RegularViewModel
init(...) {...}
func makeBookmarkedViewModel() {
createBookmarkedVM(boundBook)
}
}
class BookmarkedView: View {
#StateObject var viewModel: BookmarkedViewModel
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Text(book.title) // <---- THIS IS THE PROBLEM. Not being updated
Button("Remove bookmark") {
viewModel.removeBookmark()
}
}
.onReceive(timer) { _ in
print("adding letter") // <-- this gets called
withAnimation {
viewModel.addLetterToBookTitle()
}
}
}
}
class BookmarkedViewModel: ObservableObject {
#Binding var book: Book
// ... some other dependencies passed by the Factory object
init(...) { ... }
public func removeBookmark() {
// I know a class would be better than a struct, bear with me
book = Book(title: book.title, bookmarked: false)
}
/// Adds an "a" to the title
public func addLetterToBookTitle() {
book = Book(title: book.title + "a", bookmarked: book.bookmarked)
print("letter added") // <-- this gets called as well
}
}
From the code above, let's take a look at BookmarkedView. If I click the button and viewModel.removeBookmark() gets called, the struct is re-assigned and ParentView now renders RegularView.
This tells me that I successfully bound #Published book: Book from ParentViewModel to #Binding book: Book from BookmarkedViewModel, through its boundBook computed property. This felt like the most weird thing I had to make.
However, the problem is that even though addLetterToBookTitle() is also re-assigning the book with a new title, and it should update the Text(book.title), it's not happening. The same title is being displayed.
I can guarantee that the book title has change (because of some other components of the app I'm omitting for simplicity), but the title's visual is not being updated.
This is the first time I'm trying out these pattern of having a view model build child view models, so I appreciate I may be missing something fundamental. What am I missing?
EDIT:
I made an MVP example here: https://github.com/christopher-francisco/TestMVVM/tree/main/MVVMTest.xcodeproj
I'm looking for whether:
My take at child viewmodels is fundamentally wrong and I should start from scratch, or
I have misunderstood #Binding and #Published attributes, or
Anything really
Like I said initially #Binding does not work in a class you have to use .sink to see the changes to an ObservableObject.
See below...
class MainViewModel: ObservableObject {
#Published var timer = YourTimer()
let store: Store
let nManager: NotificationManager
let wManager: WatchConnectionManager
private let makeNotYetStartedViewModelClosure: (_ parentVM: MainViewModel) -> NotYetStartedViewModel
private let makeStartedViewModelClosure: (_ parentVM: MainViewModel) -> StartedViewModel
init(
store: Store,
nManager: NotificationManager,
wManager: WatchConnectionManager,
makeNotYetStartedViewModel: #escaping (_ patentVM: MainViewModel) -> NotYetStartedViewModel,
makeStartedViewModel: #escaping (_ patentVM: MainViewModel) -> StartedViewModel
) {
self.store = store
self.nManager = nManager
self.wManager = wManager
self.makeNotYetStartedViewModelClosure = makeNotYetStartedViewModel
self.makeStartedViewModelClosure = makeStartedViewModel
}
}
// MARK: - child View Models
extension MainViewModel {
func makeNotYetStartedViewModel() -> NotYetStartedViewModel {
self.makeNotYetStartedViewModelClosure(self)
}
func makeStartedViewModel() -> StartedViewModel {
self.makeStartedViewModelClosure(self)
}
}
class NotYetStartedViewModel: ObservableObject {
var parentVM: MainViewModel
var timer: YourTimer{
get{
parentVM.timer
}
set{
parentVM.timer = newValue
}
}
var cancellable: AnyCancellable? = nil
init(parentVM: MainViewModel) {
self.parentVM = parentVM
//Subscribe to the parent
cancellable = parentVM.objectWillChange.sink(receiveValue: { [self] _ in
//Trigger reload
objectWillChange.send()
})
}
func start() {
// I'll make this into a class later on
timer = YourTimer(remainingSeconds: timer.remainingSeconds, started: true)
}
}
class StartedViewModel: ObservableObject {
var parentVM: MainViewModel
var timer: YourTimer{
get{
parentVM.timer
}
set{
parentVM.timer = newValue
}
}
var cancellable: AnyCancellable? = nil
init(parentVM: MainViewModel) {
self.parentVM = parentVM
cancellable = parentVM.objectWillChange.sink(receiveValue: { [self] _ in
//trigger reload
objectWillChange.send()
})
}
func tick() {
// I'll make this into a class later on
timer = YourTimer(remainingSeconds: timer.remainingSeconds - 1, started: timer.started)
}
func cancel() {
timer = YourTimer()
}
}
But this is an overcomplicated setup, stick to class or struct. Also, maintain a single source of truth. That is basically the center of how SwiftUI works everything should be getting its value from a single source.

Convert computed property for #State for Swiftui Views

I'm new with SwiftUI and i want to convert basically my computed property for being used in SwiftUI views with combine and all that. I couldn’t use it like that because "get set" doesn't work for my SwiftUI views and I kinda struggled here.
Maybe someone has a good solution how can i convert it with with combine to use in swift ui.
Storage service saves the Authdata into userdefaults.
var currentAuthData: AuthData? {
get {
return self.storageService.get(AuthData.self, forKey: authDataStorageKey)
}
set {
if let value = newValue {
self.storageService.store(value, forKey: authDataStorageKey)
}
}
}
This is how you would turn a computed property in SwiftUI, by making it a #propertyWrapper. I have added a solution to read the data using Combine if you need it. I also make you property optional.
I assume this is the model you want to save.
struct AuthData {
var name: String
var email: String
}
Prepare the protocol for your property wrapper to be able to be set to nil using Optional.
public protocol AnyOptional {
var isNil: Bool { get }
}
extension Optional: AnyOptional {
public var isNil: Bool { self == nil }
}
Extend UserDefaults to conform to this protocol and be optional.
extension UserDefault where Value: ExpressibleByNilLiteral {
init(key: String, _ container: UserDefaults = .standard) {
self.init(key: key, defaultValue: nil, container: container)
}
}
Create the property wrapper that is the same thing as a getter and setter for SwiftUI views. This is a generic one and can be use to any type. You can set them in the UserDefaults extension after this bloc of code.
import Combine
#propertyWrapper
struct UserDefault<Value> {
let key: String
let defaultValue: Value
var container: UserDefaults = .standard
// Set a Combine publisher for your new value to
// always read its changes when using Combine.
private let publisher = PassthroughSubject<Value, Never>()
var wrappedValue: Value {
get {
// Get the new value or nil if any.
container.object(forKey: key) as? Value ?? defaultValue
}
set {
// Check if the value is nil and remove it from your object.
if let optional = newValue as? AnyOptional, optional.isNil {
container.removeObject(forKey: key)
}
else {
// Set your new value inside UserDefaults.
container.set(newValue, forKey: key)
}
// Add the newValue to your combine publisher
publisher.send(newValue)
}
}
var projectedValue: AnyPublisher<Value, Never> {
publisher.eraseToAnyPublisher()
}
}
Create the extension in UserDefaults to use your property wrapper in your code.
extension UserDefaults {
#UserDefault(key: "authDataStorageKey", defaultValue: nil)
static var savedAuthData: AuthData?
// You can create as many #propertyWrapper as you want that fits your need
}
examples of use
// Set a new value
UserDefaults.savedAuthData = AuthData(name: "Muli", email: "muli#stackoverflow.com")
// Read the saved value
print(UserDefaults.savedAuthData as Any)
// When using combine
var subscriptions = Set<AnyCancellable>()
UserDefaults.$savedAuthData
.sink { savedValue in
print(savedValue as Any) } // Yours saved value that changes over time.
.store(in: &subscriptions)

didSet not called by Array.append() in Swift

I am following the 100 Days of SwiftUI and have reached Day 37. While doing Making changes permanent with UserDefaults, I encounter a problem with didSet.
(I am using Swift 5 with iOS 13.4)
In the example code, it writes
.navigationBarItems(trailing: Button("Save") {
if let actualAmount = Int(self.amount) {
let item = ExpenseItem(name: self.name, type: self.type, amount: actualAmount)
self.expenses.items.append(item)
}
})
where didSet should be called by .append().
However, in practice, the didSet is not called unless I change the above code to
.navigationBarItems(trailing: Button("Save") {
if let actualAmount = Int(self.amount) {
let item = ExpenseItem(name: self.name, type: self.type, amount: actualAmount)
let newItems = self.expenses.items + [item]
self.expenses.items = newItems
}
})
I also write a small test (see below) in Playground which shows that .append() works pretty well with didSet
struct Count {
var array: [Int] {
didSet {
print("struct Count - didSet() called")
}
}
}
class CountClass {
var array: [Int] {
didSet {
print("class CountClass - didSet() called")
}
}
init() {
array = [1, 2, 3]
}
}
struct Test {
var countA = Count(array: [1, 2, 3])
var countB = CountClass()
mutating func testDidSet() {
countA.array.append(4)
countB.array.append(4)
}
}
var t = Test()
t.testDidSet()
This strange behaviour really makes me wonder how didSet works. Or is this problem related to the use of #ObservedObject (which is the case of the example project)?
PS: I have downloaded the finished version from Project7 and it also has the problem.
This is a known Swift 5.2 bug: observers of wrapped properties are not called upon modification (https://bugs.swift.org/browse/SR-12089). They had known about this since January and all the same released an update breaking a bunch of production code ¯\_(ツ)_/¯
A temporary workaround is already presented in the question - property reassignment instead of modification.
I went through this project as well. Make sure your items property in the Expenses class is marked as #Published. As below;
import SwiftUI
struct ExpenseItem: Identifiable, Codable {
let id = UUID()
let name: String
let type: String
let amount: Int
}
class Expenses: ObservableObject {
init() {
if let items = UserDefaults.standard.data(forKey: "Items") {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode([ExpenseItem].self, from: items) {
self.items = decoded
return
}
}
self.items = []
}
#Published var items: [ExpenseItem] {
didSet {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(items) {
UserDefaults.standard.set(encoded, forKey: "Items")
}
}
}
}
and add self.presentationMode.wrappedValue.dismiss() in your navigation bar Save button.
.navigationBarItems(trailing: Button("Save") {
if let actualAmount = Int(self.amount) {
let item = ExpenseItem(name: self.name, type: self.type, amount: actualAmount)
self.expenses.items.append(item)
self.presentationMode.wrappedValue.dismiss()
} else {
self.isShowingAlert = true
}
})
I finished this project last week I think, but checking the Swift GitHub page it seems there was an update on the 24th.
When I did this project everything worked fine, but now I'm on day 47 and I'm having this very problem. Maybe it's related to the Swift 5.2 update.
What's happening is that Swift is not recognizing the .append() as setting the variable. You can get around this by copying your array to a new temporary array, append the new value and then set your class array to this temporary array.
var newActivityList = [Activity]() //new temporary array
for activity in self.activityList.activities {
newActivityList.append(activity) //copy class array to temp array
}
newActivityList.append(newActivity) //append new value
self.activityList.activities = newActivityList //set class array to temp array
PS: the example above is for day 47, but the logic is the same.

Can you use a Publisher directly as an #ObjectBinding property in SwiftUI?

In SwiftUI, can you use an instance of a Publisher directly as an #ObjectBinding property or do you have to wrap it in a class that implements BindableObject?
let subject = PassthroughSubject<Void, Never>()
let view = ContentView(data:subject)
struct ContentView : View {
#ObjectBinding var data:AnyPublisher<Void, Never>
}
// When I want to refresh the view, I can just call:
subject.send(())
This doesn't compile for me and just hangs Xcode 11 Beta 2. But should you even be allowed to do this?
In your View body use .onReceive passing in the publisher like the example below, taken from Data Flow Through SwiftUI - WWDC 2019 # 21:23. Inside the closure you update an #State var, which in turn is referenced somewhere else in the body which causes body to be called when it is changed.
You can implement a BindableObject wich takes a publisher as initializer parameter.
And extend Publisher with a convenience function to create this BindableObject.
class BindableObjectPublisher<PublisherType: Publisher>: BindableObject where PublisherType.Failure == Never {
typealias Data = PublisherType.Output
var didChange: PublisherType
var data: Data?
init(didChange: PublisherType) {
self.didChange = didChange
_ = didChange.sink { (value) in
self.data = value
}
}
}
extension Publisher where Failure == Never {
func bindableObject() -> BindableObjectPublisher<Self> {
return BindableObjectPublisher(didChange: self)
}
}
struct ContentView : View {
#ObjectBinding var binding = Publishers.Just("test").bindableObject()
var body: some View {
Text(binding.data ?? "Empty")
}
}