Memory leak when using custom text field in SwiftUI

Viewed 414

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.

1 Answers

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
    }
}
Related