How to use Combine on a SwiftUI View - ios

This question relates to this one: How to observe a TextField value with SwiftUI and Combine?
But what I am asking is a bit more general.
Here is my code:
struct MyPropertyStruct {
var text: String
}
class TestModel : ObservableObject {
#Published var myproperty = MyPropertyStruct(text: "initialText")
func saveTextToFile(text: String) {
print("this function saves text to file")
}
}
struct ContentView: View {
#ObservedObject var testModel = TestModel()
var body: some View {
TextField("", text: $testModel.myproperty.text)
}
}
Scenario: As the user types into the textfield, the saveTextToFile function should be called. Since this is saving to a file, it should be slowed-down/throttled.
So My question is:
Where is the proper place to put the combine operations in the code below.
What Combine code do I put to accomplish: (A) The string must not contain spaces. (B) The string must be 5 characters long. (C) The String must be debounced/slown down
I wanted to use the response here to be a general pattern of: How should we handle combine stuff in a SwiftUI app (not UIKit app).

You should do what you want in your ViewModel. Your view model is the TestModel class (which I suggest you rename it in TestViewModel). It's where you are supposed to put the logic between the model and the view. The ViewModel should prepare the model to be ready for the visualization. And that is the right place to put your combine logic (if it's related to the view, of course).
Now we can use your specific example to actually make an example. To be honest there are a couple of slight different solutions depending on what you really want to achieve. But for now I'll try to be as generic as possible and then you can tell me if the solution is fine or it needs some refinements:
struct MyPropertyStruct {
var text: String
}
class TestViewModel : ObservableObject {
#Published var myproperty = MyPropertyStruct(text: "initialText")
private var canc: AnyCancellable!
init() {
canc = $myproperty.debounce(for: 0.5, scheduler: DispatchQueue.main).sink { [unowned self] newText in
let strToSave = self.cleanText(text: newText.text)
if strToSave != newText.text {
//a cleaning has actually happened, so we must change our text to reflect the cleaning
self.myproperty.text = strToSave
}
self.saveTextToFile(text: strToSave)
}
}
deinit {
canc.cancel()
}
private func cleanText(text: String) -> String {
//remove all the spaces
let resultStr = String(text.unicodeScalars.filter {
$0 != " "
})
//take up to 5 characters
return String(resultStr.prefix(5))
}
private func saveTextToFile(text: String) {
print("text saved")
}
}
struct ContentView: View {
#ObservedObject var testModel = TestViewModel()
var body: some View {
TextField("", text: $testModel.myproperty.text)
}
}
You should attach your own subscriber to the TextField publisher and use the debounce publisher to delay the cleaning of the string and the calling to the saving method. According to the documentation:
debounce(for:scheduler:options:)
Use this operator when you want to wait for a pause in the delivery of
events from the upstream publisher. For example, call debounce on the
publisher from a text field to only receive elements when the user
pauses or stops typing. When they start typing again, the debounce
holds event delivery until the next pause.
When the user stops typing the debounce publisher waits for the specified time (in my example here above 0.5 secs) and then it calls its subscriber with the new value.
The solution above delays both the saving of the string and the TextField update. This means that users will see the original string (the one with spaces and maybe longer than 5 characters) for a while, before the update happens. And that's why, at the beginning of this answer, I said that there were a couple of different solutions depending on the needs. If, indeed, we want to delay just the saving of the string, but we want the users to be forbidden to input space characters or string longer that 5 characters, we can use two subscribers (I'll post just the code that changes, i.e. the TestViewModel class):
class TestViewModel : ObservableObject {
#Published var myproperty = MyPropertyStruct(text: "initialText")
private var saveCanc: AnyCancellable!
private var updateCanc: AnyCancellable!
init() {
saveCanc = $myproperty.debounce(for: 0.5, scheduler: DispatchQueue.main)
.map { [unowned self] in self.cleanText(text: $0.text) }
.sink { [unowned self] newText in
self.saveTextToFile(text: self.cleanText(text: newText))
}
updateCanc = $myproperty.sink { [unowned self] newText in
let strToSave = self.cleanText(text: newText.text)
if strToSave != newText.text {
//a cleaning has actually happened, so we must change our text to reflect the cleaning
DispatchQueue.main.async {
self.myproperty.text = strToSave
}
}
}
}
deinit {
saveCanc.cancel()
updateCanc.cancel()
}
private func cleanText(text: String) -> String {
//remove all the spaces
let resultStr = String(text.unicodeScalars.filter {
$0 != " "
})
//take up to 5 characters
return String(resultStr.prefix(5))
}
private func saveTextToFile(text: String) {
print("text saved: \(text)")
}
}

