realm / realm-swift

Realm is a mobile database: a replacement for Core Data & SQLite
https://realm.io
Apache License 2.0
16.25k stars 2.14k forks source link

Support Combine framework #6161

Closed tgoyne closed 4 years ago

tgoyne commented 5 years ago

https://developer.apple.com/documentation/combine

Gujci commented 5 years ago

To support combine, right now I'm using property wrappers. The following snippet is not a general solution, just something I think worth sharing here.

@propertyWrapper
public class RealmCollectionProxy<Source: Object> {
    // the realm here can be injected or shared globally

    // Query is a custom enum in my project to type possible queryes
    public var query: Query? {
        didSet {
            results = realm.objects(Source.self).filter(query?.realmQuery)
        }
    }

    private var results: Results<Source> {
        didSet {
            notificationToken?.invalidate()
            notificationToken = results.observe({ [weak self] _ in
                guard let welf = self else { return }
                welf.subject.send(welf.wrappedValue)
            })
        }
    }

    public typealias Output = Results<Source>
    public typealias Failure = Never

    private let subject = PassthroughSubject<Results<Source>, Never>()

    private var notificationToken: NotificationToken?

    public init(_ query: Query? = nil) {
        self.query = query
        results = realm.objects(Source.self).filter(query?.realmQuery)

        notificationToken = results.observe({ [weak self] _ in
            guard let welf = self else { return }
            welf.subject.send(welf.wrappedValue)
        })
    }

    public var wrappedValue: Results<Source> { results }

    public var projectedValue: AnyPublisher<Results<Source>, Never> { self.subject.eraseToAnyPublisher() }

    public func receive<S>(subscriber: S) where S : Subscriber, RealmCollectionProxy.Failure == S.Failure, RealmCollectionProxy.Output == S.Input {
        projectedValue.receive(subscriber: subscriber)
    }

    deinit {
        notificationToken?.invalidate()
    }
}

With this, the example solution is like the following.

final class AlbumsViewModel: ObservableObject {

    @RealmCollectionProxy<Album>() var albums

    var objectWillChange: ObservableObjectPublisher = ObservableObjectPublisher()

    private var subscriptions: Set<AnyCancellable> = []

    init() {
        $albums.receive(on: RunLoop.main).sink { [weak self] _ in self?.objectWillChange.send() }.store(in: &subscriptions)
    }

    deinit {
        subscriptions.forEach { $0.cancel() }
    }
}

I'm still looking forward to implement something like @Published does, which would eliminate 90% percent of this code, leaving my class really clean.

jsflax commented 5 years ago

Hi,

We will be adding support for Combine/SwiftUI next week. I'll inform the thread once the feature is merged with the master branch.

Gujci commented 5 years ago

@jsflax Can't wait!

ismyhc commented 4 years ago

@jsflax Just curious how far along the SwiftUI support is? Will there be or are there any good examples of how one would utilize Realm when building an app with SwiftUI? My current app uses Realm, and I've used realm for quite sometime. I've started a fresh rewrite with SwiftUI and started CoreData, but am really not feeling it. :) Id like to stick with Realm !

jsflax commented 4 years ago

Hey @ismyhc . The PR is here and in review: https://github.com/realm/realm-cocoa/pull/6231. An example is included as well if you'd like to take a look.

marchy commented 4 years ago

Question related to this: is there any ongoing effort to bring support for property wrappers as a whole to Realm? ie: instead of the current @objc dynamic var way of defining things, to have something more elegant and similar to @Gujci's example the above?

tgoyne commented 4 years ago

I put together a prototype of using property wrappers for model definitions a while back and we're planning to spend some time this quarter trying to finish it.

Gujci commented 4 years ago

@tgoyne would any contributing help, or you have already made your tools?

marchy commented 4 years ago

Thanks for the prompt update @tgoyne. And incredible timing on the 5.0 release! 🎉

Excited to get adopting this - this officially unblocks us on moving forward with a Combine re-arch in a major way =)

marchy commented 4 years ago

Hi @tgoyne could we get a documentation update on this? The docs don't mention anything about it and it's hard to find the exact way that this is supposed to work (ie: how do you observe a single property rather than a collection?)

Hbrinj commented 4 years ago

Any luck @marchy ?

martinstoyanov commented 4 years ago

+1 for docs.

ianpward commented 4 years ago

We will have a blog post this week - if you'd like to see a working app example see here: https://github.com/realm/realm-cocoa/tree/master/examples/ios/swift/ListSwiftUI

ptliddle commented 4 years ago

