How to create a combine pipeline that emits the option tapped - swift

I have a dropdown that on selection shows some options. I want to create a publisher that emits the option that is then tapped.
I have a property indicating the selectedDropdown
#Published var selectedDropdown: DropdownViewModel?
struct DropdownViewModel {
var options: [DropdownOption]
...
}
struct DropdownOption {
var select = PassthroughSubject<Void, Never>()
...
}
Here's where I've got to
var optionTap: AnyPublisher<DropdownOption, Never> {
let selectedDropdown = $selectedDropdown.compactMap { $0 }
let options = selectedDropdown.map { $0.options }
let selectStream = options.map {
$0.map { option in option.select.flatMap { Just(option) } }
}.eraseToAnyPublisher()
return selectStream
}
I want a stream of DropdownOption being emitted when there is a select.send(()) but here I map over a map which isn't going to work - is there an operator I can use here?
Edit:
This is how I did the actual dropdown taps...
var dropdownTaps: AnyPublisher<DropdownViewModel?, Never> {
Publishers.Merge3(
tap(on: dropdown1),
tap(on: dropdown2),
tap(on: dropdown3)
).eraseToAnyPublisher()
}
private func tap(on dropdown: DropdownViewModel) -> AnyPublisher<DropdownViewModel?, Never> {
dropdown.tap.map { dropdown }.eraseToAnyPublisher()
}
dropdownTaps.assign(to: &$selectedDropdown)
I'd like to acheive something similar if possible

Related

Combine - bind a stream into another and handle side effects while doing it

I am trying to learn Combine. I know the terms and the basic concept theoretically. But when trying to work with it, I am lost.
I am trying to do is map an Input stream of events to Output stream of state. Is there a way to bind the result of the map to outputSubject? I am trying to make it work with sink but is there a better way?
Also is there an operator equivalent of RxSwift's withLatestFrom?
import Combine
class LearnCombine {
typealias Input = PassthroughSubject<Event, Never>
typealias Ouput = AnyPublisher<State, Never>
let input: Input
var output: Ouput
private var outputSubject: CurrentValueSubject<State, Never>
private var cancellables = Set<AnyCancellable>()
init() {
self.input = PassthroughSubject()
self.outputSubject = CurrentValueSubject(.initial)
self.output = outputSubject.eraseToAnyPublisher()
transformPipeline()
}
private func transformPipeline() {
input
.map { event in
mapEventToState(event, with: outputSubject.value)
}
.handleOutput { state in
handleSideEffects(for: state) // Also, how do I access the event here if I needed?
}
.sink {
outputSubject.send($0)
}
.store(in: &cancellables)
}
func mapEventToState(_ event: Event, with state: State) -> State {
// Some code that converts `Event` to `State`
}
}
extension Publisher {
func handleOutput(_ receiveOutput: #escaping ((Self.Output) -> Void)) -> Publishers.HandleEvents<Self> {
handleEvents(receiveOutput: receiveOutput)
}
}
Instead of using sink to assign a value to a CurrentValueSubject, I would use assign.
If you want to do something with the values in the middle of a pipeline you can use the handleEvents operator, though if you look in the documentation you'll see that the operator is listed as a debugging operator because generally your pipeline should not have side effects (building it from pure functions is one of the primary benefits.
Just reading the description of withLatestFrom in the RX documentation, I think the equivalent in combine is combineLatest
Here's your code, put into a Playground, and modified a bit to illustrates the first two points:
import Combine
struct Event {
var placeholder: String
}
enum State {
case initial
}
class LearnCombine {
typealias Input = PassthroughSubject<Event, Never>
typealias Ouput = AnyPublisher<State, Never>
let input: Input
var output: Ouput
private var outputSubject: CurrentValueSubject<State, Never>
private var cancellables = Set<AnyCancellable>()
init() {
self.input = PassthroughSubject()
self.outputSubject = CurrentValueSubject(.initial)
self.output = outputSubject.eraseToAnyPublisher()
transformPipeline()
}
private func transformPipeline() {
input
.map { event in
self.mapEventToState(event, with: self.outputSubject.value)
}
.handleEvents(receiveOutput: { value in
debugPrint("Do something with \(value)")
})
.assign(to: \.outputSubject.value, on: self)
.store(in: &cancellables)
}
func mapEventToState(_ event: Event, with state: State) -> State {
return .initial
// Some code that converts `Event` to `State`
}
}
extension Publisher {
func handleOutput(_ receiveOutput: #escaping ((Self.Output) -> Void)) -> Publishers.HandleEvents<Self> {
handleEvents(receiveOutput: receiveOutput)
}
}

