Update upcoming values in sink if needed - swift

I'm currently trying to modify an upcoming value from a textField which is using a Binding<Double>, but haven't found any working solution yet. It's only been infinite loops (Like the example below) and other solutions which didn't work in the end anyway. So, for example, if an user inputs an amount which is too low, I would want to change the upcoming value to the minimum and vice verse if the value is higher than the maximum value.
I also want to present the modified value (if needed) for the user, so I can't just store it in another variable.
Any ideas on how to solve this?
Example
class ViewModel: ObservableObject {
#Published var amount: Double
private var subscriptions: Set<AnyCancellable> = []
private let minimum: Double = 10_000
private let maximum: Double = 100_000
init() {
$amount
.sink {
if $0 < self.minimum {
// Set minimum value
self.amount = self.minimum
} else if $0 > self.maximum {
// Set maximum value
self.amount = self.maximum
}
// If `Else` is implemented it will just be an infinite loop...
else {
self.amount = $0
}
}
.store(in: &subscriptions)
}
func prepareStuff() {
// Start preparing
let chosenAmount = amount
}
}

One way is to use a property wrapper to clamp the values.
Here is a very basic example of the issue, where we have an amount, that we can change to any value. The Stepper just makes it easy for input/testing:
struct ContentView: View {
#State private var amount = 0
var body: some View {
Form {
Stepper("Amount", value: $amount)
Text(String(amount))
}
}
}
The problem with this example is that amount isn't limited to a range. To fix this, create a Clamping property wrapper (partially from here):
#propertyWrapper
struct Clamping<Value: Comparable> {
private var value: Value
let range: ClosedRange<Value>
var wrappedValue: Value {
get { value }
set { value = min(max(range.lowerBound, newValue), range.upperBound) }
}
var clampedValue: Value {
get { wrappedValue }
set { wrappedValue = newValue }
}
init(wrappedValue value: Value, _ range: ClosedRange<Value>) {
precondition(range.contains(value))
self.value = value
self.range = range
}
}
And then we can chain property wrappers, and get a working example where amount is limited:
struct ContentView: View {
#State #Clamping(-5 ... 5) private var amount = 0
var body: some View {
Form {
Stepper("Amount", value: $amount.clampedValue)
Text(String(amount))
}
}
}
I know, this isn't the proper way to limit a Stepper's range. Instead you should use Stepper(_:value:in:). However, this is to instead demonstrate clamping a value - not how to clamp a Stepper.
What does this mean you need to do?
Well, first off change your #Published property to this:
#Published #Clamping(10_000 ... 100_000) var amount: Double
And now you can just access amount like normal to get the clamped value. Use $amount.clampedValue like I did in my solution to get your Binding<Double> binding.
If having troubles sometimes with compiling chained property wrappers (probably a bug), here is my example recreated using a Model object and #Published:
struct ContentView: View {
#StateObject private var model = Model(amount: 0)
var body: some View {
Form {
Stepper("Amount", value: $model.amount.clampedValue)
Text(String(model.amount.clampedValue))
}
}
}
class Model: ObservableObject {
#Published var amount: Clamping<Int>
init(amount: Int) {
_amount = Published(wrappedValue: Clamping(wrappedValue: amount, -5 ... 5))
}
}

Related

SwiftUI + Combine, using Models and ViewModels together

