I have a structure to display a list of bank / ATM withdrawal entries, and another structure to create a new entry for display in the list. When I press the save button after entering a new entry, operation is returning to the list structure and displaying all entries but the current entry before the method addNewWithdrawal() has had a chance to save the new entry (checked with a breakpoint).
So I have a timing issue occurring at the bottom of the saveButton() function--the question is how to fix it. It appears that I need to separate the two operations by placing things on separate threads and delaying the main thread until the save operation completes. But both threads are tied to views requiring the main thread.
Let me know if you would like to see the list view logic.
struct WdModel: Codable, Identifiable, Hashable {
var id = UUID()
var wdDate: Date // bank withdrawal data
var wdCode: String
var wdBank: String
var wdAmtL: Double
var wdAmtH: Double
var wdCity: String
var wdState: String
var wdCountry: String
}
class Withdrawal: ObservableObject {
#Published var wdArray: [WdModel] {
didSet {
// save withdrawal entries
if let encoded = try? JSONEncoder().encode(wdArray) { // save withdrawal entries
UserDefaults.standard.set(encoded, forKey: StorageKeys.wdBank.rawValue)
}
}
}
init() {
if let wdArray = UserDefaults.standard.data(forKey: StorageKeys.wdBank.rawValue) {
if let decoded = try? JSONDecoder().decode([WdModel].self, from: wdArray) {
self.wdArray = decoded
return
}
}
self.wdArray = []
if let encoded = try? JSONEncoder().encode(wdArray) {
UserDefaults.standard.set(encoded, forKey: StorageKeys.wdBank.rawValue)
}
}
// save new withdrawal data
func addNewWithdrawal(wdDate: Date, wdCode: String, wdBank: String, wdAmtL: Double, wdAmtH: Double, wdCity: String, wdState: String, wdCountry: String) {
self.withdrawalTotal += wdAmtH
let item = WdModel(wdDate: wdDate, wdCode: wdCode, wdBank: wdBank, wdAmtL: wdAmtL, wdAmtH: wdAmtH, wdCity: wdCity, wdState: wdState, wdCountry: wdCountry)
self.wdArray.append(item)
}
Below is the code to save the newly created entry
// the save button has been pressed
func saveButton() {
// get coordinates and address
// convert amount as string to double
let moneyD = (wdAmtL as NSString).doubleValue
let wdCode = currencies.curItem[userData.entryCur].curCode
// get the local currency equivalent
let rate = currencies.curItem[userData.entryCur].curRate
// get local currency amount: use inverse rate
wdAmtH = moneyD * (1 / rate)
**** Problem is occurring here when dismiss() and return to the list view happens before the data is saved (wdvm.addNewWithdrawal()
// save entry to user defaults
wdvm.addNewWithdrawal(wdDate: wdDate, wdCode: wdCode, wdBank: wdBank, wdAmtL: moneyD, wdAmtH: wdAmtH, wdCity: wdCity, wdState: wdState, wdCountry: wdCountry)
}
dismiss()
}
struct WithdrawalView: View {
#StateObject var wdvm = Withdrawal()
var uniqueBankDates: [String] {
Array(Set(wdvm.wdArray)) // This will remove duplicates, but WdModel needs to be Hashable
.sorted { $0.wdDate < $1.wdDate } // Compare dates
.compactMap {
$0.wdDate.formatted(date: .abbreviated, time: .omitted) // Return an array of formatted the dates
}
}
// filters entries for the given date
func bankEntries(for date: String) -> [WdModel] {
return wdvm.wdArray.filter { $0.wdDate.formatted(date: .abbreviated, time: .omitted) == date }
}
var body: some View {
GeometryReader { g in
VStack (alignment: .leading) {
WDTitleView(g: g)
List {
if wdvm.wdArray.isEmpty {
NoItemsView()
} else {
// outer ForEach with unique dates
ForEach(uniqueBankDates, id: \.self) { dateItem in // change this to sort by date
Section {
// inner ForEach with items of this date
ForEach(bankEntries(for: dateItem)) { item in
wdRow(g: g, item: item)
}
} header: {
Text("\(dateItem)")
}
}.onDelete(perform: deleteItem)
}
}
.navigationBarTitle("Bank Withdrawals", displayMode: .inline)
.environment(\.defaultMinListRowHeight, g.size.height > g.size.width ? 9 : 5)
.navigationBarItems(trailing: NavigationLink (destination: AddWithdrawalView()) {
Image(systemName: "plus")
.resizable()
.frame(width: 18, height: 18)
})
}
}
if !wdvm.wdArray.isEmpty {
ShowWdTotal()
}
}
// may delete entries with swipe left
func deleteItem(at offsets: IndexSet) {
var money: Double = 0
offsets.forEach { singleOffset in
money = wdvm.wdArray[singleOffset].wdAmtH
wdvm.withdrawalTotal -= money
wdvm.wdArray.remove(atOffsets: offsets)
}
}
}
Related
This is a module where the user enters transaction data and then it is saved to coreData. EntryView calls getFormData for entry of form data then calls the function saveButton() for saving the data to coreData.
Things have been working great until I recently added two additional parameters gotCountry and gotHome. These parameters are defined in EntryView. The data I want is found in getFormData but I don't want to make it available to other parts of the app until the save button is pressed (func saveButton()) hence I need to pass the data from getFormData to saveButton().
One of the two warnings is Initialization of immutable value 'gotCountry' was never used; consider replacing with assignment to '_' or removing it if I place let in front of the parameters gotCountry and gotHome in getFormData. These are variables so shouldn't have let in front of them. Removing 'let' results in the error Type '()' cannot conform to 'View'
The parameters entryDT and entryPT are coming from form input data while gotCountry and gotHome are coming from calculated data available at time of entry.
Note that I have stripped out some of the code to see the passing of data better.
struct EntryView: View {
#EnvironmentObject var ctTotals: CountryTotals
#State private var entryDT = Date()
#State private var entryPT: Int = 0
#State private var gotCountry: String = ""
#State private var gotHome: Double = 0.0
var body: some View {
VStack (alignment: .leading){
ShowTabTitle(g: g, title: "Enter Transaction")
getFormData(entryDT: $entryDT, entryPT: $entryPT, gotCountry: $gotCountry, gotHome: $gotHome)
Button {
self.saveButton() // button pressed
} label: {
Text ("Save")
}
}
}
func saveButton() {
// save entry to core data
let newEntry = CurrTrans(context: viewContext)
// entry id
newEntry.id = UUID()
// entry date
newEntry.entryDT = entryDT
// entry payment type
newEntry.entryPT = Int64(entryPT)
ctTotals.sendTotals(gotCountry: gotCountry, gotHome: gotHome)
do {
try viewContext.save()
} catch {
}
// reset parameters for next entry
self.entryDT = Date()
self.entryPT = 0
}
}
struct getFormData: View {
#Binding var entryDT: Date
#Binding var entryPT: Int
#Binding var gotCountry: String
#Binding var gotHome: Double
var body: some View {
// get entry date and time
DatePicker("", selection: $entryDT, in: ...Date())
// select payment type
Picker(selection: $entryPT, label: Text("")) {}
// copy data to totals by country
gotCountry = currencies.curItem[userData.entryCur].cunName
gotHome = totalValue
}
}
struct CtModel: Codable, Identifiable, Hashable {
var id = UUID()
var ctName: String
var ctHome: Double
}
class CountryTotals: ObservableObject {
#Published var ctItem: [CtModel] {
didSet {
if let encoded = try? JSONEncoder().encode(ctItem) {
UserDefaults.standard.set(encoded, forKey: StorageKeys.ctTotals.rawValue)
}
}
}
init() {
}
func sendTotals(gotCountry: String, gotHome: Double) -> () {
let item = CtModel(ctName: gotCountry, ctHome: gotHome)
ctItem.append(item)
}
}
I work with this API and I figured out how to parse all the data from it, except one value – payload weight. The problem is I have to parse it by id – "leo", but I don't understand how to do this.
This is my code:
// MARK: - API
class InfoApi {
func getRockets(completion: #escaping ([RocketInfo]) -> ()) {
guard let url = URL(string: "https://api.spacexdata.com/v4/rockets") else {
return
}
URLSession.shared.dataTask(with: url) { (data, response, error) in
do {
let rocketsInfo = try JSONDecoder().decode([RocketInfo].self, from: data!)
DispatchQueue.main.async {
completion(rocketsInfo)
}
} catch {
print(error.localizedDescription)
}
}
.resume()
}
}
// MARK: - MODEL
struct RocketInfo: Codable, Identifiable {
let id = UUID()
let name: String
let firstFlight: String
let country: String
let costPerLaunch: Int
let firstStage: StageInfo
let payloadWeights: [Payload]
enum CodingKeys: String, CodingKey {
case id
case name
case firstFlight = "first_flight"
case country
case costPerLaunch = "cost_per_launch"
case firstStage = "first_stage"
case payloadWeights = "payload_weights"
}
// MARK: - STAGE
struct StageInfo: Codable {
let engines: Int
let fuelAmountTons: Double
let burnTimeSec: Int?
enum CodingKeys: String, CodingKey {
case engines
case fuelAmountTons = "fuel_amount_tons"
case burnTimeSec = "burn_time_sec"
}
static let firstStage = StageInfo(engines: 1, fuelAmountTons: 44.3, burnTimeSec: 169)
static let secondStage = StageInfo(engines: 1, fuelAmountTons: 3.30, burnTimeSec: 378)
}
// MARK: - PAYLOAD
struct Payload: Codable {
let id: String
let kg: Int
let lb: Int
static let payloadWeights = Payload(id: "leo", kg: 450, lb: 992)
}
// MARK: - EXAMPLE
static let example = RocketInfo(name: "Falcon 1", firstFlight: "2006-03-24", country: "Republic of the Marshall Islands", costPerLaunch: 6700000, firstStage: StageInfo.firstStage, payloadWeights: [Payload.payloadWeights])
}
// MARK: - CONTENT VIEW
struct ParametersView: View {
#State var rockets: [RocketInfo] = []
var body: some View {
List(rockets) { rocket in
VStack(spacing: 20) {
HStack {
Text("First flight of \(rocket.name)")
Spacer()
Text("\(rocket.firstFlight)")
}
HStack {
Text("Payload of \(rocket.name)")
Spacer()
Text("\(rocket.payloadWeights[0].kg)") //<-- Here I try to parse a payload weight value
}
}
}
.onAppear {
InfoApi().getRockets { rockets in
self.rockets = rockets
}
}
}
}
// MARK: - PREVIEW
struct ParametersView_Previews: PreviewProvider {
static var previews: some View {
ParametersView()
}
}
I can access payload weight value by pointing an index of the first element of the Payload array in API, but I want to figure out how I can get this value by special id – "Leo".
In API it looks this way:
You can use first(where:) to search through the array and return the first element matching a condition (in this case, matching a certain id):
if let leo = rocket.payloadWeights.first(where: { $0.id == "leo" }) {
Text("\(leo.kg)") //<-- Here I try to parse a payload weight value
}
I'm trying to save the users favorite cities in UserDefaults. Found this solution saving the struct ID - builds and runs but does not appear to be saving: On app relaunch, the previously tapped Button is reset.
I'm pretty sure I'm missing something…
Here's my data struct and class:
struct City: Codable {
var id = UUID().uuidString
var name: String
}
class Favorites: ObservableObject {
private var cities: Set<String>
let defaults = UserDefaults.standard
var items: [City] = [
City(name: "London"),
City(name: "Paris"),
City(name: "Berlin")
]
init() {
let decoder = PropertyListDecoder()
if let data = defaults.data(forKey: "Favorites") {
let cityData = try? decoder.decode(Set<String>.self, from: data)
self.cities = cityData ?? []
return
} else {
self.cities = []
}
}
func getTaskIds() -> Set<String> {
return self.cities
}
func contains(_ city: City) -> Bool {
cities.contains(city.id)
}
func add(_ city: City) {
objectWillChange.send()
cities.contains(city.id)
save()
}
func remove(_ city: City) {
objectWillChange.send()
cities.remove(city.id)
save()
}
func save() {
let encoder = PropertyListEncoder()
if let encoded = try? encoder.encode(tasks) {
defaults.setValue(encoded, forKey: "Favorites")
}
}
}
and here's the TestDataView
struct TestData: View {
#StateObject var favorites = Favorites()
var body: some View {
ForEach(self.favorites.items, id: \.id) { item in
VStack {
Text(item.title)
Button(action: {
if self.favorites.contains(item) {
self.favorites.remove(item)
} else {
self.favorites.add(item)
}
}) {
HStack {
Image(systemName: self.favorites.contains(item) ? "heart.fill" : "heart")
.foregroundColor(self.favorites.contains(item) ? .red : .white)
}
}
}
}
}
}
There were a few issues, which I'll address below. Here's the working code:
struct ContentView: View {
#StateObject var favorites = Favorites()
var body: some View {
VStack(spacing: 10) {
ForEach(Array(self.favorites.cities), id: \.id) { item in
VStack {
Text(item.name)
Button(action: {
if self.favorites.contains(item) {
self.favorites.remove(item)
} else {
self.favorites.add(item)
}
}) {
HStack {
Image(systemName: self.favorites.contains(item) ? "heart.fill" : "heart")
.foregroundColor(self.favorites.contains(item) ? .red : .black)
}
}
}
}
}
}
}
struct City: Codable, Hashable {
var id = UUID().uuidString
var name: String
}
class Favorites: ObservableObject {
#Published var cities: Set<City> = []
#Published var favorites: Set<String> = []
let defaults = UserDefaults.standard
var initialItems: [City] = [
City(name: "London"),
City(name: "Paris"),
City(name: "Berlin")
]
init() {
let decoder = PropertyListDecoder()
if let data = defaults.data(forKey: "Cities") {
cities = (try? decoder.decode(Set<City>.self, from: data)) ?? Set(initialItems)
} else {
cities = Set(initialItems)
}
self.favorites = Set(defaults.array(forKey: "Favorites") as? [String] ?? [])
}
func getTaskIds() -> Set<String> {
return self.favorites
}
func contains(_ city: City) -> Bool {
favorites.contains(city.id)
}
func add(_ city: City) {
favorites.insert(city.id)
save()
}
func remove(_ city: City) {
favorites.remove(city.id)
save()
}
func save() {
let encoder = PropertyListEncoder()
if let encoded = try? encoder.encode(self.cities) {
self.defaults.set(encoded, forKey: "Cities")
}
self.defaults.set(Array(self.favorites), forKey: "Favorites")
defaults.synchronize()
}
}
Issues with the original:
The biggest issue was that items was getting recreated on each new launch and City has an id that is assigned a UUID on creation. This guaranteed that every new launch, each batch of cities would have different UUIDs, so a saving situation would never work.
There were some general typos and references to properties that didn't actually exist.
What I did:
Made cities and favorites both #Published properties so that you don't have to call objectWillChange.send by hand
On init, load both the cities and the favorites. That way, the cities, once initially created, will always have the same UUIDs, since they're getting loaded from a saved state
On save, I save both Sets -- the favorites and the cities
In the original ForEach, I iterate through all of the cities and then only mark the ones that are part of favorites
Important note: While testing this, I discovered that at least on Xcode 12.3 / iOS 14.3, syncing to UserDefaults is slow, even when using the now-unrecommended synchronize method. I kept wondering why my changes weren't reflected when I killed and then re-opened the app. Eventually figured out that everything works if I give it about 10-15 seconds to sync to UserDefaults before killing the app and then opening it again.
I have a project which uses Core Data to store measurement values. The user can add new measurements to be persisted, and the user can edit persisted measurements.
The issue that I am experiencing is seen when attempting to edit a persisted measurement. After selecting a persisted measurement, the user is presented with the view to edit and save the measurement. The selected measurement is passed from the list to the presented view, where the value populates a TextField. Unfortunately, the value does not populate the TextField when the view is presented the first time within the app. Only after the second presentation does the measurement value populate the TextField.
The user can present the view to add a new measurement to be persisted, cancel and dismiss it, select an existing measurement, and that measurement's value will be displayed in the presented TextField. It seems that the initial presentation of the view used to add/edit a measurement does not contain the selected measurement on the first presentation. Only after the first presentation and dismissal will the value populate the TextField.
Below, you can see a 22sec GIF, which displays the current behavior.
In the GIF, you can see that a persisted measurement is selected, and the presented view's TextField is not populated with the measurement's value. Only on the second presentation is it populated. The last half of the GIF shows the process for persisting a new measurement and that the TextField is populated with that measurement's value on a subsequent presentation.
If you wish to reproduce the described behavior, then you can find the project's repository here, using the feature/edit-measurement branch, which the URL points to.
Steps to Reproduce
Launch application
Select any parameter from the list
Tap the trailing navigation button
Enter a value into the TextField
Tap the Save button
Return to the root view (list of parameters)
Select the parameter of which you saved the measurement value in step 5
Select the newly-persisted measurement in the list
Notice the unpopulated TextField
Tap the cancel button or dismiss the view by dragging downward
Select the same measurement that was selected in step 7
Notice the populated TextField
Below is the implementation of the view that displays persisted measurements:
import SwiftUI
struct ParameterMeasurementsLogView: View {
// MARK: Properties
let parameter: Parameter
#Environment(\.managedObjectContext) var managedObjectContext
#StateObject private var measurementStore = MeasurementStore()
#State private var displayMeasurementEntryView = false
#State private var selectedMeasurementIndex: Int?
private var measurementsRequest: FetchRequest<ParameterMeasurement>
private var measurements: FetchedResults<ParameterMeasurement> { measurementsRequest.wrappedValue }
private var measurementValues: [Double] { (measurements.map { $0.value }) }
private var measurementDeltas: [Double?] { measurementValues.deltasBetweenElements() }
private var measurementFormatter: MeasurementFormatter {
let formatter = MeasurementFormatter()
let numberFormatter = NumberFormatter()
numberFormatter.alwaysShowsDecimalSeparator = false
numberFormatter.maximumFractionDigits = 2
numberFormatter.numberStyle = .decimal
numberFormatter.usesGroupingSeparator = true
formatter.numberFormatter = numberFormatter
formatter.unitOptions = .providedUnit
formatter.unitStyle = .medium
return formatter
}
var body: some View {
List {
ForEach(measurements.indices, id: \.self) { index in
Button(action: {
selectedMeasurementIndex = index
displayMeasurementEntryView = true
}, label: {
HStack(content: {
VStack(alignment: .leading) {
Text(formattedMeasurement(at: index))
if index < measurements.count - 1 {
HStack(content: {
Image(systemName: deltaIconName(at: index))
Text(deltaString(for: index))
})
}
if let date = measurements[index].date {
FormattedDateTimeView(date: date)
}
}
})
})
}
.onDelete(perform: deleteMeasurements(at:))
}
.navigationTitle(parameter.name)
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing: Button(action: {
displayMeasurementEntryView = true
}, label: {
Image(systemName: Icon.plusCircleFill)
}))
.sheet(isPresented: $displayMeasurementEntryView, onDismiss: {
selectedMeasurementIndex = nil
}) {
ParameterMeasurementEntryView(parameter: parameter, entryMode: entryMode())
.environment(\.managedObjectContext, managedObjectContext)
}
}
// MARK: Initialization
init(parameter: Parameter) {
self.parameter = parameter
let entity = ParameterMeasurement.entity()
let sortDescriptors = [NSSortDescriptor(key: #keyPath(ParameterMeasurement.date), ascending: false)]
let predicateFormat = "%K =[c] %#"
let predicateArguments = [#keyPath(ParameterMeasurement.parameterName), parameter.name]
let predicate = NSPredicate(format: predicateFormat, argumentArray: predicateArguments)
measurementsRequest = FetchRequest(entity: entity, sortDescriptors: sortDescriptors, predicate: predicate, animation: .none)
}
// MARK: Deletion
private func deleteMeasurements(at offsets: IndexSet) {
offsets.forEach { managedObjectContext.delete(measurements[$0]) }
PersistenceStack.saveContext()
}
// MARK: Helpers
private func formattedMeasurement(at index: Int) -> String {
let value = measurements[index].value
switch parameter.measurementUnit {
case .unitDispersion(units: _, defaultUnit: let unit):
let measurement = Measurement<Unit>(value: value, unit: unit)
return measurementFormatter.string(from: measurement)
}
}
private func deltaIconName(at index: Int) -> String {
guard let delta = measurementDeltas[index] else { fatalError("Expected delta") }
if delta == 0 { return Icon.arrowUpArrowDown }
return delta > 0 ? Icon.arrowUp : Icon.arrowDown
}
private func deltaString(for index: Int) -> String {
guard let delta = measurementDeltas[index] else { return "" }
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
let absolute = abs(delta)
guard let formatted = formatter.string(from: absolute as NSNumber) else { fatalError("Expected formatted delta") }
return formatted
}
private func deltaBetweenMeasurement(at firstIndex: Int, and secondIndex: Int) -> Double {
measurementValues[firstIndex] - measurementValues[secondIndex]
}
private func entryMode() -> MeasurementEntryMode {
if let index = selectedMeasurementIndex {
return .edit(measurement: measurements[index])
}
return .add
}
}
Below, you can see the implementation of the view used to add/edit a measurement and persist it.
import SwiftUI
struct ParameterMeasurementEntryView: View {
// MARK: Properties
let parameter: Parameter
let entryMode: MeasurementEntryMode
#Environment(\.presentationMode) var presentationMode
#Environment(\.managedObjectContext) var managedObjectContext
#State private var measurementValueString = ""
private var measurementValue: Double? { Double(measurementValueString) }
private var cancelButton: some View {
Button(action: {
dismiss()
}, label: {
Text("Cancel")
})
}
private var saveButton: some View {
Button(action: {
saveNewMeasurement()
dismiss()
}, label: {
Text("Save")
})
.disabled(disableSaveButton())
}
var body: some View {
NavigationView(content: {
Form(content: {
Section(header: Text("Measurement")) {
HStack {
TextField("Value", text: $measurementValueString)
Text(defaultUnitSymbol())
}
}
})
.navigationTitle(parameter.name)
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: cancelButton, trailing: saveButton)
.onAppear(perform: setMeasurmentTextIfEditingMeasurement)
})
}
// MARK: Initialization
init(parameter: Parameter, entryMode: MeasurementEntryMode) {
self.parameter = parameter
self.entryMode = entryMode
}
// MARK: Helpers
private func dismiss() {
presentationMode.wrappedValue.dismiss()
}
private func disableSaveButton() -> Bool {
let measurementIsInvalid = measurementValue == nil
if case let .edit(measurement) = entryMode {
let enteredValueEqualsCurrentValue = Double(measurementValueString) == measurement.value
return measurementIsInvalid || enteredValueEqualsCurrentValue
}
return measurementIsInvalid
}
private func saveNewMeasurement() {
guard let value = measurementValue else { return assertionFailure("Expected measurement value") }
let measurement = ParameterMeasurement(entity: ParameterMeasurement.entity(), insertInto: managedObjectContext)
measurement.value = value
measurement.date = Date()
measurement.parameterName = parameter.name
PersistenceStack.saveContext()
}
private func defaultUnitSymbol() -> String {
switch parameter.measurementUnit {
case .unitDispersion(_, defaultUnit: let defaultUnit): return defaultUnit.symbol
}
}
private func setMeasurmentTextIfEditingMeasurement() {
if case let .edit(measurment) = entryMode { measurementValueString = String(measurment.value) }
}
}
MeasurementEntryMode is a simple enum that allows the list to tell the add/entry view if it's adding a new measurement or editing an existing one.
import Foundation
enum MeasurementEntryMode {
// MARK: Cases
case add, edit(measurement: ParameterMeasurement)
}
What is causing the persisted measurement's value to not be displayed in the TextField on the first presentation of the add/edit view but be displayed on the second presentation?
Even the following trivial example yields the same results:
struct PrimaryView: View {
#State private var selectedIndex: Int?
#State private var showDetail = false
var body: some View {
NavigationView {
List {
ForEach(Array(0...50).indices, id: \.self) { index in
Button(action: {
selectedIndex = index
showDetail = true
}, label: {
Text("\(index)")
})
}
}
.sheet(isPresented: $showDetail, onDismiss: {
selectedIndex = nil
}) {
Text(String(describing: selectedIndex))
}
}
}
}
According to Justin Stanley's response to my question on Twitter, simply moving the code to set whether the detail view is shown from the Button's action to an View.onChange(of:perform:) modifier fixes the issue.
In a text field, I'd like, when a user enters a number e.g. 12345, it gets formatted as 123.45. The user never needs to enter a decimal place, it just uses the 2 right most numbers as the decimal places. The field should only allow numbers too. This is for a SwiftUI project. Thanks in advance for any assistance.
Because there of a two way binding between what you enter and what is being shown in the TextField view it seems not possible to interpolate the displayed number entered. I would suggest a small hack:
create a ZStack with a TextField and a Text View superimposed.
the foreground font of the entered text in the TextField is clear or white .foregroundColor(.clear)
the keyboard is only number without decimal point: .keyboardType(.numberPad)
use .accentColor(.clear) to hide the cursor
the results are displayed in a Text View with formatting specifier: "%.2f"
It would look like
This is the code:
struct ContentView: View {
#State private var enteredNumber = ""
var enteredNumberFormatted: Double {
return (Double(enteredNumber) ?? 0) / 100
}
var body: some View {
Form {
Section {
ZStack(alignment: .leading) {
TextField("", text: $enteredNumber)
.keyboardType(.numberPad).foregroundColor(.clear)
.textFieldStyle(PlainTextFieldStyle())
.disableAutocorrection(true)
.accentColor(.clear)
Text("\(enteredNumberFormatted, specifier: "%.2f")")
}
}
}
}
}
With Swift UI the complete solution is
TextField allow numeric value only
Should accept only one comma (".")
Restrict decimal point upto x decimal place
File NumbersOnlyViewModifier
import Foundation
import SwiftUI
import Combine
struct NumbersOnlyViewModifier: ViewModifier {
#Binding var text: String
var includeDecimal: Bool
var digitAllowedAfterDecimal: Int = 1
func body(content: Content) -> some View {
content
.keyboardType(includeDecimal ? .decimalPad : .numberPad)
.onReceive(Just(text)) { newValue in
var numbers = "0123456789"
let decimalSeparator: String = Locale.current.decimalSeparator ?? "."
if includeDecimal {
numbers += decimalSeparator
}
if newValue.components(separatedBy: decimalSeparator).count-1 > 1 {
let filtered = newValue
self.text = isValid(newValue: String(filtered.dropLast()), decimalSeparator: decimalSeparator)
} else {
let filtered = newValue.filter { numbers.contains($0)}
if filtered != newValue {
self.text = isValid(newValue: filtered, decimalSeparator: decimalSeparator)
} else {
self.text = isValid(newValue: newValue, decimalSeparator: decimalSeparator)
}
}
}
}
private func isValid(newValue: String, decimalSeparator: String) -> String {
guard includeDecimal, !text.isEmpty else { return newValue }
let component = newValue.components(separatedBy: decimalSeparator)
if component.count > 1 {
guard let last = component.last else { return newValue }
if last.count > digitAllowedAfterDecimal {
let filtered = newValue
return String(filtered.dropLast())
}
}
return newValue
}
}
File View+Extenstion
extension View {
func numbersOnly(_ text: Binding<String>, includeDecimal: Bool = false) -> some View {
self.modifier(NumbersOnlyViewModifier(text: text, includeDecimal: includeDecimal))
}
}
File ViewFile
TextField("", text: $value, onEditingChanged: { isEditing in
self.isEditing = isEditing
})
.foregroundColor(Color.neutralGray900)
.numbersOnly($value, includeDecimal: true)
.font(.system(size: Constants.FontSizes.fontSize22))
.multilineTextAlignment(.center)