Home > Software engineering >  Is there a way to pass @Published object as a func argument UIKit?
Is there a way to pass @Published object as a func argument UIKit?

Time:10-23

I'm using UIKit not SwiftUI. I found solutions which all in SwiftUI but not solved my problem.

I have a @Published object like: @Published var searchText = ""

I'm using that @Published object for search functionality like in following function. Also, I'm trying to reach that function outside of corresponding class which is final class MainViewModel

final class MainViewModel {
 @Published var searchText = ""

 //subscribes to the searchText 'Publisher'
 func searchTextManipulation(searchText: String) {
  $searchText
   .debounce(for: .seconds(1.0), scheduler: RunLoop.main)
   .removeDuplicates()
   .sink { [weak self] (text) in //(text) is always empty string ""
    ...

I want to use that function parameter searchText: String as $searchText to search with text come from another screen something like:

final class MainViewController {
  let viewModel = MainViewModel()
  @Published var searchText = ""

 override func viewDidLoad() {
  viewModel.searchTextManipulation($searchText: searchText) //but it's not possible.
 }
}

Can you show me a workaround for solving that crucial part?

CodePudding user response:

Your code suggests that need to understand how Combine works a little bit better.

When you put together a publisher with a set of operators:

$searchText
   .debounce(for: .seconds(1.0), scheduler: RunLoop.main)
   .removeDuplicates()
   .sink { text in doSomething(text) }

It's like you're putting together a set of pipes. You only have to put them together once. When they are assembled, and you put something into the input side, the pipes will transform the value and deliver it to the output side. If you subscribe to the output side, using sink or assign for example, then you can catch the transformed value and do something with it.

But you only need to build the pipeline once. After you build the pipeline, you also have to keep ahold of it.

Your searchTextManipulation function is building a pipeline that is immediately destroyed when you leave the function. To prevent that you have to store a subscription (in the form of an AnyCancellable). sink and assign return subscriptions and you should retain those to keep the pipeline from being destroyed.

An @Published property creates a Publisher, with the same name and a $ prefix, that emits a value when the property is initialized or changed. In other words, when you change searchText the system will put the value into the input of a pipeline named $searchText for you.

So a complete example of what it looks like you are trying to do is the playground below.

MainViewModel has two published properties. One is searchText and the other is searchResults. We set up the model so that when searchText changes (with a debounce and uniq) a new value of searchResults is published.

In init we build a pipeline that starts with $searchText. Through the pipeline we transform a search string into an array of search results. (The idea that each step in a pipeline transforms a value from one thing to another is the model I use in my head to decide how to chain operators.)

The assign at the end of the pipeline takes the transformed result and assigns it to the searchResults property.

Note: To ensure our pipeline is not destroyed when init ends, we have to capture and store the subscription done by assign.

With that pipeline in place, whenever code changes searchText the pipeline transform that value into an array of search results, and store the result in searchResults. Since MainViewModel owns the subscription, the pipeline will only be destroyed when the view model instance is destroyed.

So how do you use it? Well, the searchResults get published to a pipeline named $searchResults whenever they change. So all you have to do is listen that pipeline for changes.

In MainViewController we want to listen for those changes. In viewDidLoad we set up a short pipeline that starts with $searchResults, drops the first result (it's sent when searchResults is initialized) and uses sink` to print the results to the console.

Again note: we are constructing the pipeline once, keeping track of it by storing the subscription, and it will run automatically every time a new value is put into the seachResults.

I added a function called userDoesSomething that should be called whenever you want to kick off the search process.

  1. userDoesSomething assigns some text value that came from the user to the model's searchText property.
  2. Because it's @Published, the system sends the new value into the $searchText pipeline created by the view model's init function.
  3. The pipeline transforms the text into and array of search results and assigns the array to searchResults
  4. Because searchResults is @Published it sends the array into the pipeline called $searchResults
  5. The MainViewController created a pipeline to listen to $searchResults and that pipeline runs with the new results.
  6. That pipeline sends the value to a sink that simply prints it to the console.

The bit at the very end of the playground simulates a user sending a string with each of the letters in the English alphabet ('a'-'z') once every 0.2 seconds to userDoesSomething. That kicks of the numbered sequence I described above and gives you something interesting to look at on the Playground console.

import UIKit
import Combine
import PlaygroundSupport

let TheItems = ["The", "Quick", "Brown", "Fox", "Jumped", "Over", "the", "Lazy", "Dogs"]

final class MainViewModel {
    @Published var searchText = ""
    @Published private(set) var searchResults = []
    
    var subscription: AnyCancellable! = nil
    
    init() {
        self.subscription = $searchText
            .debounce(for: .seconds(0.1), scheduler: RunLoop.main)
            .removeDuplicates()
            .map(findItems)
            .assign(to: \.searchResults, on: self)
    }
}

private func findItems(searchText: String) -> [String] {
    if let firstChar = searchText.lowercased().first {
        return TheItems.filter { $0.lowercased().contains(firstChar) }
    }
    
    return []
}

final class MainViewController: UIViewController {
    var searchResultsSubscription : AnyCancellable! = nil
    let viewModel = MainViewModel()
    
    override func loadView() {
        self.view = UIView(frame: CGRect(x: 0,y: 0,width: 200,height: 200))
    }
    
    override func viewDidLoad() {
        searchResultsSubscription = viewModel.$searchResults
            .dropFirst()
            .sink{
                // do what you like here when search results change
                debugPrint($0)
            }
    }

    func userDoesSomething(generates searchText: String) {
        viewModel.searchText = searchText;
    }
}

let model = MainViewModel()
let controller = MainViewController()

PlaygroundSupport.PlaygroundPage.current.liveView = controller


(UInt8(ascii: "a")...UInt8(ascii:"z"))
    .enumerated()
    .publisher
    .sink { index, char in
        DispatchQueue.main.asyncAfter(deadline: .now()   .milliseconds(200 * index)) {
            controller.userDoesSomething(generates: String(UnicodeScalar(char)))
        }
    }

CodePudding user response:

Yes, you can pass a @Published wrapper around, by changing the function signature to expect the projected value of the property wrapper:

func searchTextManipulation(searchText: Published<String>.Publisher) {
    ...
}

, and later in your viewDidLoad just use the $:

override func viewDidLoad() {
    viewModel.searchTextManipulation(searchText: $searchText)
}

Everytime you write $someVar, what the compiler does is to de-sugar it into _someVar.projectedValue, and in case of Published, the projected value is of type Published<T>.Publisher. This is not something specific to SwiftUI, and it's part of the language, so you can freely use it in any parts of your code (assuming it makes sense, ofcourse).

  • Related