Real-time NSTextField formatter in SwiftUI context - swiftui

After lots of trial and error, I ended up with the following implementation to do real-time formatting for numeric entry in a text field. Various attempts to use a SwiftUI TextField() resulted in many anomalies. The approach below seems to be solid but even here I struggled with the proper approach to sub-classing NSTextField as I couldn't find any documentation on how to handle the designated initializer such that it would be compatible with SwiftUI's frame modifier.
The one minor remaining anomaly is that when placing the cursor in the middle of an entered number then typing non-numeric characters, the cursor advances even though no changes occur in the text. This is livable but I would prefer to keep that from happening.
Is there a better, more "proper" way to implement this?
import Foundation
import SwiftUI
struct NumberField : NSViewRepresentable {
typealias NSViewType = NumberText
var defaultText : String
var maxDigits : Int
var numberValue : Binding<Int>
func makeNSView(context: Context) -> NSViewType {
// Create text field
let numberTextField = NumberText()
numberTextField.isEditable = true
// numberTextField.numberBinding = numberValue
numberTextField.configure(text: defaultText, digits: maxDigits, intBinding: numberValue)
return numberTextField
}
func updateNSView(_ nsView: NSViewType, context: Context) {
// nsView.stringValue = "This is my string"
}
}
/// NumberText draws an NSTextField that will accept only digits up to a maximum number specified when calling Configure. Apple implements some nice integration between SwiftUI's frame and padding modifiers and the NSTextField's designated initializer. Rather than having to figure out how to fix/preserve this integration, this class provides a "configure()" function that is effectively it's initializer. As a result, it is MANDATORY that this class's configure() function be called immediately after initializing the class.
class NumberText : NSTextField {
// Code below jumps through a couple of hoops to avoid having to write a custom initializer since that gets in the middle of Apple's configuration of the text field using standard SwiftUI modifiers.
// NOTE THAT A USER OF NumberText MUST CALL CONFIGURE() IMMEDIATELY AFTER CREATING IT
var numberBinding : Binding<Int> = Binding( // This is initialized with a garbage Binding just to avoid having to write an initializer
get: {return -1},
set: {newValue in return}
)
var defaultText = "Default String"
var maxDigits = 9
private var decimalFormatter = NumberFormatter()
func configure(text: String, digits: Int, intBinding: Binding<Int>) { // Configure is used here instead of attempting to override init()
// Configure values
decimalFormatter.numberStyle = .decimal
defaultText = text
self.placeholderString = defaultText
maxDigits = digits
numberBinding = intBinding
// Set up TextField values
self.integerValue = numberBinding.wrappedValue
if self.integerValue == 0 {self.stringValue = ""}
}
override func textDidChange(_ notification: Notification) {
self.stringValue = numberTextFromString(self.stringValue)
if self.stringValue == "0" {self.stringValue = ""}
}
func numberTextFromString(_ inputText: String, maxLength: Int = 9) -> String {
// Create a filtered and trucated version of inputText
let filteredText = inputText.filter { character in
character.isNumber
}
let truncatedText = String(filteredText.suffix(maxLength))
// Make a number from truncated text
let myNumber = Int(truncating: decimalFormatter.number(from: truncatedText) ?? 0 )
// Set binding value
numberBinding.wrappedValue = myNumber
// Create formatted string for return
let returnValue = decimalFormatter.string(from: myNumber as NSNumber) ?? "?"
return returnValue
}

After some additional trial and error, I was able to fix the cursor problems mentioned in my initial question. The version here is, to the best of my knowledge, bullet proof (though the test team will have a whack at it so perhaps it will change).
Would still welcome any improvement suggestions.
import Foundation
import SwiftUI
struct NumberField : NSViewRepresentable {
typealias NSViewType = NumberText
var defaultText : String
var maxDigits : Int
var numberValue : Binding<Int>
func makeNSView(context: Context) -> NSViewType {
// Create text field
let numberTextField = NumberText()
numberTextField.isEditable = true
numberTextField.configure(text: defaultText, digits: maxDigits, intBinding: numberValue)
return numberTextField
}
func updateNSView(_ nsView: NSViewType, context: Context) {
}
}
/// NumberText draws an NSTextField that will accept only digits up to a maximum number specified when calling Configure. Apple implements some nice integration between SwiftUI's frame and padding modifiers and the NSTextField's designated initializer. Rather than having to figure out how to fix/preserve this integration, this class provides a "configure()" function that is effectively it's initializer. As a result, it is MANDATORY that this class's configure() function be called immediately after initializing the class.
class NumberText : NSTextField {
// Code below jumps through a couple of hoops to avoid having to write a custom initializer since that gets in the middle of Apple's configuration of the text field using standard SwiftUI modifiers.
// NOTE THAT A USER OF NumberText MUST CALL CONFIGURE() IMMEDIATELY AFTER CREATING IT
// The following variable declarations are all immediately initialized to avoid having to write an init() function
var numberBinding : Binding<Int> = Binding( // This is initialized with a garbage Binding just to avoid having to write an initializer
get: {return -1},
set: {newValue in return}
)
var defaultText = "Default String"
var maxDigits = 9
private var decimalFormatter = NumberFormatter()
func configure(text: String, digits: Int, intBinding: Binding<Int>) { // Configure is used here instead of attempting to override init()
// Configure values
decimalFormatter.numberStyle = .decimal
defaultText = text
self.placeholderString = defaultText
maxDigits = digits
numberBinding = intBinding
// Make sure that default text is shown if numberBinding.wrappedValue is 0
if numberBinding.wrappedValue == 0 {self.stringValue = ""}
}
override func textDidChange(_ notification: Notification) {
self.stringValue = numberTextFromString(self.stringValue, maxLength: maxDigits) // numberTextFromString() also sets the wrappedValue of numberBinding
if self.stringValue == "0" {self.stringValue = ""}
}
/// Takes in string from text field and returns the best number string that can be made from it by removing any non-numeric characters and adding comma separators in the right places.
/// Along the way, self.numberBinding.warppedValue is set to the Int corresponding to the output string and self's cursor is reset to account for the erasure of invalid characters and the addition of commas
/// - Parameters:
/// - inputText: Incoming text from text field
/// - maxLength: Maximum number of digits allowed in this field
/// - Returns:String representing number
func numberTextFromString(_ inputText: String, maxLength: Int) -> String {
var decrementCursorForInvalidChar = 0
var incomingDigitsBeforeCursor = 0
// For cursor calculation, find digit count behind cursor in incoming string
// Get incoming cursor location
let incomingCursorLocation = currentEditor()?.selectedRange.location ?? 0
// Create prefix behind incoming cursor location
let incomingPrefixToCursor = inputText.prefix(incomingCursorLocation)
// Count digits in prefix
for character in incomingPrefixToCursor {
if character.isNumber == true {
incomingDigitsBeforeCursor += 1
}
}
// Create a filtered and trucated version of inputText
var characterCount = 0
let filteredText = inputText.filter { character in
characterCount += 1
if character.isNumber == true {
return true
} else { // character is invalid or comma.
if character != "," { // character is invalid,
if characterCount < inputText.count { // Only decrement cursor if not at end of string
// Decrement cursor
decrementCursorForInvalidChar += 1
}
}
return false
}
}
// Decrement cursor as needed for invalid character entries
currentEditor()!.selectedRange.location = incomingCursorLocation - decrementCursorForInvalidChar
let truncatedText = String(filteredText.prefix(maxLength))
// Make a number from truncated text
let myNumber = Int(truncating: decimalFormatter.number(from: truncatedText) ?? 0 )
// Set binding value
numberBinding.wrappedValue = myNumber
// Create formatted string for return
let outgoingString = decimalFormatter.string(from: myNumber as NSNumber) ?? "?"
// For cursor calculation, find character representing incomingDigitsBeforeCursor.lastIndex
var charCount = 0
var digitCount = 0
var charIndex = outgoingString.startIndex
while digitCount < incomingDigitsBeforeCursor && charCount < outgoingString.count {
charIndex = outgoingString.index(outgoingString.startIndex, offsetBy: charCount)
charCount += 1
if outgoingString[charIndex].isNumber == true {
digitCount += 1
}
}
// Get integer corresponding to current charIndex
let outgoingCursorLocation = outgoingString.distance(from: outgoingString.startIndex, to: charIndex) + 1
currentEditor()!.selectedRange.location = outgoingCursorLocation
return outgoingString
}
}

Related

SwiftUI computed var in custom init of a view error

I want to be able to calculate a variable (nbwin) depending of the attribute "nom_rang1" of the coredata entity "RamiSession".
Basically, for each entity "RamiSession" whose attribute "nom_rang1" is equal to "nom_joueur_selec" (which is a String), I want to increment a counter and return the final value of that counter in the variable "nbwin". And then display it in a text box.
I think I have to compute that property "nbwin" in my custom init() method. But it doesn't work.
I have the error message on the line where nbwin is calculated in init saying "Function produces expected type 'Int'; did you mean to call it with '()'?"
But if I put () at the end of the calculation of "nbwin", I have an error message saying "'self' used before all stored properties are initialized"
So I guess I am doing something wrong...
Can anyone help me? Thank you
Here is my code
struct GraphView: View {
#FetchRequest
private var sessions: FetchedResults<RamiSession>
private var nbwin : Int
init(nom_joueur_selec: String) {
let predicate = NSPredicate(format: "nom_rang1= %# OR nom_rang2= %# ", nom_joueur_selec, nom_joueur_selec)
let sortDescriptors = [SortDescriptor(\RamiSession.date)] // need something to sort by.
_sessions = FetchRequest(sortDescriptors: sortDescriptors, predicate: predicate)
nbwin = {
var nbwinTotal = 0
for session in sessions {
if session.nom_rang1 == nom_joueur_selec {
nbwinTotal += 1
}
}
return nbwinTotal
}
}
//
var body: some View {
Text("\(nbwin)")
} // body
}

Code for textfield character limit isn't working(SwiftUI)

I've stumbled across this piece of code:
class TextLimiter: ObservableObject {
private let limit: Int
init(limit: Int) {
self.limit = limit
}
#Published var value = "" {
didSet {
if value.count > self.limit {
value = String(value.prefix(self.limit))
self.hasReachedLimit = true
} else {
self.hasReachedLimit = false
}
}
}
#Published var hasReachedLimit = false }
struct Strix: View {
#ObservedObject var input = TextLimiter(limit: 5)
var body: some View {
TextField("Text Input",
text: $input.value)
.border(Color.red,
width: $input.hasReachedLimit.wrappedValue ? 1 : 0 )
} }
It's a TextField limiting code where after a user inputs characters after a limit, it won't keep inputing characters inside the box. I've tried this code and after the limit is reached, it just keeps on inputting characters.
For example:
How it's supposed to work: limit is 5 so the only input allowed is 'aaaaa'
How it's behaving: limit is 5 but input allowed is 'aaaaaaaa.....'
I'm aware of a recent solution to this:
How to set textfield character limit SwiftUI?
but the solution is specifically tailored for iOS 14. I was hoping to be able to support iOS 13. Thanks.
Link to original code:
https://github.com/programmingwithswift/SwiftUITextFieldLimit/blob/master/SwiftUITextFieldLimit/SwiftUITextFieldLimit/ContentView.swift
Your solution is lies in SwiftUI's subscriber .onReceive,
Make sure that your property hasReachedLimit must not marked with #Published else it will trigger infinite loop of view body rendering.
Below shown code works as your expectation.
class TextLimiter: ObservableObject {
let limit: Int
#Published var value = ""
var hasReachedLimit = false
init(limit: Int) {
self.limit = limit
}
}
struct Strix: View {
#ObservedObject var input = TextLimiter(limit: 5)
var body: some View {
TextField("Text Input",
text: $input.value)
.border(Color.red,
width: $input.hasReachedLimit.wrappedValue ? 1 : 0 )
.onReceive(Just(self.input.value)) { inputValue in
self.input.hasReachedLimit = inputValue.count > self.input.limit
if inputValue.count > self.input.limit {
self.input.value.removeLast()
}
}
}
}
BTW this is not an efficient solution.

What is the best way to get Drag Velocity?

I was wondering how can one get DragGesture Velocity?
I understand the formula works and how to manually get it but when I do so it is no where what Apple returns (at least some times its very different).
I have the following code snippet
struct SecondView: View {
#State private var lastValue: DragGesture.Value?
private var dragGesture: some Gesture {
DragGesture()
.onChanged { (value) in
self.lastValue = value
}
.onEnded { (value) in
if lastValue = self.lastValue {
let timeDiff = value.time.timeIntervalSince(lastValue.time)
print("Actual \(value)") // <- A
print("Calculated: \((value.translation.height - lastValue.translation.height)/timeDiff)") // <- B
}
}
var body: some View {
Color.red
.frame(width: 50, height: 50)
.gesture(self.dragGesture)
}
}
From above:
A will output something like Value(time: 2001-01-02 16:37:14 +0000, location: (250.0, -111.0), startLocation: (249.66665649414062, 71.0), velocity: SwiftUI._Velocity<__C.CGSize>(valuePerSecond: (163.23212105439427, 71.91841849340494)))
B will output something like Calculated: 287.6736739736197
Note from A I am looking at the 2nd value in valuePerSecond which is the y velocity.
Depending on how you drag, the results will be either different or the same. Apple provides the velocity as a property just like .startLocation and .endLocation but unfortunately there is no way for me to access it (at least none that I know) so I have to calculate it myself, theoretically my calculations are correct but they are very different from Apple. So what is the problem here?
This is another take on extracting the velocity from DragGesture.Value. It’s a bit more robust than parsing the debug description as suggested in the other answer but still has the potential to break.
import SwiftUI
extension DragGesture.Value {
/// The current drag velocity.
///
/// While the velocity value is contained in the value, it is not publicly available and we
/// have to apply tricks to retrieve it. The following code accesses the underlying value via
/// the `Mirror` type.
internal var velocity: CGSize {
let valueMirror = Mirror(reflecting: self)
for valueChild in valueMirror.children {
if valueChild.label == "velocity" {
let velocityMirror = Mirror(reflecting: valueChild.value)
for velocityChild in velocityMirror.children {
if velocityChild.label == "valuePerSecond" {
if let velocity = velocityChild.value as? CGSize {
return velocity
}
}
}
}
}
fatalError("Unable to retrieve velocity from \(Self.self)")
}
}
Just like this:
let sss = "\(value)"
//Intercept string
let start = sss.range(of: "valuePerSecond: (")
let end = sss.range(of: ")))")
let arr = String(sss[(start!.upperBound)..<(end!.lowerBound)]).components(separatedBy: ",")
print(Double(arr.first!)!)

Retrieving value from database

Hi I have a problem and would be grateful to any advice or answer.
func getUserProfileMDP(){
// set attributes to textField
var ref: DatabaseReference!
ref = Database.database().reference()
let user = Auth.auth().currentUser
print(user!.uid)
ref.child("users").child((user?.uid)!).observeSingleEvent(of: .value, with: { (snapshot) in
// Get user value
guard let value = snapshot.value as? [String: String] else { return }
print(value)
let passwordValue = value["password"]!as! String
print(passwordValue)
self.MDP = passwordValue // prints the right value from database
}){ (error) in
print(error.localizedDescription)
}
print(self.MDP) // prints the value innitialised in class(nope)
}
Here is the function that gets the value from database. It works (the first print gets the right value)
#IBAction func register(_ sender: Any) {
print(self.MDP)// prints the value innitialised in class(nope)
getUserProfileMDP()
print(self.MDP) // prints the value innitialised in class(nope)
let MDP = self.MDP
That is were I need the password to compare it. It doesn't get me the value of the database but the value initialized in class above:
var MDP = "nope"
Have a nice day
Given your last comment, I'd say that you're almost there, but here's an example. I did not fix the other parts of your code, I only added the completion handler in the method signature, and passed the password value to the handler, to show you how this works. The handler must be called inside the async closure.
func getUserProfileMDP(completion: #escaping (String)->()) {
// set attributes to textField
var ref: DatabaseReference!
ref = Database.database().reference()
let user = Auth.auth().currentUser
print(user!.uid)
ref.child("users").child((user?.uid)!).observeSingleEvent(of: .value, with: { (snapshot) in
// Get user value
guard let value = snapshot.value as? [String: String] else { return }
print(value)
let passwordValue = value["password"]!as! String
completion(passwordValue)
}){ (error) in
print(error.localizedDescription)
}
}
And you call it like that:
getUserProfileMDP() { pass in
print(pass)
self.MDP = pass
}

UISearchbar Search in all fields in swift 3

I've got a tableview showing some data and I filter the shown data uisng UISearchbar. Each data struct consists of different values and
struct Cake {
var name = String()
var size = String()
var filling = String()
}
When a user starts typing I don't know whether he is filtering for name, size or filling. I don't want to use a scopebar. Is there a way to filter for various fields at the same time in swift 3?
This is the code I use to filter:
func updateSearchResults(for searchController: UISearchController) {
if searchController.searchBar.text! == "" {
filteredCakes = cakes
} else {
// Filter the results
filteredCakes = cakes.filter { $0.name.lowercased().contains(searchController.searchBar.text!.lowercased()) }
}
self.tableView.reloadData()
}
thanks for your help!
func updateSearchResults(for searchController: UISearchController)
{
guard let searchedText = searchController.searchBar.text?.lowercased() else {return}
filteredCakes = cakes.filter
{
$0.name.lowercased().contains(searchedText) ||
$0.size.lowercased().contains(searchedText) ||
$0.filling.lowercased().contains(searchedText)
}
self.tableView.reloadData()
}