I'm very new to Swift and I am currently trying to learn by building a rent splitting app with SwiftUI + Combine. I want to follow the MVVM pattern and am trying to implement this. At the moment I have the following Model, ViewModel and View files:
Model:
import Foundation
import Combine
struct InputAmounts {
var myMonthlyIncome : Double
var housemateMonthlyIncome : Double
var totalRent : Double
}
ViewModel (where I have attempted to use the data from the Model to conform to the MVVM pattern, but I am not sure I have done this in the cleanest way/correct way so please correct me if wrong)
import Foundation
import Combine
class FairRentViewModel : ObservableObject {
private var inputAmounts: InputAmounts
init(inputAmounts: InputAmounts) {
self.inputAmounts = inputAmounts
}
var yourShare: Double {
inputAmounts.totalRent = Double(inputAmounts.totalRent)
inputAmounts.myMonthlyIncome = Double(inputAmounts.myMonthlyIncome)
inputAmounts.housemateMonthlyIncome = Double(inputAmounts.housemateMonthlyIncome)
let totalIncome = Double(inputAmounts.myMonthlyIncome + inputAmounts.housemateMonthlyIncome)
let percentage = Double(inputAmounts.myMonthlyIncome / totalIncome)
let value = Double(inputAmounts.totalRent * percentage)
return Double(round(100*value)/100)
}
}
And then am trying to pass this all to the View:
import SwiftUI
import Combine
struct FairRentView: View {
#ObservedObject private var viewModel: FairRentViewModel
init(viewModel: FairRentViewModel){
self.viewModel = viewModel
}
var body: some View {
NavigationView {
Form {
Section(header: Text("Enter the total monthly rent:")) {
TextField("Total rent", text: $viewModel.totalRent)
.keyboardType(.decimalPad)
}
Section(header: Text("Enter your monthly income:")) {
TextField("Your monthly wage", text: $viewModel.myMonthlyIncome)
.keyboardType(.decimalPad)
}
Section(header: Text("Enter your housemate's monthly income:")) {
TextField("Housemate's monthly income", text: $viewModel.housemateMonthlyIncome)
.keyboardType(.decimalPad)
}
Section {
Text("Your share: £\(viewModel.yourShare, specifier: "%.2f")")
}
}
.navigationBarTitle("FairRent")
}
}
}
struct FairRentView_Previews: PreviewProvider {
static var previews: some View {
let viewModel = FairRentViewModel(inputAmounts: <#InputAmounts#>)
FairRentView(viewModel: viewModel)
}
}
I am getting the build errors with the View:
"Value of type 'ObservedObject.Wrapper' has no dynamic member 'totalRent' using key path from root type 'FairRentViewModel'"
"Value of type 'ObservedObject.Wrapper' has no dynamic member 'myMonthlyIncome' using key path from root type 'FairRentViewModel'"
"Value of type 'ObservedObject.Wrapper' has no dynamic member 'housemateMonthlyIncome' using key path from root type 'FairRentViewModel'"
My questions are:
What does this error mean and please point me in the right direction to solve?
Have I gone completely the wrong way at trying to implement the MVVM pattern here?
As I said I am a Swift beginner just trying to learn so any advice would be appreciated.
UPDATE IN RESPONSE TO ANSWER
var yourShare: String {
inputAmounts.totalRent = (inputAmounts.totalRent)
inputAmounts.myMonthlyIncome = (inputAmounts.myMonthlyIncome)
inputAmounts.housemateMonthlyIncome = (inputAmounts.housemateMonthlyIncome)
var totalIncome = Double(inputAmounts.myMonthlyIncome) 0.00 + Double(inputAmounts.housemateMonthlyIncome) ?? 0.00
var percentage = Double(myMonthlyIncome) ?? 0.0 / Double(totalIncome) ?? 0.0
var value = (totalRent * percentage)
return FairRentViewModel.formatter.string(for: value) ?? ""
}
I am getting errors here that "Value of optional type 'Double?' must be unwrapped to a value of type 'Double'" which I thought I was achieving with the ?? operands?
Your view model should have properties for each of the model properties you want to work with in the view and the view model should also be responsible for converting them to a format suitable for the view. The properties should be marked as #Published to so that the view gets updated if they are changed.
For example
#Published var myMonthlyIncome: String
and this is as you see a String and we can convert it in the init
myMonthlyIncome = FairRentViewModel.formatter.string(for: inputAmounts.myMonthlyIncome) ?? ""
Here is my complete version of the view model
final class FairRentViewModel : ObservableObject {
private static let formatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter
}()
private var inputAmounts: InputAmounts
#Published var myMonthlyIncome: String
#Published var housemateMonthlyIncome: String
#Published var totalRent: String
init(inputAmounts: InputAmounts) {
self.inputAmounts = inputAmounts
myMonthlyIncome = FairRentViewModel.formatter.string(for: inputAmounts.myMonthlyIncome) ?? ""
housemateMonthlyIncome = FairRentViewModel.formatter.string(for: inputAmounts.housemateMonthlyIncome) ?? ""
totalRent = FairRentViewModel.formatter.string(for: inputAmounts.totalRent) ?? ""
}
var yourShare: String {
let value = inputAmounts.totalRent * inputAmounts.myMonthlyIncome / (inputAmounts.myMonthlyIncome + inputAmounts.housemateMonthlyIncome)
return FairRentViewModel.formatter.string(for: Double(round(100*value)/100)) ?? ""
}
func save() {
inputAmounts.myMonthlyIncome = FairRentViewModel.formatter.number(from: myMonthlyIncome)?.doubleValue ?? 0
//...
}
}
You should do something similar for yourShare and I the save method is just a simple example of how to update the model from the view if you tie the function to a Button action or similar.
Also note that the number style I used for the formatter is just a guess, you might need to change that. And it is recommended to work with Decimal instead of Double when dealing with money.