Related

How to do network request without clicking at button

I have a textfield and I need that when I write smth. in textfield it sends request to server without clicking at any button. For example if my sending text is the same as server's text it print that text. I am using SwiftUI and Combine. Below is my code.
#State var cancellable: AnyCancellable?
.onReceive(Just($homePageVM.orderNumber)) { res in
if !homePageVM.orderNumber.isEmpty {
cancellable = homePageVM.orderNumber
.publisher
.receive(on: RunLoop.main)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { value in
homePageVM.getVoucherInfo { result in
if homePageVM.orderNumber == homePageVM.getVoucherInfoModel?.orderNumber {
print(result)
}
}
})
}
}
I am using onreceive because I need to send the data every time i change the text. But in the result i receive an unended cycle. How can i send data only one time every time I change my text? Please help me.
It is very strange to use onReceive with Just and sink, there is no point in using Combine at all. The onChange modifier would replace all of this. However, the best way to do what you need simply with multiple View structs and the task modifier, e.g.
struct View1: View {
...
var body: some View {
View2(orderNumber: model.orderNumber)
}
}
struct View2: View {
let orderNumber: Int
#State var result: String?
// body called when any of the properties change
var body: some View {
Text(result ?? "No result")
.task(id: orderNumber) { newOrderNumber in
result = await fetchVoucherInfo(orderNumber: newOrderNumber)
} // The `task` is started if the orderNumber has changed.
}
}

"Binding<String> action tried to update multiple times per frame" on TextField with CurrentValueSubject binding