"withLatestFrom" won't accept BehaviorRelay

I've got this simple view model to check a phone number's status before registering a user. But I've got this error:
Instance method 'withLatestFrom' requires that 'BehaviorRelay' conform to 'SharedSequenceConvertibleType'
Here's the code:
import Foundation
import RxSwift
import RxCocoa
protocol RegisterViewModelling {
var openRegistrationData: Signal<String> { get }
var showErrorMessage: Signal<String> { get }
var sendButtonActive: Driver<Bool> { get }
var phoneNumberText: BehaviorRelay<String> { get }
var tapSendButton: PublishRelay<Void> { get }
}
final class RegisterViewModel: RegisterViewModelling {
var openRegistrationData: Signal<String>
let showErrorMessage: Signal<String>
let sendButtonActive: Driver<Bool>
let phoneNumberText: BehaviorRelay<String> = BehaviorRelay<String>(value: "")
let tapSendButton: PublishRelay<Void> = PublishRelay<Void>()
init(getPhoneNumberStatus: GetPhoneNumberStatusUseCase) {
sendButtonActive = phoneNumberText
.asDriver(onErrorDriveWith: .empty())
.map(shouldButtonActive(number:))
let isRegistered = tapSendButton
.withLatestFrom(phoneNumberText)
.flatMap(getPhoneNumberStatus.get(number:))
.share()
showErrorMessage = isRegistered
.asSignal(onErrorSignalWith: .just(true))
.filter { $0 == true }
.map { _ in () }
.map(getErrorMessage)
openRegistrationData = isRegistered
.asSignal(onErrorSignalWith: .just(true))
.filter { $0 == false }
.withLatestFrom(phoneNumberText) // ERROR: Instance method 'withLatestFrom' requires that 'BehaviorRelay<String>' conform to 'SharedSequenceConvertibleType'
}
}
private func shouldButtonActive(number: String) -> Bool {
return !number.isEmpty && number.count <= 15
}
private func getErrorMessage() -> String {
return "Phone number has been registered."
}
protocol GetPhoneNumberStatusUseCase {
func get(number: String) -> Observable<Bool>
}
What went wrong here? Why won't withLatestFrom work at that line but it worked fine on the others? Thanks.
I think it because you have convert isRegistered to Signal before use withLatestFrom. You can try to move asSignal() to below withLatestFrom

Using Combine Publisher with AsyncSequence

