Multiple UITextFields and textDidChangeNotification notification - swift

I was playing with Combine framework lately and was wondering if it is possible to create some smart extension to get text changes as Publisher.
Let's say I've got two UITextFields:
firstTextField.textPub.sink {
self.viewModel.first = $0
}
secondTextField.textPub.sink {
self.viewModel.second = $0
}
where first and second variable is just `#Published var first/second: String = ""
extension UITextField {
var textPub: AnyPublisher<String, Never> {
return NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification)
.map {
guard let textField = $0.object as? UITextField else { return "" }
return textField.text ?? ""
}
.eraseToAnyPublisher()
}
}
This doesn't work because I'm using shared instance of NotificationCenter so when I make any change to any of textFields it will propagate new value to both sink closures. Do you think is there any way to achieve something similar to rx.text available in RxSwift? I was thinking about using addTarget with closure but it would require using associated objects from Objective-C.

I figured this out. We can pass object using NotificationCenter and then filter all instances that are not matching our instance. It seems to work as I expected:
extension UITextField {
var textPublisher: AnyPublisher<String, Never> {
NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification, object: self)
.compactMap { $0.object as? UITextField }
.map { $0.text ?? "" }
.eraseToAnyPublisher()
}
}

I would suggest you add subscribers to the view modal, and connect them a text field publisher within the context of the view controller.
NotificationCenter is useful to dispatch events app-wide; there's no need to use it when connecting items that are fully owned by the View Controller. However, once you've updated the view modal it may make sense to publish a 'View Modal Did Change' event to NotificationCenter.

Related

MVVM project. How to connect Model View to the View

I am trying to make an app that will show the weather in my city. I am using MVVM architecture and I have my Model, ModelView and View as follows. I have a variable inside the WeatherModelView class that I want to use in my view controller:
label.text = "(weatherViewModel.res?.main.temp ?? -123)"
but it does not work.
(https://i.stack.imgur.com/8BTEJ.png)
View Controller](https://i.stack.imgur.com/qW54n.png)
It does not give an error, it simply prints -123.0 on the label, which is the nil case after unwrapping. I would like it to print the actual weather. I don't think there are problems with the URL or the JSON decoding.
This is what is wrongfully shown when I run it: simulator
In "viewdidload" "fetchWeather" is not complete.
You need set "label.text" after it completed
change res in your view model
var res = WeatherModel? {
didSet {
resHasData?()
}
}
var resHasData?: (() -> Void)?
add my code in the last line "viewDidLoad"
weatherViewModel.resHasData = { [weak sekf] in
guard let self = self else { return }
self.label.text = "\(self.weatherViewModel.res?.main.temp ?? -123)"
}
good luck.

Is there a way to update UIButton's titleLabel text using Combine's .assign instead of .sink?

I have a #Published string in my view model. I want to receive updates on its value in my view controller so that I can update the UI.
I am able to successfully get the updates through use of the sink subscriber. This works fine:
viewModel.$buttonText.sink { [weak self] buttonText in
self?.buttonOutlet.setTitle(buttonText, for: .normal)
}.store(in: &cancellables)
But I am looking for a one line approach. Something more like what you are able to do with UILabels using the assign subscriber:
viewModel.$title.assign(to: \.text, on: titleLabel).store(in: &cancellables)
I've tried accessing the buttonOutlet.titleLabel directly, but this of course doesn't work since we can't directly update the text (UIButton.titleLabel is read-only). And it also introduces the issue of unwrapping the titleLabel property:
viewModel.$buttonText.assign(to: \.!.text, on: buttonOutlet.titleLabel).store(in: &cancellables)
I don't know if I'm just struggling to find the correct syntax or if this is simply a limitation of Combine for the time being.
You can just write an extension:
extension UIButton {
var normalTitleText: String? {
get {
title(for: .normal)
}
set {
setTitle(newValue, for: .normal)
}
}
}
Now you can get the keypath
viewModel.$title.assign(to: \.normalTitleText, on: someButton).store(in: &cancellables)
Another option:
extension UIButton {
func title(for state: UIControl.State) -> (String?) -> Void {
{ title in
self.setTitle(title, for: state)
}
}
}
So you don't have to write a different var for each control state. It can be used like this:
viewModel.$buttonText
.sink(receiveValue: buttonOutlet.title(for: .normal))
.store(in: &cancellables)
There's nothing wrong with the other answers, but if you just want the least code overall, you tighten up your code by capturing buttonOutlet directly and using $0 as the closure argument.
viewModel.$buttonText
.sink { [b = buttonOutlet] in b?.setTitle($0, for: .normal) }
.store(in: &cancellables)

NSItemProvider loadObject in SwiftUI Drag and Drop

I am dragging and dropping views using a DropDelegate in SwiftUI. I'm successfully wrapping my data in an NSItemProvider using the .onDrag View Modifier, and I even have .onDrop working if I am able to have my drop data be one of the stored properties of my DropDelegate.
I'm trying to allow for decoding my drop data using the provided DropInfo. I update my view in the dropEntered(info:) delegate method, so that the user can have a preview of their drop before it occurs. When I write
info.itemProviders.first?.loadObject(...) { reading, error in
...
}
The completion handler is not called. If I were to instead write this closure in the performDrop(info:) delegate method, the completion handler would be called. Why is the completion handler only called in performDrop(info:)? Is there a way to have drag & drop previews while performing the necessary changes in real time while not having the data model changed in dropEntered(info:)?
I don't love that I edit my data model in dropEntered(info:), and I haven't gotten to how it would work if the user were to cancel the drag and drop somehow... Perhaps there's a better way to go about this that will allow me to edit my data model in performDrop(info:)?
Thank you!
Edit
Here's the code to reproduce the bug:
struct ReorderableForEach<Content: View, Item: Identifiable>: View {
let items: [Item]
let content: (Item) -> Content
var body: some View {
ForEach(items) { item in
content(item)
.onDrag {
return NSItemProvider(object: "\(item.id)" as NSString)
}
.onDrop(
of: [.text],
delegate: DragRelocateDelegate()
)
}
}
}
struct DragRelocateDelegate: DropDelegate {
func dropEntered(info: DropInfo) {
let _ = info.itemProviders(for: [.text]).first?.loadObject(ofClass: String.self) { item, error in
print("dropEntered: \(item)") // Won't trigger this
}
}
func performDrop(info: DropInfo) -> Bool {
let _ = info.itemProviders(for: [.text]).first?.loadObject(ofClass: String.self) { item, error in
print("performDrop: \(item)") // Will trigger this
}
return true
}
}

Why does this combine subscription not deallocate in custom ViewModifier?

In the documentation for assign it says the following...
The Subscribers/Assign instance created by this operator maintains a
strong reference to object, and sets it to nil when the upstream
publisher completes (either normally or with an error).
In the ViewModifier below the assign method in subscribeToKeyboardChanges() refers to self but self is a struct here so there's no way it can create a strong reference
Why doesn't the subscription in subscribeToKeyboardChanges() get immediately deallocated?
What is the actually happening here behind the scenes?
struct KeyboardHandler: ViewModifier {
#State private var keyboardHeight: CGFloat = 0
func body(content: Content) -> some View {
content
.padding(.bottom, self.keyboardHeight)
.animation(.default)
.onAppear(perform: subscribeToKeyboardChanges)
}
private let keyboardWillShow = NotificationCenter.default
.publisher(for: UIResponder.keyboardWillShowNotification)
.compactMap { $0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect }
.map { $0.height }
private let keyboardWillHide = NotificationCenter.default
.publisher(for: UIResponder.keyboardWillHideNotification)
.map { _ in CGFloat.zero }
private func subscribeToKeyboardChanges() {
_ = Publishers.Merge(keyboardWillShow, keyboardWillHide)
.subscribe(on: DispatchQueue.main)
.assign(to: \.self.keyboardHeight, on: self)
}
}
I believe you`re referring to the wrong function description. Here is right one:
assign(to:on:)
Assigns a publisher’s output to a property of an object.
...
Return Value
An AnyCancellable instance. Call cancel() on this instance when you no
longer want the publisher to automatically assign the property.
Deinitializing this instance will also cancel automatic assignment.
So in your subscribeToKeyboardChanges example code it's expected that subscription will be canceled after function finishes. You have to keep strong reference to AnyCancellable return from assign to keep subscription in memory.
EDIT:
It appears that in this line assign copies self and holds it in the memory until cancel() call.
.assign(to: \.self.keyboardHeight, on: self)
Therefore View that uses KeyboardHandler view modifier will never be deallocated with subscription and will eventually bloat the memory during navigation. For example, here is the screenshot after 3 navigations see 3 instances of KeyboardHandler still in the memory.

