I tried to build a NumberField from a TextField where the value is validated and pushed along the Binding only when the .onSubmit modifier is called. The logic seems to work fine but you will see that the NumberField is not updated properly when the submitted value is outside the specified range.
Here's my code for the View:
struct NumberField: View {
init(
value: Binding<Double>,
range: ClosedRange<Double>
) {
self._value = value
self.range = range
self._valueStore = State(initialValue: value.wrappedValue)
}
#Binding var value: Double
let range: ClosedRange<Double>
#State private var valueStore: Double
var body: some View {
TextField(
"",
value: .init(
get: {
valueStore
},
set: { newValue in
valueStore = validate(
newValue,
range: range
)
}),
formatter: NumberFormatter()
)
.onSubmit {
value = valueStore
}
.onChange(of: value) { newValue in
guard newValue != valueStore else {
return
}
valueStore = newValue
}
}
func validate(_ newValue: Double, range: ClosedRange<Double>) -> Double {
let validatedValue = clamp(newValue, to: range)
print("validate - newValue: \(newValue) - validated: \(validatedValue)")
return validatedValue
}
func clamp(_ value: Double, to range: ClosedRange<Double>) -> Double {
min(max(range.lowerBound, value), range.upperBound)
}
}
You can use it like so in a playground (or a macOS app):
struct MainView: View {
#State var value = 2.0
var body: some View {
VStack {
Text("value: \(value)")
NumberField(value: $value, range: 2.0 ... 10)
NumberField(value: $value, range: 2.0 ... 10)
}
.frame(width: 200)
.padding()
}
}
Related
I'm trying to implement some TextFields that accept any number in a desired range. This is, if the user is entering an value, I'd like it to be from min to max dynamically , for example. However, I don't know how to control this in a TextField.
struct Container {
var textInput: Double
}
struct ContentView: View {
#State private var container = Container
var body: some View {
TextField("", value: $container.textInput, format: .number)
.keyboardType(.decimalPad)
.frame(width: 200, height: 20)
.padding()
}
}
Had the exact same problem and came up with this:
Using a custom formatter – it’s not perfect but it works the way I want it to.
class BoundFormatter: Formatter {
var max: Int = 0
var min: Int = 0
func clamp(with value: Int, min: Int, max: Int) -> Int{
guard value <= max else {
return max
}
guard value >= min else {
return min
}
return value
}
func setMax(_ max: Int) {
self.max = max
}
func setMin(_ min: Int) {
self.min = min
}
override func string(for obj: Any?) -> String? {
guard let number = obj as? Int else {
return nil
}
return String(number)
}
override func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
guard let number = Int(string) else {
return false
}
obj?.pointee = clamp(with: number, min: self.min, max: self.max) as AnyObject
return true
}
}
Then I use it like this:
let max: Int = 100
let min: Int = 0
var formatter: BoundFormatter {
let formatter = BoundFormatter()
formatter.setMax(self.max)
formatter.setMin(self.min)
return formatter
}
#Binding var value: Int = 0
//// VIEW BODY \\\\
TextField("Number here:", value: $value, formatter: boundFormatter)
You can even improve this version by setting min max in the formatter as bindings, so you have dynamic bounds.
class BoundFormatter: Formatter {
#Binding var max: Int
#Binding var min: Int
// you have to add initializers
init(min: Binding<Int>, max: Binding<Int>) {
self._min = min
self._max = max
super.init()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented"
}
...
}
/// usage
TextField("Number here:", value: $value, formatter: BoundFormatter(min: .constant(0), max: $max))
TextField(
.onChange(of: text, perform: {
text = String($0.prefix(1))
})
I tested my app with Instruments and found no leaks but memory increases every time any value is updated and My app is updated every 0.5 second in multiple places. After running for about 20 mins, My app crashed and i got "Terminated due to memory issue" message.
I tried to find out which place cause this issue and ItemView seems to be causing the problem. I created a code snippet and the test result is below.
Please explain what's wrong with my code.
Thanks for any help!
import SwiftUI
import Combine
struct ContentView: View {
#State private var laserPower: Float = 0
var body: some View {
VStack {
ItemView(
title: "Laser Power",
value: $laserPower,
unit: "W",
callback: { (newValue) in
print("setPower")
//Api.setPower(watts: newValue)
}
)
}
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in
//Memory increases every time the laserPower's value is updated.
laserPower = Float(Int.random(in: 1..<160) * 10)
}
}
}
}
struct ItemView: View {
let title: String
#Binding var value: Float
let unit: String
let callback: (_ newValue: Float) -> Void
#State private var stringValue = ""
#State private var isEditingValue = false
#State var editingValue: Float = 0
func getStringValue(from value: Float) -> String {
return String(format: "%.1f", value)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text(title)
Spacer()
}
HStack {
TextField(
"",
text: $stringValue,
onEditingChanged: { editingChanged in
DispatchQueue.main.async {
if !editingChanged {
if stringValue.contain(pattern: "^-?\\d+\\.\\d$") {
editingValue = Float(stringValue)!
} else if stringValue.contain(pattern: "^-?\\.\\d$") {
stringValue = "0.\(stringValue.split(separator: ".", omittingEmptySubsequences: false)[1])"
editingValue = Float(stringValue)!
} else if stringValue.contain(pattern: "^-?\\d+\\.?$") {
stringValue = "\(stringValue.split(separator: ".", omittingEmptySubsequences: false)[0]).0"
editingValue = Float(stringValue)!
} else {
stringValue = getStringValue(from: value)
}
callback(Float(getStringValue(from: editingValue))!)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isEditingValue = false
}
} else {
isEditingValue = true
}
}
},
onCommit: {
DispatchQueue.main.async {
callback(Float(getStringValue(from: editingValue))!)
}
}
)
.font(.title)
.multilineTextAlignment(.center)
.padding(4)
.overlay(RoundedRectangle(cornerRadius: 5).stroke())
Text(unit)
.padding(5)
}
}
.padding(10)
.onReceive(Just(stringValue)) { newValue in
if newValue.contain(pattern: "^-?\\d+\\.\\d?$") {
return
}
var filtered = newValue.filter { "0123456789.-".contains($0) }
let split = filtered.split(separator: ".", omittingEmptySubsequences: false)
if (split.count > 1 && String(split[1]).count > 1) || split.count > 2 {
let dec = split[1]
filtered = "\(split[0]).\(dec.isEmpty ? "" : String(dec[dec.startIndex]))"
}
self.stringValue = filtered
}
.onChange(of: value) { _ in
if !isEditingValue {
stringValue = getStringValue(from: self.value)
editingValue = value
}
}
.onAppear {
stringValue = getStringValue(from: value)
editingValue = value
}
}
}
extension String {
func contain(pattern: String) -> Bool {
guard let regex = try? NSRegularExpression(pattern: pattern, options: NSRegularExpression.Options()) else {
return false
}
return regex.firstMatch(in: self, options: NSRegularExpression.MatchingOptions(), range: NSMakeRange(0, self.count)) != nil
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Memory report:
Instruments report:
When I run my app and try swiping, the onDelete does not appear and doesn't work. I haven't had the chance to really test if it deletes or not because when I swipe it doesn't allow me to try deleting it. I am using RealmSwift and posted the code for the view as well as the ViewModel I use. Sorry if this isn't enough code, let me know and I'll link my GitHub repo, or share more code.
import SwiftUI
import RealmSwift
import Combine
enum ActiveAlert{
case error, noSauce
}
struct DoujinView: View {
#ObservedObject var doujin: DoujinAPI
// #ObservedResults(DoujinInfo.self) var doujinshis
#State private var detailViewShowing: Bool = false
#State private var selectedDoujin: DoujinInfo?
#StateObject var doujinModel = DoujinInfoViewModel()
var body: some View {
//Code if there are any Doujins
ScrollView(.vertical) {
LazyVStack(spacing: 0) {
ForEach(doujinModel.doujins, id: \.UniqueID) { doujinshi in
Button(action: {
self.detailViewShowing = true
self.doujinModel.selectedDoujin = doujinshi
}) {
DoujinCell(image: convertBase64ToImage(doujinshi.PictureString))
}
}
.onDelete(perform: { indexSet in
self.doujinModel.easyDelete(at: indexSet)
})
//This will preseent the sheet that displays information for the doujin
.sheet(isPresented: $detailViewShowing, onDismiss: {if doujinModel.deleting == true {doujinModel.deleteDoujin()}}, content: {
DoujinInformation(theAPI: doujin, doujinModel: doujinModel)
})
// Loading circle
if doujin.loadingCircle == true{
LoadingCircle(theApi: doujin)
}
}
}
}
}
enum colorSquare:Identifiable{
var id: Int{
hashValue
}
case green
case yellow
case red
}
class DoujinInfoViewModel: ObservableObject{
var theDoujin:DoujinInfo? = nil
var realm:Realm?
var token: NotificationToken? = nil
#ObservedResults(DoujinInfo.self) var doujins
#Published var deleting:Bool = false
#Published var selectedDoujin:DoujinInfo? = nil
#Published var loading:Bool = false
init(){
let realm = try? Realm()
self.realm = realm
token = doujins.observe({ (changes) in
switch changes{
case .error(_):break
case .initial(_): break
case .update(_, deletions: _, insertions: _, modifications: _):
self.objectWillChange.send() }
})
}
deinit {
token?.invalidate()
}
var name: String{
get{
selectedDoujin!.Name
}
}
var id: String {
get {
selectedDoujin!.Id
}
}
var mediaID:String {
get {
selectedDoujin!.MediaID
}
}
var numPages:Int{
get {
selectedDoujin!.NumPages
}
}
var pictureString:String {
get {
selectedDoujin!.PictureString
}
}
var uniqueId: String{
get{
selectedDoujin!.PictureString
}
}
var similarity:Double{
get {
selectedDoujin!.similarity
}
}
var color:colorSquare{
get{
switch selectedDoujin!.similarity{
case 0...50:
return .red
case 50...75:
return .yellow
case 75...100:
return .green
default:
return .green
}
}
}
var doujinTags: List<DoujinTags>{
get {
selectedDoujin!.Tags
}
}
func deleteDoujin(){
try? realm?.write{
realm?.delete(selectedDoujin!)
}
deleting = false
}
func easyDelete(at indexSet: IndexSet){
if let index = indexSet.first{
let realm = doujins[indexSet.first!].realm
try? realm?.write({
realm?.delete(doujins[indexSet.first!])
})
}
}
func addDoujin(theDoujin: DoujinInfo){
try? realm?.write({
realm?.add(theDoujin)
})
}
}
.onDelete works only for List. For LazyVStack we need to create our own swipe to delete action.
Here is the sample demo. You can modify it as needed.
SwipeDeleteRow View
struct SwipeDeleteRow<Content: View>: View {
private let content: () -> Content
private let deleteAction: () -> ()
private var isSelected: Bool
#Binding private var selectedIndex: Int
private var index: Int
init(isSelected: Bool, selectedIndex: Binding<Int>, index: Int, #ViewBuilder content: #escaping () -> Content, onDelete: #escaping () -> Void) {
self.isSelected = isSelected
self._selectedIndex = selectedIndex
self.content = content
self.deleteAction = onDelete
self.index = index
}
#State private var offset = CGSize.zero
#State private var offsetY : CGFloat = 0
#State private var scale : CGFloat = 0.5
var body : some View {
HStack(spacing: 0){
content()
.frame(width : UIScreen.main.bounds.width, alignment: .leading)
Button(action: deleteAction) {
Image("delete")
.renderingMode(.original)
.scaleEffect(scale)
}
}
.background(Color.white)
.offset(x: 20, y: 0)
.offset(isSelected ? self.offset : .zero)
.animation(.spring())
.gesture(DragGesture(minimumDistance: 30, coordinateSpace: .local)
.onChanged { gestrue in
self.offset.width = gestrue.translation.width
print(offset)
}
.onEnded { _ in
self.selectedIndex = index
if self.offset.width < -50 {
self.scale = 1
self.offset.width = -60
self.offsetY = -20
} else {
self.scale = 0.5
self.offset = .zero
self.offsetY = 0
}
}
)
}
}
Demo View
struct Model: Identifiable {
var id = UUID()
}
struct CustomSwipeDemo: View {
#State var arr: [Model] = [.init(), .init(), .init(), .init(), .init(), .init(), .init(), .init()]
#State private var listCellIndex: Int = 0
var body: some View {
ScrollView(.vertical) {
LazyVStack(spacing: 0) {
ForEach(arr.indices, id: \.self) { index in
SwipeDeleteRow(isSelected: index == listCellIndex, selectedIndex: $listCellIndex, index: index) {
if let item = self.arr[safe: index] {
Text(item.id.description)
}
} onDelete: {
arr.remove(at: index)
self.listCellIndex = -1
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
}
}
}
Helper function
//Help to preventing delete row from index out of bounds.
extension Collection where Indices.Iterator.Element == Index {
subscript (safe index: Index) -> Iterator.Element? {
return indices.contains(index) ? self[index] : nil
}
}
How do I get to the proper value of the the amount entered in textfield? Assuming my dollar value is 50.05, I noticed that when I try to access:
bindingManager.text.decimal
I get 5005. What am I doing wrong to not get 50.05?
import SwiftUI
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct ContentView: View {
#ObservedObject private var bindingManager = TextBindingManager(amount: 0)
var decimal: Decimal { bindingManager.text.decimal / pow(10, Formatter.currency.maximumFractionDigits) }
var maximum: Decimal = 999_999_999.99
#State private var lastValue: String = ""
#State private var locale: Locale = .current {
didSet { Formatter.currency.locale = locale }
}
var body: some View {
VStack(alignment: .leading) {
TextField(bindingManager.text, text: $bindingManager.text)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing) // this will keep the text aligned to the right
.onChange(of: bindingManager.text) { string in
if string.decimal > maximum {
self.bindingManager.text = lastValue
} else {
self.bindingManager.text = decimal.currency
lastValue = self.bindingManager.text
}
return
}
}
.padding()
.onAppear {
Formatter.currency.locale = locale
}
}
}
class TextBindingManager: ObservableObject {
#Published var text: String = ""
var amount: Decimal = .zero
init(amount: Decimal) {
self.amount = amount
self.text = Formatter.currency.string(for: amount) ?? "$0.00"
}
}
fileprivate extension Formatter {
static let currency: NumberFormatter = .init(numberStyle: .currency)
}
extension NumberFormatter {
convenience init(numberStyle: Style) {
self.init()
self.numberStyle = numberStyle
}
}
extension StringProtocol where Self: RangeReplaceableCollection {
var digits: Self { filter (\.isWholeNumber) }
}
extension String {
var decimal: Decimal { Decimal(string: digits) ?? 0 }
}
extension Decimal {
var currency: String { Formatter.currency.string(for: self) ?? "" }
}
You just need to divide the decimal value by the number of maximum fraction digits. Same as it is being done with the decimal instance property of your ContentView:
var decimal: Decimal { bindingManager.text.decimal / pow(10, Formatter.currency.maximumFractionDigits) }
.onChange(of: bindingManager.text) { string in
if string.decimal > maximum {
self.bindingManager.text = lastValue
} else {
self.bindingManager.text = decimal.currency
lastValue = self.bindingManager.text
}
print("decimal", decimal)
return
}
This will print
decimal 0.05
decimal 0.5
decimal 5
decimal 50.05
I'm using the DecimalField struct to place text fields in my app. However, if I use it alongside an environment object, the app freezes with a memory leak.
This is my model:
class PaymentPlan: ObservableObject {
#Published var amountBorrowed: Decimal?
}
This is my content view:
var currencyFormatter: NumberFormatter {
let nf = NumberFormatter()
nf.numberStyle = .currency
nf.isLenient = true
return nf
}
struct ContentView: View {
#EnvironmentObject var paymentPlan: PaymentPlan
static var currencyFormatter: NumberFormatter {
let nf = NumberFormatter()
nf.numberStyle = .currency
nf.isLenient = true
return nf
}
var body: some View {
DecimalField("Placeholder", value: $paymentPlan.amountBorrowed, formatter: Self.currencyFormatter)
}
}
This is the custom text field I am using (source):
import SwiftUI
import Combine
struct DecimalField : View {
let label: LocalizedStringKey
#Binding var value: Decimal?
let formatter: NumberFormatter
let onEditingChanged: (Bool) -> Void
let onCommit: () -> Void
// The text shown by the wrapped TextField. This is also the "source of
// truth" for the `value`.
#State private var textValue: String = ""
// When the view loads, `textValue` is not synced with `value`.
// This flag ensures we don't try to get a `value` out of `textValue`
// before the view is fully initialized.
#State private var hasInitialTextValue = false
init(
_ label: LocalizedStringKey,
value: Binding<Decimal?>,
formatter: NumberFormatter,
onEditingChanged: #escaping (Bool) -> Void = { _ in },
onCommit: #escaping () -> Void = {}
) {
self.label = label
_value = value
self.formatter = formatter
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
}
var body: some View {
TextField(label, text: $textValue, onEditingChanged: { isInFocus in
// When the field is in focus we replace the field's contents
// with a plain unformatted number. When not in focus, the field
// is treated as a label and shows the formatted value.
if isInFocus {
self.textValue = self.value?.description ?? ""
} else {
let f = self.formatter
let newValue = f.number(from: self.textValue)?.decimalValue
self.textValue = f.string(for: newValue) ?? ""
}
self.onEditingChanged(isInFocus)
}, onCommit: {
self.onCommit()
})
.onReceive(Just(textValue)) {
guard self.hasInitialTextValue else {
// We don't have a usable `textValue` yet -- bail out.
return
}
// This is the only place we update `value`.
self.value = self.formatter.number(from: $0)?.decimalValue
}
.onAppear(){ // Otherwise textfield is empty when view appears
self.hasInitialTextValue = true
// Any `textValue` from this point on is considered valid and
// should be synced with `value`.
if let value = self.value {
// Synchronize `textValue` with `value`; can't be done earlier
self.textValue = self.formatter.string(from: NSDecimalNumber(decimal: value)) ?? ""
}
}
.keyboardType(.decimalPad)
}
}
Any suggestions on what may not be working well? The text field works perfectly with #State.
Here is fixed part - to avoid cycling it needs to update only with really new value
Tested with Xcode 12 / iOS 14
.onReceive(Just(textValue)) {
guard self.hasInitialTextValue else {
// We don't have a usable `textValue` yet -- bail out.
return
}
// This is the only place we update `value`.
let newValue = self.formatter.number(from: $0)?.decimalValue
if newValue != self.value {
self.value = newValue
}
}