I am building an app where I need to change the SwiftUI View based on a bool property. The problem is that boolean property value depends on the result of an async operation.
Label("\(tweet.likeCount)", systemImage: isTweetLiked ? "heart.fill": "heart")
private var isTweetLiked: Bool {
Task {
return await model.isTweetLikedByCurrentUser(tweet: tweet)
}
}
Cannot convert return expression of type 'Task<Bool, Never>' to return type 'Bool'
How can I accomplish this?
I can only provide limited insight from the code reference you have given.
Here, you would have to wait for async operation to complete then you can change the state of the property to which properties of Label might be binded.
I tested this code locally, It works.
struct ContentView: View {
#State var likeCount: Int = 0
#State var isTweetLiked: Bool = false
var body: some View {
Label("\(likeCount)", systemImage: isTweetLiked ? "heart.fill": "heart").onAppear{
Task {
await updateUsers()
}
}
}
func updateUsers() async {
do {
let isLiked = await model.isTweetLikedByCurrentUser(tweet: tweet)
DispatchQueue.main.async {
likeCount = isLiked ? likeCount + 1 : isLiked
isTweetLiked = isLiked
}
} catch {
print("Oops!")
}
}
}
Certainly, I tested with a sample API, but I have changed the code to resemble your code for ease of understanding.
Also, I am calling the updateUsers from onAppear. Which might not be what you want, you can call it from a place which suits your custom requirements.
Related
I trying to understand how Combine and SwiftUI works in combination with MVVM and clean architecture, but I encountered a problem with using withAnimation once my view model has an async method that updated published value. I was able to solve it, but I'm pretty sure it's not the correct way and I'm missing something fundamental. Here it is how it looks my solution, starting with my data manager:
protocol NameManaging {
var publisher: AnyPublisher<[Name], Never> { get }
func fetchNames() async
}
class MockNameManager: NameManaging {
var publisher: AnyPublisher<[Name], Never> {
names.eraseToAnyPublisher()
}
func fetchNames() async {
var values = await heavyAsyncTask()
names.value.append(contentsOf: values)
}
private func heavyAsyncTask() async -> [Name] {
// do some heavy async task
}
private var names = CurrentValueSubject<[Name], Never>([])
}
Then view models:
class NameListViewModel: ObservableObject {
#Published var names = [Name]()
private var anyCancellable: AnyCancellable?
private var nameManager: NameManaging
init(nameManager: NameManaging = MockNameManager()) {
self.nameManager = nameManager
self.anyCancellable = nameManager.publisher
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] values in
withAnimation {
self?.names = values
}
})
}
func fetchNames() async {
await nameManager.fetchNames()
}
}
Lastly my view:
struct NameList: View {
#StateObject private var nameListViewModel = NameListViewModel()
var body: some View {
VStack {
VStack {
HStack {
Button(action: updateNames) {
Text("Fetch some more names")
}
}
}
.padding()
List {
ForEach(nameListViewModel.names) {
NameRow(name: $0)
}
}
}
.navigationTitle("Names list")
.onAppear(perform: updateNames)
}
func updateNames() {
Task {
await nameListViewModel.fetchNames()
}
}
}
What I did is use withAnimation inside my view model in .sink() method of data manager publisher. This works as expected, but it indroduce view function inside view model. How can I do it in a way that inside updateNames in my view I'll use withAnimation? Or maybe I should do it in completely different way?
You are mixing up technologies. The point of async/await is to remove the need for a state object (i.e. a reference type) and Combine to do async work. You can simply use the .task modifier to call any async func and set the result on an #State var. If the async func throws then you might catch the exception and set a message on another #State var. The great thing about .task is it's called when the UIView (that SwiftUI creates for you) appears and cancelled when it disappears (also if the optional id param changes). So no need for an object, which often is the cause of consistency/memory problems which Swift and SwiftUI's use of value types is designed to eliminate.
struct NameList: View {
#State var var names: [Name] = []
#State var fetchCount = 0
var body: some View {
VStack {
VStack {
HStack {
Button("Fetch some more names") {
fetchCount += 1
}
}
}
.padding()
List {
ForEach(names) { name in
NameRow(name: name)
}
}
}
.navigationTitle("Names list")
.task(id: fetchCount) {
let names = await Name.fetchNames()
withAnimation {
self.names = names
}
}
}
}
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.
}
}
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.
I have a SwiftUI Form I'm working with, which updates values of an EnvironmentObject. I need to call a function when the value of a Picker changes, but every way I've found is either really messy, or causes other problems. What I'm looking for is a similar way in which the Slider works, but there doesn't seem to be one. Basic code without any solution is here:
class ValueHolder : ObservableObject {
#Published var sliderValue : Float = 0.5
static let segmentedControlValues : [String] = ["one", "two", "three", "four", "five"]
#Published var segmentedControlValue : Int = 3
}
struct ContentView: View {
#EnvironmentObject var valueHolder : ValueHolder
func sliderFunction() {
print(self.valueHolder.sliderValue)
}
func segmentedControlFunction() {
print(ValueHolder.segmentedControlValues[self.valueHolder.segmentedControlValue])
}
var body: some View {
Form {
Text("\(self.valueHolder.sliderValue)")
Slider(value: self.$valueHolder.sliderValue, onEditingChanged: {_ in self.sliderFunction()
})
Text("\(ValueHolder.segmentedControlValues[self.valueHolder.segmentedControlValue])")
Picker("", selection: self.$valueHolder.segmentedControlValue) {
ForEach(0..<ValueHolder.segmentedControlValues.count) {
Text("\(ValueHolder.segmentedControlValues[$0])")
}
}.pickerStyle(SegmentedPickerStyle())
}
}
}
After reviewing this similar (but different) question here: Is there a way to call a function when a SwiftUI Picker selection changes? I have tried using onReceive() as below, but it is also called when the Slider value changes, resulting in unwanted behavior.
.onReceive([self.valueHolder].publisher.first(), perform: {_ in
self.segmentedControlFunction()
})
I have tried changing the parameter for onReceive to filter it by only that value. The value passed is correct, but the segmentedControlFunction still gets called when the slider moves, not just when the picker changes.
.onReceive([self.valueHolder.segmentedControlValue].publisher.first(), perform: {_ in
self.segmentedControlFunction()
})
How can I get the segmentedControlFunction to be called in a similar way to the sliderFunction?
There is much simpler approach, which looks more appropriate for me.
The generic schema as follows
Picker("Label", selection: Binding( // proxy binding
get: { self.viewModel.value }, // get value from storage
set: {
self.viewModel.value = $0 // set value to storage
self.anySideEffectFunction() // didSet function
}) {
// picker content
}
As I was proofing the original question and tinkering with my code I accidentally stumbled across what I think is probably the best solution. So, in case it will help anyone else, here it is:
struct ContentView: View {
#EnvironmentObject var valueHolder : ValueHolder
#State private var sliderValueDidChange : Bool = false
func sliderFunction() {
if self.sliderValueDidChange {
print("Slider value: \(self.valueHolder.sliderValue)\n")
}
self.sliderValueDidChange.toggle()
}
var segmentedControlValueDidChange : Bool {
return self._segmentedControlValue != self.valueHolder.segmentedControlValue
}
#State private var _segmentedControlValue : Int = 0
func segmentedControlFunction() {
self._segmentedControlValue = self.valueHolder.segmentedControlValue
print("SegmentedControl value: \(ValueHolder.segmentedControlValues[self.valueHolder.segmentedControlValue])\n")
}
var body: some View {
Form {
Text("\(self.valueHolder.sliderValue)")
Slider(value: self.$valueHolder.sliderValue, onEditingChanged: {_ in self.sliderFunction()
})
Text("\(ValueHolder.segmentedControlValues[self.valueHolder.segmentedControlValue])")
Picker("", selection: self.$valueHolder.segmentedControlValue) {
ForEach(0..<ValueHolder.segmentedControlValues.count) {
Text("\(ValueHolder.segmentedControlValues[$0])")
}
}.pickerStyle(SegmentedPickerStyle())
.onReceive([self._segmentedControlValue].publisher.first(), perform: {_ in
if self.segmentedControlValueDidChange {
self.segmentedControlFunction()
}
})
}
}
}
Note that the onReceive() is for the a new #State property, which pairs with a Bool evaluating if it's the same as the #EnvironmentObject counterpart. The #State value is then updated on the function call. Also note that in the solution I've changed how the sliderFunction works so that it is only called once, when the #EnvironmentObject value actually changes. It's a bit hacky, but the Slider and Picker both work in the same way when updating values and calling their respective functions.
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)")
}
}