@ianpward I'm currently working on a SwiftUI project making use of Realm. Did that blog get posted? Could you provide a link?

ianpward commented 4 years ago

@ptliddle It sure did - https://developer.mongodb.com/article/realm-cocoa-swiftui-combine

traviskaufman commented 3 years ago

Any way to use combine and still write to realm without notifying that publisher?

jsflax commented 3 years ago

@traviskaufman Depends on how you are trying to use it. Could you give us a sample? There are ways to use both Realm and/or Combine to filter certain results.

traviskaufman commented 3 years ago

Yeah for sure! Thanks so much for getting back to me 😄

So my primary use-case is that I want to be able to perform ui-driven updates using Combine API.

So for example, say I have this example table view controller

import UIKit
import Combine
import RealmSwift

class ExampleTableViewController: UITableViewController {
  private let realm = try! Realm()
  private var items: Results<Item>!
  private var c1: AnyCancellable? = nil

  override func viewDidLoad() {
    super.viewDidLoad()
    items = realm.objects(Item.self).filter("complete = 0")
    c1 = items
      .collectionPublisher
      .subscribe(on: DispatchQueue(label: "background queue"))
      .freeze()
      .receive(on: DispatchQueue.main)
      .assertNoFailure()
      .sink {[weak self] _ in
        self?.tableView.reloadData()
      }
  }
}

The items collection powers all of the UITableViewDataSource methods, etc.

Now say I want to add a "swipe right to complete" option:

override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
    let complete = UIContextualAction(style: .normal, title: "Complete") {_, _, completionHandler in
      let itemId = self.items[indexPath.row].id
      let realm = try! Realm()
      guard let item = realm.object(ofType: Item.self, forPrimaryKey: itemId) else {
        return
      }
      // How do I access a token that I can use via commitWrite() without notifying the combine publisher
      try! realm.write {
        item.complete = true
      }
      self.tableView.deleteRows(at: [indexPath], with: .automatic)
      completionHandler(true)
    }
    complete.backgroundColor = .systemGreen
    complete.image = UIImage(systemName: "checkmark")

    let config = UISwipeActionsConfiguration(actions: [complete])
    config.performsFirstActionWithFullSwipe = true
    return config
  }

So in this case, I try to complete the item using a UI-driven update, but the logic within sink will cause the animation to be interrupted by a data reload on the table view. The only workaround I've found for this is to introduce an additional variable e.g. isUiUpdate and use that within my sink callback to return early without doing a reload, however that seems a bit inelegant.

Let me know if any of that is unclear and thanks for your help!

marchy commented 3 years ago

Hey @traviskaufman, just to chime in at a high level you should not use a full reload .reloadData() on table-view updates.

Look up diffable data sources that were introduced in iOS 13 (link), which is the de facto way any such updates should be handled. These have the benefit of automatically animating item removals, so your animations should work out right.

If you Realm collection is set up to filter out items that are not completed, the Combine publisher will fire, your snapshot diff will detect the exact row that was removed, and your list will update perfectly (ie: in a reactive fashion, which is presumably why you are using Combine in the first place).

NOTE: You could also do this using the old, manual UITableView beginUpdates()/endUpdates() API, but in short this is basically deprecated and should not manage this yourself.

A second note, if your table view has different sections with multiple data sources each – the updates to diffable data sources in iOS 14 are needed to properly manage those (the V1 of the API didn't have proper support for section diffs)

tgoyne commented 3 years ago

@traviskaufman I've created a new feature request for that. I don't see any straightforward way to do it currently.

mohitnandwani commented 3 years ago

Hey @traviskaufman, just to chime in at a high level you should not use a full reload .reloadData() on table-view updates.

Look up diffable data sources that were introduced in iOS 13 (link), which is the de facto way any such updates should be handled. These have the benefit of automatically animating item removals, so your animations should work out right.

If you Realm collection is set up to filter out items that are not completed, the Combine publisher will fire, your snapshot diff will detect the exact row that was removed, and your list will update perfectly (ie: in a reactive fashion, which is presumably why you are using Combine in the first place).

NOTE: You could also do this using the old, manual UITableView beginUpdates()/endUpdates() API, but in short this is basically deprecated and should not manage this yourself.

A second note, if your table view has different sections with multiple data sources each – the updates to diffable data sources in iOS 14 are needed to properly manage those (the V1 of the API didn't have proper support for section diffs)

Hello @marchy I'm new to realm and kinda having tough time to update UI with diffableDataSource and realm. Can you please explain a bit more about updating snapshot when there is a change in realm objects? (specially when inserting new items). Thanks!