square / Cleanse

Lightweight Swift Dependency Injection Framework
Other
1.78k stars 90 forks source link

Trying to imitate the @BindsOptionalOf behaviour from Dagger 2 #160

Closed razvanred closed 3 years ago

razvanred commented 4 years ago

Hi, I'm new to Cleanse, I'm trying to imitate the behaviour of the @BindsOptionalOf annotation from Dagger 2.

More specifically, I'm trying to make a Component's Seed object available to its RootComponent.

If you're familiar with the dagger.dev ATM tutorial, at the last step the Account object bind in the UserCommandsRouter subcomponent is injected into the LoginCommand when available, through the @BindsOptionalOf annotation.

UserCommandsRouter.java subcomponent:

@Subcomponent(modules = ...)
@PerSession
public interface UserCommandsRouter {

    CommandRouter router();

    @Subcomponent.Factory
    interface Factory {
        UserCommandsRouter create(@BindsInstance Database.Account account);
    }

    @Module(subcomponents = UserCommandsRouter.class)
    interface InstallationModule { // Included in the RootComponent
    }

}

LoginModule.java (included in the RootComponent):

@Module
public interface LoginCommandModule {
  ...

  @BindsOptionalOf
  Account optionalAccount();

}

And this is how I tried to imitate the UserCommandsRouter class in Swift:

struct UserCommandsRouter: Component {

    typealias Seed = Account
    typealias Scope = PerSession
    typealias Root = CommandRouter

    static func configure(binder: Binder<Scope>) {
        ...
    }

    static func configureRoot(binder bind: ReceiptBinder<Root>) -> BindingReceipt<Root> {
        bind.to(factory: CommandRouter.init)
    }
}

LoginCommandModule.swift:

struct LoginCommandModule : Module { // Included in the RootComponent

    static func configure(binder: Binder<Unscoped>) {
        binder
            .bind(Command.self)
            .intoCollection()
            .to(factory: LoginCommand.init)

       // Here I don't know how to use the binder in order to bind the optional Account from the UserCommandsRouter subcomponent
    }
}
sebastianv1 commented 4 years ago

@razvanred99 Thanks for the issue! I see two distinct issues here so I'm gonna break this up into two parts.

  1. Regarding@BindsOptionalOf, I think adding an optional dependency requirement could be a helpful feature for Cleanse. Off the top of my head, my thinking is that it would take the form of OptionalProvider<E> so that it doesn't collide with Swift's Optional type. I can draft an RFC and work with you to make sure the new feature meets your needs.

  2. I'd like to learn more about how a subcomponent seed object (that isn't already bound into the graph) would be available to its parent. I'm confused how this would work because a Seed object can be unique to the subcomponent, and there could be multiple subcomponents alive at the same time. If there are multiple subcomponents, how would the parent know which Account object to use?

Using Account as an example probably isn't helpful since there's usually just 1 account at a time, but take for example an eBook reader application that supports multitasking by reading two different books at the same time. Let's say we create a Subcomponent for displaying the reader screen when we tap on a book.

struct BookReaderComponent: Component {
    typealias Root = ReaderViewController
    typealias Seed = Book
    // ...

When we want to display the two books side-by-side, we would call say:

func display(book1, book2) {
    let reader1 = bookReaderComponent.build(book1)
    let reader2 = bookReaderComponent.build(book2)
    showViewControllers([reader1, reader2])
}

So in the parent graph, what would Book be? The instance of book1 or book2? Maybe it doesn't matter and the intended purpose is just a way to see if any subcomponent is alive?

holmes commented 4 years ago

@razvanred99 even in Dagger there's not a direct way to provide a Subcomponent's seed value to a parent graph. I didn't quite follow what was happening in the dagger.dev tutorial but you can do something like this (sorry, my Swift might be a bit rusty):

In your RootComponent, create a class that acts as a holder: AccountStatusHolder. That class can have a nullable account on it Account? Then since the Subcomponent is a child of the RootComponent it can inject AccountStatusHolder and set a value on it.

RootComponent

struct Account {
    let email: String
}

class AccountStatusHolder {
    var account: Account?
}

Subcomponent

class LoggedInHandler {
    let accountStatusHolder: AccountStatusHolder

    init(accountStatusHolder: AccountStatusHolder) {
        self.accountStatusHolder = accountStatusHolder
    }

    func login(account: Account) {
        accountStatusHolder.account = account
    }
}

Let me know if that makes sense

sebastianv1 commented 3 years ago

@razvanred99 We currently do not have any plans to implement an optional binding provider. To achieve the same effect today, you could create a swift optional binding of your type and assign it to nil.

binder
    .bind(Database?.self)
    .to(value: nil)

PRs welcome if you'd like to help add this feature!