Well I’ve cracked it: a replacement for the TextField initialiser referred to in Chapter 6 which doesn’t work correctly, misleadingly failing to update the view when focus is lost, (among other problems).
My CustomField<T: CustomFieldProtocol>(title: String, placeholder: String, value: Binding) will apply the following rules to any type which conforms to CustomFieldProtocol (see examples at end of posting).
/* Editing rules
When a TextField obtains focus: colour the foreground red as a visual signal
While editing: accept any input until:
1) we lose focus
or 2) we hit enter
In both cases then recolour the foreground black and ....
(if contents valid, accept) or (if invalid, revert to pre-edit values)
*/
Here’s the code (split into two structs):
struct CustomField<T: CustomFieldProtocol>: View {
var title: String = ""
var placeholder: String = ""
@Binding var value: T { didSet { self.string = value.simpleString } }
@State private var string: String = ""
var body: some View {
HStack {
if title.count > 0 {
Text(title)
.frame(width: 150, alignment: .trailing)
} else {Spacer(minLength: 150)}
RevertingField(placeholder: placeholder,
string: $string,
revert: T.revert)
.onAppear { self.string = value.simpleString }
.onChange(of: string) {
if let v = T($0) { value = v }
}
.frame(width:120, alignment: .trailing)
}
}
}
fileprivate struct RevertingField: View {
var placeholder: String
@Binding var string: String
@State private var typing = false
@State private var store = ""
@State private var foregroundColor: Color? = .black
var revert: (String) -> Bool
var body: some View {
HStack {
TextField(placeholder,
text: $string,
onEditingChanged: { isEditing in
self.typing = isEditing
if typing {
foregroundColor = .red
store = string
}
},
onCommit: {
foregroundColor = .black
if revert(string) { string = store }
})
.onChange(of: typing) { _ in
if !typing {
if revert(string) { string = store }
foregroundColor = .black
}
}
.multilineTextAlignment(.trailing)
.foregroundColor(foregroundColor)
.padding(EdgeInsets(top: 8, leading: 16,
bottom: 8, trailing: 16))
.background(Color.white)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(lineWidth: 2)
)
.shadow(color: Color.gray.opacity(0.4),
radius: 3, x: 1, y: 2)
Spacer()
}
}
}
Pretty simple really! CustomField converts the bound T into a string for handling by RevertingField and converts the string returned to it into the bound T It uses .onAppear to load the value as a string and .onCommit to restore it.
The job of RevertingField is to watch the string being entered and enforce the rules. It uses the onEditingChanged and onCommit parameters and the .onChange modifier to achieve this. The first spots that editing has commenced and colours the foreground. Any kind of editing is permitted but when the user loses the focus or commits, which is noticed using the isTyping Bool, the other functions validate simply by casting the string as a T or reverting to the original value held in store. No formatter required!!
Here’s the protocol and its application to double, int and string. The only “difficult” part is the revert var which provides a function which basically casts the T as a string to see if it is valid.
protocol CustomFieldProtocol {
var simpleString: String { get }
init?(_ string: String)
static var revert: (String) -> Bool { get }
}
extension Double: CustomFieldProtocol {
var simpleString: String {
var str = String(format: "%f", self)
while str.last == "0" { str.removeLast() }
return str
}
static var revert: (String) -> Bool {
return { str in Double(str) == nil }
}
}
extension Int: CustomFieldProtocol {
var simpleString: String { String(format: "%d", self) }
static var revert: (String) -> Bool {
return { str in Int(str) == nil }
}
}
extension String: CustomFieldProtocol {
var simpleString: String { self }
static var revert: (String) -> Bool { return { _ in false } }
}
Here’s how to use it:
@main
struct EditApp: App {
@State var number: Double = 123.4
var body: some Scene {
WindowGroup {
CustomField(title: "Real number:", placeholder: "enter value", value: $value)
}
}
}
I hope it’s useful.
A residual issue is that the most recently edited field always holds the focus and therefore remains red.