I've been trying to find a good way to connect a TextField with a viewmodel, so that the inputs to the TextField don't immediately update the view.
Here is a working example:
struct TextFieldSamples: View {
#StateObject var vm = TFViewModel()
var body: some View {
print(Self._printChanges())
return VStack {
TextField("placeholder",
text: Binding(
get: { vm.outputText },
set: { vm.subject.value = $0 }
))
Text(vm.outputText) // this will only update when the textfield holds >5 characters
}
}
}
class TFViewModel: ObservableObject {
#Published var outputText: String = ""
let subject = CurrentValueSubject<String, Never>("")
var subscriptions = Set<AnyCancellable>()
init() {
subject
.filter({ $0.count > 5 })
.sink { self.outputText = $0 }
.store(in: &subscriptions)
}
}
However, I came across the propertyWrapped CurrentValueSubject below on the swiftbysundell.com blog
#propertyWrapper
struct Input<Value> {
var wrappedValue: Value {
get { subject.value }
set { subject.send(newValue) }
}
var projectedValue: AnyPublisher<Value, Never> {
subject.eraseToAnyPublisher()
}
private let subject: CurrentValueSubject<Value, Never>
init(wrappedValue: Value) {
subject = CurrentValueSubject(wrappedValue)
}
}
Trying to use it, will result in the error Binding<String> action tried to update multiple times per frame.
(Not only that, the input behavior of the TextField gets messed up. This shows especially with Japanese input where the character transformations don't are not working most of the time.)
Here's a example showing this behavior:
struct TextFieldSamples: View {
#StateObject var vm = TFViewModel()
var body: some View {
print(Self._printChanges())
return VStack {
TextField("placeholder", text: $vm.inputText)
Text(vm.outputText)
}
}
}
class TFViewModel: ObservableObject {
#Input var inputText: String = ""
#Published var outputText: String = ""
var subscriptions = Set<AnyCancellable>()
init() {
$inputText
.filter({ $0.count > 5 })
.sink { self.outputText = $0 }
.store(in: &subscriptions)
}
}
I don't quite understand why the PropertyWrapper version is not working as expected.
The issue appears to a combination of the binding to the inputText field of vm and the binding to the vm itself as a #StateObject.
When text is entered, It sends the value to the inputText binding. This allows the system to flag that the TextField needs to be re-evaluated.
But inputText is a struct property of the vm object. Because vm is a #StateObject when inputText changes, the vm object broadcasts that the TextFieldSamples view needs to be re-evaluated. As part of that it also tries to set the value of textInput (to the value it already holds) which causes the "binding fired multiple times in the same frame" message.
You can eliminate the extra time the Binding fires by making vm a #State property instead of #StateObject. Now the change to inputText will fire once, and vm will not broadcast the object change message that tries to fire it the second time.
However, when you do that the system won't know TextFieldSamples needs to be reevaluated so it will never see the change to outputText.

SwiftUI List with TextField adjust when keyboard appears/disappears

I have written a List with SwiftUI. I also have a TextField object which is used as a search bar. My code looks like this:
import SwiftUI
struct MyListView: View {
#ObservedObject var viewModel: MyViewModel
#State private var query = ""
var body: some View {
NavigationView {
List {
// how to listen for changes here?
// if I add onEditingChange here, Get the value only after the user finish search (by pressing enter on the keyboard)
TextField(String.localizedString(forKey: "search_bar_hint"), text: self.$query) {
self.fetchListing()
}
ForEach(viewModel.myArray, id: \.id) { arrayObject in
NavigationLink(destination: MyDetailView(MyDetailViewModel(arrayObj: arrayObject))) {
MyRow(arrayObj: arrayObject)
}
}
}
.navigationBarTitle(navigationBarTitle())
}
.onAppear(perform: fetchListing)
}
private func fetchListing() {
query.isEmpty ? viewModel.fetchRequest(for: nil) : viewModel.fetchRequest(for: query)
}
private func navigationBarTitle() -> String {
return query.isEmpty ? String.localizedString(forKey: "my_title") : query
}
}
The problem I have now is that the List remains behind the keyboard :(. How can I set the list padding bottom or edge insets (or whatever else works, I am totally open) so that the scrolling of the list ends above the keyboard? The list „size“ should also adjust automatically depending on if keyboard will be opened or closed.
Problem looks like this:
Please help me with any advice on this, I really have no idea how to do this :(. I am a SwiftUI beginner who is trying to learn it :).
You may try the following and add detailed animations by yourself.
#ObservedObject var keyboard = KeyboardResponder()
var body: some View {
NavigationView {
List {
// how to listen for changes here?
// if I add onEditingChange here, Get the value only after the user finish search (by pressing enter on the keyboard)
TextField("search_bar_hint", text: self.$query) {
self.fetchListing()
}
ForEach(self.viewModel, id: \.self) { arrayObject in
Text(arrayObject)
}
}.padding(.bottom, self.keyboard.currentHeight).animation(.easeIn(duration: self.keyboard.keyboardDuration))
.navigationBarTitle(self.navigationBarTitle())
}
.onAppear(perform: fetchListing)
}
class KeyboardResponder: ObservableObject {
#Published var currentHeight: CGFloat = 0
#Published var keyboardDuration: TimeInterval = 0
private var anyCancellable: Set<AnyCancellable> = Set<AnyCancellable>()
init() {
let publisher1 = NotificationCenter.Publisher(center: .default, name: UIResponder.keyboardWillShowNotification).map{ notification -> Just<(CGFloat, TimeInterval)> in
guard let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else {return Just((CGFloat(0.0), 0.0)) }
guard let duration:TimeInterval = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { return Just((CGFloat(0.0), 0.0)) }
return Just((keyboardSize.height, duration))}
let publisher2 = NotificationCenter.Publisher(center: .default, name: UIResponder.keyboardWillHideNotification) .map{ notification -> Just<(CGFloat, TimeInterval)> in
guard let duration:TimeInterval = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { return Just((CGFloat(0.0), 0.0)) }
return Just((0.0, duration))}
Publishers.Merge(publisher1, publisher2).switchToLatest().subscribe(on: RunLoop.main).sink(receiveValue: {
if $0.1 > 1e-6 { self.currentHeight = $0.0 }
self.keyboardDuration = $0.1
}).store(in: &anyCancellable)
}
}
The resolution for the problem with the keyboard padding is like E.coms suggested. Also the class written here by kontiki can be used:
How to make the bottom button follow the keyboard display in SwiftUI
The problems I had was because of state changes in my view hierarchy due to multiple instances of reference types publishing similar state changes.
My view models are reference types, which publish changes to its models, which are value types. However, these view models also contain reference types which handle network requests. For each view I render (each row), I assign a new view model instance, which also creates a new network service instance. Continuing this pattern, each of these network services also create and assign new network managers.

How to format TextField input string by function?

I have a simple TextField for telephone input, and want to format it each time it being changed.
I'm using PhoneNumberKit and it works fine, but i do not understand how to call formatting func after the value in textField have changed.
Telephone Formatting function.
import SwiftUI
import PhoneNumberKit
import Combine
func formatTelephone(telephone : String) -> String
{
do {
let phoneNumber = PartialFormatter().formatPartial(telephone)
print(phoneNumber)
return phoneNumber
}
catch {
print("Generic parser error")
}
}
It does something like this:
formatTelephone("79152140700") -> "7 (915) 214 08-00"
formatTelephone("791521") -> "7 (915) 21"
and i have a TextField like that
TextField("915 214 07 00" , text: $telephoneManager.telephone)
After each input of a digit the whole textfield label needs to be formatted by a func and show user's input in a better way.
In order to get what you want you need to use Combine (the implementation of the two methods in the view model is up to you because they strictly depend on your needs):
import SwiftUI
import Combine
class ContentViewModel: ObservableObject {
#Published var phoneNumber = ""
private var toCancel: AnyCancellable!
init() {
toCancel = $phoneNumber
.filter { !self.isFormatValid($0) }
.map { self.convert($0) }
.receive(on: RunLoop.main)
.assign(to: \.phoneNumber, on: self)
}
private func isFormatValid(_ str: String) -> Bool {
//here you must check if _str_ is already in the right format
true //just to make this example compile, but clearly this must be replaced with your code
}
private func convert(_ str: String) -> String {
//convert your string to be in the right format
"string in the correct format" //just to make this example compile, but clearly this must be replaced with your code
}
deinit {
toCancel.cancel()
}
}
struct ContentView: View {
#ObservedObject var contentViewModel = ContentViewModel()
var body: some View {
TextField("Your phone number...", text: $contentViewModel.phoneNumber)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Steps are (look at the view model init):
phoneNumber is a #Published property, so "it's backed" by a publisher you can subscribe to. So, let's subscribe to that publisher.
You must check if the string in your textfield is already in the right format. This is paramount: if you miss this step you'll end up with an infinite loop. This is done through the .filter operator. The event is forwarded down only if the .filter operator returns true (in this case it returns true if the string format is invalid).
With the .map operator you can change the string (in the wrong format) and return the string in the right format.
.receive lets you forward the event on the thread you are interested in. In this case the main thread (since you want to update a textfield).
.assign just assigns the new value to the textfield.

How to create SwiftUI TextField that accepts only numbers and a single dot?

How to create a swiftui textfield that allows the user to only input numbers and a single dot?
In other words, it checks digit by digit as the user inputs, if the input is a number or a dot and the textfield doesn't have another dot the digit is accepted, otherwise the digit entry is ignored.
Using a stepper isn't an option.
SwiftUI doesn't let you specify a set of allowed characters for a TextField. Actually, it's not something related to the UI itself, but to how you manage the model behind. In this case the model is the text behind the TextField. So, you need to change your view model.
If you use the $ sign on a #Published property you can get access to the Publisher behind the #Published property itself. Then you can attach your own subscriber to the publisher and perform any check you want. In this case I used the sink function to attach a closure based subscriber to the publisher:
/// Attaches a subscriber with closure-based behavior.
///
/// This method creates the subscriber and immediately requests an unlimited number of values, prior to returning the subscriber.
/// - parameter receiveValue: The closure to execute on receipt of a value.
/// - Returns: A cancellable instance; used when you end assignment of the received value. Deallocation of the result will tear down the subscription stream.
public func sink(receiveValue: #escaping ((Self.Output) -> Void)) -> AnyCancellable
The implementation:
import SwiftUI
import Combine
class ViewModel: ObservableObject {
#Published var text = ""
private var subCancellable: AnyCancellable!
private var validCharSet = CharacterSet(charactersIn: "1234567890.")
init() {
subCancellable = $text.sink { val in
//check if the new string contains any invalid characters
if val.rangeOfCharacter(from: self.validCharSet.inverted) != nil {
//clean the string (do this on the main thread to avoid overlapping with the current ContentView update cycle)
DispatchQueue.main.async {
self.text = String(self.text.unicodeScalars.filter {
self.validCharSet.contains($0)
})
}
}
}
}
deinit {
subCancellable.cancel()
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
TextField("Type something...", text: $viewModel.text)
}
}
Important to note that:
$text ($ sign on a #Published property) gives us an object of type Published<String>.Publisher i.e. a publisher
$viewModel.text ($ sign on an #ObservableObject) gives us an object of type Binding<String>
That are two completely different things.
EDIT: If you want you can even create you own custom TextField with this behaviour. Let's say you want to create a DecimalTextField view:
import SwiftUI
import Combine
struct DecimalTextField: View {
private class DecimalTextFieldViewModel: ObservableObject {
#Published var text = ""
private var subCancellable: AnyCancellable!
private var validCharSet = CharacterSet(charactersIn: "1234567890.")
init() {
subCancellable = $text.sink { val in
//check if the new string contains any invalid characters
if val.rangeOfCharacter(from: self.validCharSet.inverted) != nil {
//clean the string (do this on the main thread to avoid overlapping with the current ContentView update cycle)
DispatchQueue.main.async {
self.text = String(self.text.unicodeScalars.filter {
self.validCharSet.contains($0)
})
}
}
}
}
deinit {
subCancellable.cancel()
}
}
#ObservedObject private var viewModel = DecimalTextFieldViewModel()
var body: some View {
TextField("Type something...", text: $viewModel.text)
}
}
struct ContentView: View {
var body: some View {
DecimalTextField()
}
}
This way you can use your custom text field just writing:
DecimalTextField()
and you can use it wherever you want.
This is a simple solution for TextField validation: (updated)
struct ContentView: View {
#State private var text = ""
func validate() -> Binding<String> {
let acceptableNumbers: String = "0987654321."
return Binding<String>(
get: {
return self.text
}) {
if CharacterSet(charactersIn: acceptableNumbers).isSuperset(of: CharacterSet(charactersIn: $0)) {
print("Valid String")
self.text = $0
} else {
print("Invalid String")
self.text = $0
self.text = ""
}
}
}
var body: some View {
VStack {
Spacer()
TextField("Text", text: validate())
.padding(24)
Spacer()
}
}
}
I think using an async dispatch is the wrong approach and may cause other issues. Here's an implementation that achieves the same thing with a Double-backed property and manually iterates over the characters each time you type in the bound view.
final class ObservableNumber: ObservableObject {
let precision: Int
#Published
var value: String {
didSet {
var decimalHit = false
var remainingPrecision = precision
let filtered = value.reduce(into: "") { result, character in
// If the character is a number that by adding wouldn't exceed the precision and precision is set then add the character.
if character.isNumber, remainingPrecision > 0 || precision <= 0 {
result.append(character)
// If a decimal has been hit then decrement the remaining precision to fulfill
if decimalHit {
remainingPrecision -= 1
}
// If the character is a decimal, one hasn't been added already, and precision greater than zero then add the decimal.
} else if character == ".", !result.contains("."), precision > 0 {
result.append(character)
decimalHit = true
}
}
// Only update value if after processing it is a different value.
// It will hit an infinite loop without this check since the published event occurs as a `willSet`.
if value != filtered {
value = filtered
}
}
}
var doubleValue: AnyPublisher<Double, Never> {
return $value
.map { Double($0) ?? 0 }
.eraseToAnyPublisher()
}
init(precision: Int, value: Double) {
self.precision = precision
self.value = String(format: "%.\(precision)f", value)
}
}
This solution also ensures you only have a single decimal instead of allowing multiple instances of ".".
Note the extra computed property to put it "back" into a Double. This allows you to continue to react to the number as a number instead of a String and have to cast/convert everywhere. You could very easily add as many computed properties as you want to react to it as Int or whatever numeric type as long as you convert it in the way you would expect.
One more note you could also make it generic ObservableNumber<N: Numeric> and handle different inputs but using Double and keeping the generics out of it will simplify other things down the road. Change according to your needs.
Easy solution is to set .numberPad keyboardType:
TextField(
"0.0",
text: $fromValue
)
.keyboardType(UIKeyboardType.numberPad)

Resources