Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Combine @Published property: get current value during update, from elsewhere

Tags:

ios

swift

combine

My main problem is that I'm trying to work around the (undocumented) fact that @Published properties don't update the property's value until after subscribers have been notified of the change. I can't seem to get a good way around it.

Consider the following contrived combination of a Subject and @Published properties. First, a simple class:

class StringPager {
    @Published var page = 1
    @Published var string = ""
}
let pager = StringPager()

And then a simple passthrough subject:

let stringSubject = PassthroughSubject<String, Never>()

For debugging, let's subscribe to the string property and print it out:

pager.$string.sink { print($0) }

So far so good. Next, let's subscribe to the subject and alter the pager based on its value:

stringSubject.sink { string in
  if pager.page == 1 {
    pager.string = string
  } else {
    pager.string = string.uppercased()
  }
}

Hopefully, this logic will allow us to make the pager string uppercased whenever we're not on the first page.

Now let's send values through the stringSubject when the page gets updated:

pager.$page.sink { 
  $0 == 1 ? stringSubject.send("lowercase") : stringSubject.send("uppercase") 
}

If we've gotten this logic right, then lowercase will always be lowercased, while uppercase will always be uppercased. Unfortunately, that's not at all what happens. Here's a sample output:

pager.page = 1 // lowercase
pager.page = 2 // uppercase
pager.page = 3 // UPPERCASE
pager.page = 4 // UPPERCASE
pager.page = 1 // LOWERCASE
pager.page = 1 // lowercase

The reason for this is when we subscribe to the subject, we check the value of pager.page... but updating pager.page is what triggers the subject's closure, so the pager.page doesn't have an updated value yet, so the subject executes the wrong branch.

I've tried fixing this by both ziping the pager.$page with the subject before sinking:

stringSubject.zip(pager.$page).eraseToAnyPublisher().sink { ...same code... }

as well as combineLatesting it:

stringSubject.combineLatest(pager.$page).eraseToAnyPublisher().sink { ...same code... }

but that leads either to the exact same observed behavior (in the former case) or equally undesired behavior except more of it (in the latter case).

How can I get the current page within the subject sink closure?

like image 410
Elliot Schrock Avatar asked Dec 09 '19 04:12

Elliot Schrock


2 Answers

The problem is that you're not actually using the power of the Combine framework. You should not be using a sink closure to look inside any value. That subverts the whole point of Combine! Instead, you should let that value flow into your sink as part of the Combine pipeline that you construct.

Let's start with your StringPager:

class StringPager {
    @Published var page = 1
    @Published var string = "this is a test"
}

And let's have a view controller class that has a StringPager, along with some test buttons that set its string or its page number:

class ViewController: UIViewController {
    let pager = StringPager()
    var storage = Set<AnyCancellable>()
    override func viewDidLoad() {
        // ...
    }
    @IBAction func doButton(_ sender: Any) {
        let i = Int.random(in: 1...4)
        print("setting page to \(i)")
        self.pager.page = i
    }
    @IBAction func doButton2(_ sender: Any) {
        let s = ["Manny", "Moe", "Jack"].randomElement()!
        print("setting string to \(s)")
        self.pager.string = s
    }
}

You see what the idea of this test bed is. We click the first button or the second button, and we set the pager's page or string, randomly, to a new value, reporting in the console what it is.

Our goal now is create a Combine pipeline that will spit out an uppercase or a non-uppercase version of the pager's string, depending on whether the pager's page is 1 or not, every time the string or the page changes. I'll do that in viewDidLoad. The Combine operator that watches for any of multiple publishers to change and reports their current values when either of them changes is combineLatest, so that's what I'll use:

    pager.$page.combineLatest(pager.$string)
        .map {p,s in p > 1 ? s.uppercased() : s}
        .sink {print($0)}.store(in:&storage)

That's it! Now I'll tap the buttons a few times and let's see what we get:

this is a test
setting page to 3
THIS IS A TEST
setting page to 3
THIS IS A TEST
setting page to 1
this is a test
setting string to Jack
Jack
setting string to Manny
Manny
setting page to 1
Manny
setting page to 1
Manny
setting page to 4
MANNY
setting string to Manny
MANNY
setting string to Moe
MOE
setting string to Jack
JACK

As you can see, every time we change the page or the string, we get a new value out the end of the pipeline, and it is the right value! If the page is greater than 1, we get an uppercase version of the string; if the page is 1, we get the string as is. Mission accomplished!

like image 75
matt Avatar answered Oct 19 '22 18:10

matt


What I understand from your intention is, I think you want to control lowercase & UPPERCASE with respect to the page number. But you are taking your logic to an extent what is not the intended job of the Combine framework. As one of the comments in your question by @user1046037 mentions:

Combine is not about mutation. Instead you should be using it for transforming your values over time.

So it should not be the page subscriber which triggers the value mutation of the string publisher. Instead you will deliberately change the value of string. Then you can transform the value to your desired logic bound to the page. And these logic should go into the object itself. Let's see what I meant:

class StringPager {
    @Published var page = 0
    @Published var string = "lorem ipsum"

    private var cancellableBag = Set<AnyCancellable>()

    init() {

        let publisher = $page
            .map { [unowned self] in
                return $0 == 1 ? self.string.lowercased() : self.string.uppercased()
        }

        publisher
            .eraseToAnyPublisher()
            .assign(to: \.string, on: self)
            .store(in: &cancellableBag) // must store the subscriber to get the events
    }
}

Then when you change your page value, you will get the expected cased version of the string value that the string at that time will hold.

let pager = StringPager()
pager.$string.sink { print($0) }
pager.page = 1 // lorem ipsum
pager.page = 2 // LOREM IPSUM
pager.page = 3 // LOREM IPSUM
pager.page = 4 // LOREM IPSUM
pager.page = 1 // lorem ipsum
pager.page = 1 // lorem ipsum

When you need to update your string to any value other than the previous set value, it will be independent from the page transformation. What does this mean?

pager.string = "new value" // new value

Until you deliberately set your page again:

pager.page = 3 // NEW VALUE
like image 1
nayem Avatar answered Oct 19 '22 19:10

nayem