How to modify a user input inside a SwiftUI form loop

I'm developing a simple SwiftUI app in Xcode 11. I want to have a form that loops through multiple user input strings and displays a form with a button. When the user presses the button it modifies the input value - specifically increment or decrement it.
However when passing an array of references like UserInput().foo where UserInput is a published observable object I cannot modify the value inside a ForEach because the ForEach is passed a copy as oppose to the original reference (at least that's my basic understanding). How do I then try to achieve it? I read about inout and everybody says to avoid it but surely this must be a relatively common issue.
I've made an simple example of what I'm trying to do but I can't quite work it out:
import SwiftUI
class UserInput: ObservableObject {
#Published var foo: String = ""
#Published var bar: String = ""
}
struct ContentView: View {
#ObservedObject var input = UserInput()
var body: some View {
LoopInputs()
}
func LoopInputs() -> AnyView?{
var userinputs = [
[UserInput().foo, "Foo"],
[UserInput().bar, "Bar"]
]
var inputs: some View{
VStack(){
ForEach(userinputs, id: \.self){userinput in
Text("\(userinput[1]): \(String(userinput[0]))")
Button(action: {
increment(input: String(userinput[0]))
}){
Text("Increase")
}
}
}
}
return AnyView(inputs)
}
func increment(input: String){
var lead = Int(input) ?? 0
lead += 1
// input = String(lead)
}
}
As I understood, when adding a value to userinputs, the ForEach values doesn't change.
Well, if that's the case, first of all, you could try creating a struct and in it, you declare foo and bar, then just declare a variable of type the struct. It'll look like this:
struct Input: Identifiable {
var id = UUID()
var foo: String
var bar: String
}
class UserInput: ObservableObject {
#Published var inputs: [Input] = [Input]()
}
//ContentView
struct ContentView: View {
#ObservedObject var input = UserInput()
var body: some View {
LoopInputs()
}
func LoopInputs() -> AnyView? {
var inputs: some View {
VStack {
ForEach(input.inputs) { userinput in
Text("\(userinput.bar): \(String(userinput.foo))")
Button(action: {
increment(input: String(userinput.foo))
}) {
Text("Increase")
}
}
}
}
return AnyView(inputs)
}
func increment(input: String) {
var lead = Int(input) ?? 0
lead += 1
// input = String(lead)
}
}
Wouldn't this be easier and more elegant?

Published computed properties in SwiftUI model objects

Suppose I have a data model in my SwiftUI app that looks like the following:
class Tallies: Identifiable, ObservableObject {
let id = UUID()
#Published var count = 0
}
class GroupOfTallies: Identifiable, ObservableObject {
let id = UUID()
#Published var elements: [Tallies] = []
}
I want to add a computed property to GroupOfTallies that resembles the following:
// Returns the sum of counts of all elements in the group
var cumulativeCount: Int {
return elements.reduce(0) { $0 + $1.count }
}
However, I want SwiftUI to update views when the cumulativeCount changes. This would occur either when elements changes (the array gains or loses elements) or when the count field of any contained Tallies object changes.
I have looked into representing this as an AnyPublisher, but I don't think I have a good enough grasp on Combine to make it work properly. This was mentioned in this answer, but the AnyPublisher created from it is based on a published Double rather than a published Array. If I try to use the same approach without modification, cumulativeCount only updates when the elements array changes, but not when the count property of one of the elements changes.
There are multiple issues here to address.
First, it's important to understand that SwiftUI updates the view's body when it detects a change, either in a #State property, or from an ObservableObject (via #ObservedObject and #EnvironmentObject property wrappers).
In the latter case, this is done either via a #Published property, or manually with objectWillChange.send(). objectWillChange is an ObservableObjectPublisher publisher available on any ObservableObject.
This is a long way of saying that IF the change in a computed property is caused together with a change of any #Published property - for example, when another element is added from somewhere:
elements.append(Talies())
then there's no need to do anything else - SwiftUI will recompute the view that observes it, and will read the new value of the computed property cumulativeCount.
Of course, if the .count property of one of the Tallies objects changes, this would NOT cause a change in elements, because Tallies is a reference-type.
The best approach given your simplified example is actually to make it a value-type - a struct:
struct Tallies: Identifiable {
let id = UUID()
var count = 0
}
Now, a change in any of the Tallies objects would cause a change in elements, which will cause the view that "observes" it to get the now-new value of the computed property. Again, no extra work needed.
If you insist, however, that Tallies cannot be a value-type for whatever reason, then you'd need to listen to any changes in Tallies by subscribing to their .objectWillChange publishers:
class GroupOfTallies: Identifiable, ObservableObject {
let id = UUID()
#Published var elements: [Tallies] = [] {
didSet {
cancellables = [] // cancel the previous subscription
elements.publisher
.flatMap { $0.objectWillChange }
.sink(receiveValue: self.objectWillChange.send)
.store(in: &cancellables)
}
}
private var cancellables = Set<AnyCancellable>
var cumulativeCount: Int {
return elements.reduce(0) { $0 + $1.count } // no changes here
}
}
The above will subscribe a change in the elements array (to account for additions and removals) by:
converting the array into a Sequence publisher of each array element
then flatMap again each array element, which is a Tallies object, into its objectWillChange publisher
then for any output, call objectWillChange.send(), to notify of the view that observes it of its own changes.
This is similar to the last option of #New Devs answer, but a little shorter, essentially just passing the objectWillChange notification to the parent object:
import Combine
class Tallies: Identifiable, ObservableObject {
let id = UUID()
#Published var count = 0
func increase() {
count += 1
}
}
class GroupOfTallies: Identifiable, ObservableObject {
let id = UUID()
var sinks: [AnyCancellable] = []
#Published var elements: [Tallies] = [] {
didSet {
sinks = elements.map {
$0.objectWillChange.sink( receiveValue: objectWillChange.send)
}
}
}
var cumulativeCount: Int {
return elements.reduce(0) { $0 + $1.count }
}
}
SwiftUI Demo:
struct ContentView: View {
#ObservedObject
var group: GroupOfTallies
init() {
let group = GroupOfTallies()
group.elements.append(contentsOf: [Tallies(), Tallies()])
self.group = group
}
var body: some View {
VStack(spacing: 50) {
Text( "\(group.cumulativeCount)")
Button( action: group.elements.first!.increase) {
Text( "Increase first")
}
Button( action: group.elements.last!.increase) {
Text( "Increase last")
}
}
}
}
The simplest & fastest is to use value-type model.
Here is a simple demo. Tested & worked with Xcode 12 / iOS 14
struct TestTallies: View {
#StateObject private var group = GroupOfTallies() // SwiftUI 2.0
// #ObservedObject private var group = GroupOfTallies() // SwiftUI 1.0
var body: some View {
VStack {
Text("Cumulative: \(group.cumulativeCount)")
Divider()
Button("Add") { group.elements.append(Tallies(count: 1)) }
Button("Update") { group.elements[0].count = 5 }
}
}
}
struct Tallies: Identifiable { // << make struct !!
let id = UUID()
var count = 0
}
class GroupOfTallies: Identifiable, ObservableObject {
let id = UUID()
#Published var elements: [Tallies] = []
var cumulativeCount: Int {
return elements.reduce(0) { $0 + $1.count }
}
}