Combine: Going from Notification Center addObserver with selector to Notification publisher

I've seen how to transition to Combine using a Publisher from some NotificationCenter code, but have not seen how to do it for something like:
NotificationCenter.default.addObserver(
self,
selector: #selector(notCombine),
name: NSNotification.Name(rawValue: "notCombine"),
object: nil
)
I've seen that this is available as a Publisher, but I don't have a selector and am not sure what to do for it:
NotificationCenter.default.publisher(
for: Notification.Name(rawValue: "notCombine")
)
Does anyone know? Thanks!
You're right to say "I don't have a selector", as that is half the point right there. You can receive notifications from the notification center without a selector using Combine.
The other half of the point is that you can push your logic for dealing with the notification up into the Combine pipeline, so that the correct result just pops out the end of the pipeline if it reaches you at all.
The old-fashioned way
Let's say I have a Card view that emits a virtual shriek when it is tapped by posting a notification:
static let tapped = Notification.Name("tapped")
#objc func tapped() {
NotificationCenter.default.post(name: Self.tapped, object: self)
}
Now let's say, for purposes of the example, that what the game is interested in when it receives one of these notifications is the string value of the name property of the Card that posted the notification. If we do this the old-fashioned way, then getting that information is a two-stage process. First, we have to register to receive notifications at all:
NotificationCenter.default.addObserver(self,
selector: #selector(cardTapped), name: Card.tapped, object: nil)
Then, when we receive a notification, we have to look to see that its object really is a Card, and if it is, fetch its name property and do something with it:
#objc func cardTapped(_ n:Notification) {
if let card = n.object as? Card {
let name = card.name
print(name) // or something
}
}
The Combine way
Now let's do the same thing using the Combine framework. We obtain a publisher from the notification center by calling its publisher method. But we don't stop there. We don't want to receive a notification if the object isn't a Card, so we use the compactMap operator to cast it safely to Card (and if it isn't a Card, the pipeline just stops as if nothing had happened). We only want the Card's name, so we use the map operator to get it. Here's the result:
let cardTappedCardNamePublisher =
NotificationCenter.default.publisher(for: Card.tapped)
.compactMap {$0.object as? Card}
.map {$0.name}
Let's say that cardTappedCardNamePublisher is an instance property of our view controller. Then what we now have is an instance property that publishes a string if a Card posts the tapped notification, and otherwise does nothing.
Do you see what I mean when I say that the logic is pushed up into the pipeline?
So how would we arrange to receive what comes out of the end of the pipeline? We could use a sink:
let sink = self.cardTappedCardNamePublisher.sink {
print($0) // the string name of a card
}
If you try it, you'll see that we now have a situation where every time the user taps a card, the name of the card is printed. That is the Combine equivalent of our earlier register-an-observer-with-a-selector approach.
The use case is not entirely clear, but here a basics playground example:
import Combine
import Foundation
class CombineNotificationSender {
var message : String
init(_ messageToSend: String) {
message = messageToSend
}
static let combineNotification = Notification.Name("CombineNotification")
}
class CombineNotificationReceiver {
var cancelSet: Set<AnyCancellable> = []
init() {
NotificationCenter.default.publisher(for: CombineNotificationSender.combineNotification)
.compactMap{$0.object as? CombineNotificationSender}
.map{$0.message}
.sink() {
[weak self] message in
self?.handleNotification(message)
}
.store(in: &cancelSet)
}
func handleNotification(_ message: String) {
print(message)
}
}
let receiver = CombineNotificationReceiver()
let sender = CombineNotificationSender("Message from sender")
NotificationCenter.default.post(name: CombineNotificationSender.combineNotification, object: sender)
sender.message = "Another message from sender"
NotificationCenter.default.post(name: CombineNotificationSender.combineNotification, object: sender)
For some use cases you can also make it a combine only solution without using notifications
import Combine
import Foundation
class CombineMessageSender {
#Published var message : String?
}
class CombineMessageReceiver {
private var cancelSet: Set<AnyCancellable> = []
init(_ publisher: AnyPublisher<String?, Never>) {
publisher
.compactMap{$0}
.sink() {
self.handleNotification($0)
}
.store(in: &cancelSet)
}
func handleNotification(_ message: String) {
print(message)
}
}
let sender = CombineMessageSender()
let receiver = CombineMessageReceiver(sender.$message.eraseToAnyPublisher())
sender.message = "Message from sender"
sender.message = "Another message from sender"