I'm currently migrating code that was using Combine Publisher to an AsyncSequence. I previously used this alongside #Published search query that user could type in and now trying to "combine" that search term with AsyncSequence based data source such as following (using values to convert the search query to AsyncSequence as well). However, I'm only seeing the flatMap code being executed once initially.
#MainActor
class FantasyPremierLeagueViewModel: ObservableObject {
#Published var playerList = [Player]()
#Published var query: String = ""
private let repository: FantasyPremierLeagueRepository
init(repository: FantasyPremierLeagueRepository) {
self.repository = repository
Task {
let playerStream = asyncStream(for: repository.playerListNative)
let filteredPlayerStream = $query
.debounce(for: 0.5, scheduler: DispatchQueue.main)
.values
.flatMap { query in
playerStream
.map { $0.filter { uery.isEmpty || $0.name.contains(query) } }
}
.map { $0.sorted { $0.points > $1.points } }
for try await data in filteredPlayerStream {
self.playerList = data
}
}
}
}
Code pushed to branch and can also be viewed in https://github.com/joreilly/FantasyPremierLeague/blob/kmp_native_coroutines/ios/FantasyPremierLeague/FantasyPremierLeague/ViewModel.swift
Ok, looks like this can be done in a much cleaner way using combineLatest() from new Swift Async Algorithms package (https://github.com/apple/swift-async-algorithms).
Task {
let playerStream = asyncStream(for: repository.playerListNative)
.map { $0.sorted { $0.points > $1.points } }
for try await (players, searchQuery) in combineLatest(playerStream, $query.values) {
self.playerList = players
.filter { searchQuery.isEmpty || $0.name.localizedCaseInsensitiveContains(query) }
}
}

Finding an item in an array and updating its value

I have a DataModel with the following struct:
struct Task: Codable, Hashable, Identifiable {
var id = UUID()
var title: String
var completed = false
var priority = 2
}
In one my views, I populate a list with buttons based on each element of that DataModel, essentially a Task.
I pass each task for the view like this:
ForEach(taskdata.filteredTasks(byPriority: byPriotity), id: \.id) { task in
TaskView(task: task)
}
On the view, I add each button as follow:
struct TaskView: View {
#EnvironmentObject private var taskdata: DataModel
#State var task: Task
var body: some View {
Button(action: {
task.completed.toggle() }) {
....
}
}
}
Now, when the user clicks on the button, if completed was false, it makes it true. Simple. However, this doesn't update the DataModel, so I added the following to see if I could find the item in question and update it:
struct TaskView: View {
#EnvironmentObject private var taskdata: DataModel
#State var task: Task
#State var id: UUID // added this so I can also pass the ID and try to find it
var body: some View {
Button(action: {
task.completed.toggle()
if task.completed { // if it's completed, find it an update it.
//taskdata.tasks.first(where: { $0.id == id })?.completed = true
//taskdata.tasks.filter {$0.id == id}.first?.completed = true
/*if let index = taskdata.tasks.first(where: {$0.id == id}) {
print(index)
taskdata.tasks ????
}*/
}
}) {
....
}
All the commented code is what I have tried... I get different errors, from Cannot assign to property: function call returns immutable value to cannot assign to property: 'first' is a get-only property and a few other colorful Xcode errors.
How do I find the correct object on my DataModel and correctly update its .completed to true (or false if it's clicked again).
Thank you
You need to use Array >>> firstIndex
if let index = taskdata.tasks.firstIndex(where: {$0.id == id}) {
taskdata.tasks[index].completed = true
}
There is another way for your problem using a function in extension like this custom update function:
extension Array {
mutating func update(where condition: (Element) -> Bool, with newElement: (Element) -> Element) {
if let unwrappedIndex: Int = self.firstIndex(where: { value in condition(value) }) {
self[unwrappedIndex] = newElement(self[unwrappedIndex])
}
}
}
use case:
taskdata.tasks.update(where: { element in element.id == id }, with: { element in
var newElement: Task = element
newElement.completed = true
return newElement
})

How to best create a publisher aggregate of #Published values in Combine?

Given an hierarchical structure of #OberservableObjects - I often find myself in a situation where I need a publisher which provides some kind of updated aggregate of the entire structure (the example below calculates a sum, but it could be anything)
Below is the solution I have come up with - which kinda works, but also not... :)
Problem #1: It looks way to complicated - and I feel I am missing something...
Problem #2: It does not work as the $foo publisher on top does emit changes to foo before foo changes, which are then not present in the second self.$foo publisher (which shows the old state).
Sometimes I need the aggregate in sync with swiftUI view updates - so that I need to utilize the #Published value and no separate publisher that emits during didSet of the variable.
I did not find a good solution... So how would you guys resolve this?
class Foo:ObservableObject {
#Published var bar:Int = 0
}
class Foobar:ObservableObject {
#Published var foo:[Foo] = []
var sumPublisher:AnyPublisher<Int,Never> {
// Whenever the foo array or one of the foo.bar values change
//
$foo
.map { fooArray in
Publishers.MergeMany( fooArray.map { foo in foo.$bar } )
}
.switchToLatest()
// Calclulate a new sum by collecting and reducing all foo.bar values.
//
.map { [unowned self] _ in
self.$foo // <--- in case of a foo change, this is still the unchanged foo, therefore not correct.
.map { fooArray -> AnyPublisher<Int,Never> in
Publishers.MergeMany( fooArray.map { foo in foo.$bar.first() } )
.collect()
.map { barArray -> Int in
barArray.reduce(0, { $0 + $1 })
}
.eraseToAnyPublisher()
}
.switchToLatest()
}
.switchToLatest()
.removeDuplicates()
.eraseToAnyPublisher()
}
}
Problem #2 : #Published fire signals on "willSet" and not "didSet".
You can use this extension :
extension Published.Publisher {
var didSet: AnyPublisher<Value, Never> {
self.receive(on: RunLoop.main).eraseToAnyPublisher()
}
}
and
self.$foo.didSet
.map { _ in
//...//
}
Problem #1 :
Maybe so :
class Foobar:ObservableObject {
#Published var foo:[Foo] = []
#Published var sum = 0
var cancellable: AnyCancellable?
init() {
cancellable =
sumPublisher
.sink {
self.sum = $0
}
}
var sumPublisher: AnyPublisher<Int,Never> {
let firstPublisher = $foo.didSet
.flatMap {array in
array.publisher
.flatMap { $0.$bar.didSet }
.map { _ -> [Foo] in
return self.foo
}
}
.eraseToAnyPublisher()
let secondPublisher = $foo.didSet
.dropFirst(1)
return Publishers.Merge(firstPublisher, secondPublisher)
.map { barArray -> Int in
return barArray
.map {$0.bar}
.reduce(0, { $0 + $1 })
}
.removeDuplicates()
.eraseToAnyPublisher()
}
}
And to test :
struct FooBarView: View {
#StateObject var fooBar = Foobar()
var body: some View {
VStack {
HStack {
Button("Change list") {
fooBar.foo = (1 ... Int.random(in: 5 ... 9)).map { _ in Int.random(in: 1 ... 9) }.map(Foo.init)
}
Text(fooBar.sum.description)
Button("Change element") {
let idx = Int.random(in: 0 ..< fooBar.foo.count)
fooBar.foo[idx].bar = Int.random(in: 1 ... 9)
}
}
List(fooBar.foo, id: \.bar) { foo in
Text(foo.bar.description)
}
.onAppear {
fooBar.foo = [1, 2, 3, 8].map(Foo.init)
}
}
}
}
EDIT :
If you really prefer to use #Published (the willSet publisher), it sends the new value of bar therefore you could deduce the new value of foo (the array) :
var sumPublisher: AnyPublisher<Int, Never> {
let firstPublisher = $foo
.flatMap { array in
array.enumerated().publisher
.flatMap { index, value in
value.$bar
.map { (index, $0) }
}
.map { index, value -> [Foo] in
var newArray = array
newArray[index] = Foo(bar: value)
return newArray
}
}
.eraseToAnyPublisher()
let secondPublisher = $foo
.dropFirst(1)
return Publishers.Merge(firstPublisher, secondPublisher)
.map { barArray -> Int in
barArray
.map { $0.bar }
.reduce(0, { $0 + $1 })
}
.removeDuplicates()
.eraseToAnyPublisher()
}
By far, the easiest approach here is to use a struct instead of a class with #Published:
struct Foo {
var bar: Int = 0
}
Then you can simply create a computed property:
class Foobar: ObservableObject {
#Published var foo: [Foo] = []
var sum: Int {
foo.map(\.bar).reduce(0, +)
}
// ...
}
For SwiftUI views, you wouldn't even need to make it a Publisher - when foo changes, because it's #Published, it will cause the View to access sum again, which would give it the recomputed value.
If you insist on it being a Publisher, it's still easy to do, since foo itself changes when any of its values Foo change (since they are value-type structs):
var sumPublisher: AnyPublisher<Int, Never> {
self.$foo
.map { $0.map(\.bar).reduce(0, +) }
.eraseToAnyPublisher()
}
Sometimes, it's not possible to change a class into a struct for whatever reason (maybe each class has its own life cycle that self-updates). Then you'd need to manually keep track of all the additions/removals of Foo objects in the array (via willSet or didSet), and subscribe to changes in their foo.bar.