SwiftUI / Combine : Listening array items value change

I want to display multiple text fields, representing scores of each part of a match.
Example : For a volleyball match, we have 25/20, 25/22, 25/23. The global score is 3/0.
The global components architecture :
>> ParentComponent
>> MainComponent
>> X TextFieldsComponent (2 text fields, home/visitor score)
The lowest component, TextFieldsComponent, contains basic bindings :
struct TextFieldsComponent: View {
#ObservedObject var model: Model
class Model: ObservableObject, Identifiable, CustomStringConvertible {
let id: String
#Published var firstScore: String
#Published var secondScore: String
var description: String {
"\(firstScore) \(secondScore)"
}
init(id: String, firstScore: String = .empty, secondScore: String = .empty) {
self.id = id
self.firstScore = firstScore
self.secondScore = secondScore
}
}
var body: some View {
HStack {
TextField("Dom.", text: $model.firstScore)
.keyboardType(.numberPad)
TextField("Ext.", text: $model.secondScore)
.keyboardType(.numberPad)
}
}
}
The parent component needs to show the total score of all parts of the match. And I wanted to try a Combine binding/stream to get the total score.
I tried multiple solutions and I ended up with this non-working code (the reduce seems to not be take all the elements of the array but internally stores a previous result) :
struct MainComponent: View {
#ObservedObject var model: Model
#ObservedObject private var totalScoreModel: TotalScoreModel
class Model: ObservableObject {
#Published var scores: [TextFieldsComponent.Model]
init(scores: [TextFieldsComponent.Model] = [TextFieldsComponent.Model(id: "main")]) {
self.scores = scores
}
}
private final class TotalScoreModel: ObservableObject {
#Published var totalScore: String = ""
private var cancellable: AnyCancellable?
init(publisher: AnyPublisher<String, Never>) {
cancellable = publisher.print().sink {
self.totalScore = $0
}
}
}
init(model: Model) {
self.model = model
totalScoreModel = TotalScoreModel(
publisher: Publishers.MergeMany(
model.scores.map {
Publishers.CombineLatest($0.$firstScore, $0.$secondScore)
.map { ($0.0, $0.1) }
.eraseToAnyPublisher()
}
)
.reduce((0, 0), { previous, next in
guard let first = Int(next.0), let second = Int(next.1) else { return previous }
return (
previous.0 + (first == second ? 0 : (first > second ? 1 : 0)),
previous.1 + (first == second ? 0 : (first > second ? 0 : 1))
)
})
.map { "[\($0.0)] - [\($0.1)]" }
.eraseToAnyPublisher()
)
}
var body: some View {
VStack {
Text(totalScoreModel.totalScore)
ForEach(model.scores) { score in
TextFieldsComponent(model: score)
}
}
}
}
I'm searching for a solution to get an event on each binding change, and merge it in a single stream, to display it in MainComponent.
N/B: The TextFieldsComponent needs to be usable in standalone too.
MergeMany is the correct approach here, as you started out yourself, though I think you overcomplicated things.
If you want to display the total score in the View (and let's say the total score is "owned" by Model instead of TotalScoreModel, which makes sense since it owns the underlying scores), you'd then need to signal that this model will change when any of the underlying scores will change.
Then you can provide the total score as a computed property, and SwiftUI will read the updated value when it recreates the view.
class Model: ObservableObject {
#Published var scores: [TextFieldsComponent.Model]
var totalScore: (Int, Int) {
scores.map { ($0.firstScore, $0.secondScore) }
.reduce((0,0)) { $1.0 > $1.1 ? ( $0.0 + 1, $0.1 ) : ($0.0, $0.1 + 1) }
}
private var cancellables = Set<AnyCancellable>()
init(scores: [TextFieldsComponent.Model] = [.init(id: "main")]) {
self.scores = scores
// get the ObservableObjectPublisher publishers
let observables = scores.map { $0.objectWillChange }
// notify that this object will change when any of the scores change
Publishers.MergeMany(observables)
.sink(receiveValue: self.objectWillChange.send)
.store(in: &cancellables)
}
}
Then, in the View, you can just use the Model.totalScore as usual:
#ObservedObject var model: Model
var body: some View {
Text(model.totalScore)
}

SwiftUI example for autocompletion

I'm a SwiftUI beginner. I have an array of "values" provided by an API and what I want is to make autocompletion when we tap characters in a "textfield". Can you please provide me an example of code for SwiftUI which can do this stuff ?
What I mean by autocompletion is this :
I have my own values and not those provided by google such here;
thx
The code from this repository used: https://github.com/simonloewe/TextFieldInputPrediction
And modified so the predictions are returned as a list like this:
//
// ContentView.swift
// StackOverflow
//
// Created by Simon Löwe on 04.04.20.
// Copyright © 2020 Simon Löwe. All rights reserved.
//
import SwiftUI
struct ContentView: View {
#State var textFieldInput: String = ""
#State var predictableValues: Array<String> = ["First", "Second", "Third", "Fourth"]
#State var predictedValue: Array<String> = []
var body: some View {
VStack(alignment: .leading){
Text("Predictable Values: ").bold()
HStack{
ForEach(self.predictableValues, id: \.self){ value in
Text(value)
}
}
PredictingTextField(predictableValues: self.$predictableValues, predictedValues: self.$predictedValue, textFieldInput: self.$textFieldInput)
.textFieldStyle(RoundedBorderTextFieldStyle())
// This is the only modification from the example in the repository
List() {
ForEach(self.predictedValue, id: \.self){ value in
Text(value)
}
}
}.padding()
}
}
/// TextField capable of making predictions based on provided predictable values
struct PredictingTextField: View {
/// All possible predictable values. Can be only one.
#Binding var predictableValues: Array<String>
/// This returns the values that are being predicted based on the predictable values
#Binding var predictedValues: Array<String>
/// Current input of the user in the TextField. This is Binded as perhaps there is the urge to alter this during live time. E.g. when a predicted value was selected and the input should be cleared
#Binding var textFieldInput: String
/// The time interval between predictions based on current input. Default is 0.1 second. I would not recommend setting this to low as it can be CPU heavy.
#State var predictionInterval: Double?
/// Placeholder in empty TextField
#State var textFieldTitle: String?
#State private var isBeingEdited: Bool = false
init(predictableValues: Binding<Array<String>>, predictedValues: Binding<Array<String>>, textFieldInput: Binding<String>, textFieldTitle: String? = "", predictionInterval: Double? = 0.1){
self._predictableValues = predictableValues
self._predictedValues = predictedValues
self._textFieldInput = textFieldInput
self.textFieldTitle = textFieldTitle
self.predictionInterval = predictionInterval
}
var body: some View {
TextField(self.textFieldTitle ?? "", text: self.$textFieldInput, onEditingChanged: { editing in self.realTimePrediction(status: editing)}, onCommit: { self.makePrediction()})
}
/// Schedules prediction based on interval and only a if input is being made
private func realTimePrediction(status: Bool) {
self.isBeingEdited = status
if status == true {
Timer.scheduledTimer(withTimeInterval: self.predictionInterval ?? 1, repeats: true) { timer in
self.makePrediction()
if self.isBeingEdited == false {
timer.invalidate()
}
}
}
}
/// Capitalizes the first letter of a String
private func capitalizeFirstLetter(smallString: String) -> String {
return smallString.prefix(1).capitalized + smallString.dropFirst()
}
/// Makes prediciton based on current input
private func makePrediction() {
self.predictedValues = []
if !self.textFieldInput.isEmpty{
for value in self.predictableValues {
if self.textFieldInput.split(separator: " ").count > 1 {
self.makeMultiPrediction(value: value)
}else {
if value.contains(self.textFieldInput) || value.contains(self.capitalizeFirstLetter(smallString: self.textFieldInput)){
if !self.predictedValues.contains(String(value)) {
self.predictedValues.append(String(value))
}
}
}
}
}
}
/// Makes predictions if the input String is splittable
private func makeMultiPrediction(value: String) {
for subString in self.textFieldInput.split(separator: " ") {
if value.contains(String(subString)) || value.contains(self.capitalizeFirstLetter(smallString: String(subString))){
if !self.predictedValues.contains(value) {
self.predictedValues.append(value)
}
}
}
}
}
Provides the following outcome:
Tested on Version 11.5 and iOS 13.5