TextFieldWithToolBar.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. // LoopFollow
  2. // TextFieldWithToolBar.swift
  3. import HealthKit
  4. import SwiftUI
  5. import UIKit
  6. public struct TextFieldWithToolBar: UIViewRepresentable {
  7. @Binding var quantity: HKQuantity
  8. var textColor: UIColor
  9. var textAlignment: NSTextAlignment
  10. var autocapitalizationType: UITextAutocapitalizationType
  11. var autocorrectionType: UITextAutocorrectionType
  12. var shouldBecomeFirstResponder: Bool
  13. var maxLength: Int?
  14. var isDismissible: Bool
  15. var textFieldDidBeginEditing: (() -> Void)?
  16. var unit: HKUnit
  17. var allowDecimalSeparator: Bool
  18. var minValue: HKQuantity?
  19. var maxValue: HKQuantity?
  20. var onValidationError: (String) -> Void
  21. public init(
  22. quantity: Binding<HKQuantity>,
  23. textColor: UIColor = .label,
  24. textAlignment: NSTextAlignment = .right,
  25. autocapitalizationType: UITextAutocapitalizationType = .none,
  26. autocorrectionType: UITextAutocorrectionType = .no,
  27. shouldBecomeFirstResponder: Bool = false,
  28. maxLength: Int? = nil,
  29. isDismissible: Bool = true,
  30. textFieldDidBeginEditing: (() -> Void)? = nil,
  31. unit: HKUnit,
  32. allowDecimalSeparator: Bool = true,
  33. minValue: HKQuantity? = nil,
  34. maxValue: HKQuantity? = nil,
  35. onValidationError: @escaping (String) -> Void
  36. ) {
  37. _quantity = quantity
  38. self.textColor = textColor
  39. self.textAlignment = textAlignment
  40. self.autocapitalizationType = autocapitalizationType
  41. self.autocorrectionType = autocorrectionType
  42. self.shouldBecomeFirstResponder = shouldBecomeFirstResponder
  43. self.maxLength = maxLength
  44. self.isDismissible = isDismissible
  45. self.textFieldDidBeginEditing = textFieldDidBeginEditing
  46. self.unit = unit
  47. self.allowDecimalSeparator = allowDecimalSeparator
  48. self.minValue = minValue
  49. self.maxValue = maxValue
  50. self.onValidationError = onValidationError
  51. }
  52. private func formattedPlaceholder(for unit: HKUnit) -> String {
  53. let formatter = NumberFormatter()
  54. formatter.minimumFractionDigits = unit.preferredFractionDigits
  55. formatter.maximumFractionDigits = unit.preferredFractionDigits
  56. formatter.numberStyle = .decimal
  57. return formatter.string(from: NSNumber(value: 0)) ?? "0"
  58. }
  59. public func makeUIView(context: Context) -> UITextField {
  60. let textField = UITextField()
  61. context.coordinator.textField = textField
  62. textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
  63. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
  64. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidEnd), for: .editingDidEnd)
  65. textField.delegate = context.coordinator
  66. textField.text = quantity.doubleValue(for: unit) == 0 ? "" : context.coordinator.format(quantity: quantity, for: unit)
  67. textField.placeholder = formattedPlaceholder(for: unit)
  68. textField.keyboardType = unit.preferredFractionDigits == 0 ? .numberPad : .decimalPad
  69. return textField
  70. }
  71. private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar {
  72. let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
  73. let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
  74. let doneButton = UIBarButtonItem(
  75. image: UIImage(systemName: "keyboard.chevron.compact.down"),
  76. style: .done,
  77. target: textField,
  78. action: #selector(UITextField.resignFirstResponder)
  79. )
  80. let clearButton = UIBarButtonItem(
  81. image: UIImage(systemName: "trash"),
  82. style: .plain,
  83. target: context.coordinator,
  84. action: #selector(Coordinator.clearText)
  85. )
  86. toolbar.items = [clearButton, flexibleSpace, doneButton]
  87. toolbar.sizeToFit()
  88. return toolbar
  89. }
  90. public func updateUIView(_ textField: UITextField, context: Context) {
  91. if !context.coordinator.isEditing {
  92. let newText = quantity.doubleValue(for: unit) == 0 ? "" : context.coordinator.format(quantity: quantity, for: unit)
  93. if textField.text != newText {
  94. textField.text = newText
  95. }
  96. }
  97. textField.textColor = textColor
  98. textField.textAlignment = textAlignment
  99. textField.keyboardType = unit.preferredFractionDigits == 0 ? .numberPad : .decimalPad
  100. textField.autocapitalizationType = autocapitalizationType
  101. textField.autocorrectionType = autocorrectionType
  102. if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
  103. if textField.window != nil, textField.becomeFirstResponder() {
  104. context.coordinator.didBecomeFirstResponder = true
  105. }
  106. } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
  107. context.coordinator.didBecomeFirstResponder = false
  108. }
  109. }
  110. public func makeCoordinator() -> Coordinator {
  111. Coordinator(self, maxLength: maxLength, unit: unit, minValue: minValue, maxValue: maxValue, onValidationError: onValidationError)
  112. }
  113. public final class Coordinator: NSObject, UITextFieldDelegate {
  114. var parent: TextFieldWithToolBar
  115. var textField: UITextField?
  116. let maxLength: Int?
  117. var didBecomeFirstResponder = false
  118. var isEditing = false
  119. var unit: HKUnit
  120. var minValue: HKQuantity?
  121. var maxValue: HKQuantity?
  122. let onValidationError: (String) -> Void
  123. init(_ parent: TextFieldWithToolBar, maxLength: Int?, unit: HKUnit, minValue: HKQuantity?, maxValue: HKQuantity?, onValidationError: @escaping (String) -> Void) {
  124. self.parent = parent
  125. self.maxLength = maxLength
  126. self.unit = unit
  127. self.minValue = minValue
  128. self.maxValue = maxValue
  129. self.onValidationError = onValidationError
  130. }
  131. @objc fileprivate func clearText() {
  132. DispatchQueue.main.async {
  133. self.parent.quantity = HKQuantity(unit: self.unit, doubleValue: 0)
  134. self.textField?.text = ""
  135. }
  136. }
  137. @objc fileprivate func editingDidBegin(_ textField: UITextField) {
  138. isEditing = true
  139. DispatchQueue.main.async {
  140. if self.parent.quantity.doubleValue(for: self.unit) == 0 {
  141. textField.text = ""
  142. }
  143. textField.moveCursorToEnd()
  144. }
  145. }
  146. @objc fileprivate func editingDidEnd(_ textField: UITextField) {
  147. isEditing = false
  148. DispatchQueue.main.async {
  149. let decimalSeparator = Locale.current.decimalSeparator ?? "."
  150. let text = textField.text?.replacingOccurrences(of: decimalSeparator, with: ".") ?? ""
  151. if text.isEmpty {
  152. self.parent.quantity = HKQuantity(unit: self.unit, doubleValue: 0)
  153. } else if let number = Double(text) {
  154. let quantity = HKQuantity(unit: self.unit, doubleValue: number)
  155. if self.isWithinLimits(quantity) {
  156. self.parent.quantity = quantity
  157. textField.text = self.format(quantity: self.parent.quantity, for: self.unit)
  158. } else {
  159. let formatter = NumberFormatter()
  160. formatter.minimumFractionDigits = self.unit.preferredFractionDigits
  161. formatter.maximumFractionDigits = self.unit.preferredFractionDigits
  162. let step = pow(10.0, Double(-formatter.maximumFractionDigits))
  163. var message = "Value outside of guardrails: \(text)\n"
  164. if let minValue = self.parent.minValue {
  165. let minVal = minValue.doubleValue(for: self.unit)
  166. let adjustedMin = ceil(minVal / step) * step
  167. let minQuantity = HKQuantity(unit: self.unit, doubleValue: adjustedMin)
  168. message += "Minimum: \(self.format(quantity: minQuantity, for: self.unit))\n"
  169. }
  170. if let maxValue = self.parent.maxValue {
  171. let maxVal = maxValue.doubleValue(for: self.unit)
  172. let adjustedMax = floor(maxVal / step) * step
  173. let maxQuantity = HKQuantity(unit: self.unit, doubleValue: adjustedMax)
  174. message += "Maximum: \(self.format(quantity: maxQuantity, for: self.unit))"
  175. }
  176. self.onValidationError(message)
  177. self.parent.quantity = HKQuantity(unit: self.unit, doubleValue: 0)
  178. }
  179. } else {
  180. self.onValidationError("Invalid number format")
  181. }
  182. }
  183. }
  184. func format(quantity: HKQuantity, for unit: HKUnit) -> String {
  185. let value = quantity.doubleValue(for: unit)
  186. let formatter = NumberFormatter()
  187. formatter.minimumFractionDigits = unit.preferredFractionDigits
  188. formatter.maximumFractionDigits = max(unit.preferredFractionDigits, 3)
  189. formatter.numberStyle = .decimal
  190. return formatter.string(from: NSNumber(value: value)) ?? ""
  191. }
  192. private func isWithinLimits(_ quantity: HKQuantity) -> Bool {
  193. if let minValue = minValue, quantity.doubleValue(for: unit) < minValue.doubleValue(for: unit) {
  194. return false
  195. }
  196. if let maxValue = maxValue, quantity.doubleValue(for: unit) > maxValue.doubleValue(for: unit) {
  197. return false
  198. }
  199. return true
  200. }
  201. public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
  202. let currentText = textField.text ?? ""
  203. guard let textRange = Range(range, in: currentText) else {
  204. return false
  205. }
  206. let updatedText = currentText.replacingCharacters(in: textRange, with: string)
  207. if let maxLength = maxLength, updatedText.count > maxLength {
  208. return false
  209. }
  210. let decimalSeparator = Locale.current.decimalSeparator ?? "."
  211. let sanitizedText = updatedText.replacingOccurrences(of: decimalSeparator, with: ".")
  212. if sanitizedText.isEmpty {
  213. DispatchQueue.main.async {
  214. self.parent.quantity = HKQuantity(unit: self.unit, doubleValue: 0)
  215. }
  216. return true
  217. } else if let number = Double(sanitizedText) {
  218. let quantity = HKQuantity(unit: unit, doubleValue: number)
  219. if isWithinLimits(quantity) {
  220. DispatchQueue.main.async {
  221. self.parent.quantity = quantity
  222. }
  223. }
  224. return true
  225. } else {
  226. return true
  227. }
  228. }
  229. }
  230. }
  231. extension UITextField {
  232. func moveCursorToEnd() {
  233. dispatchPrecondition(condition: .onQueue(.main))
  234. let newPosition = endOfDocument
  235. selectedTextRange = textRange(from: newPosition, to: newPosition)
  236. }
  237. }
  238. extension UIApplication {
  239. func endEditing() {
  240. sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
  241. }
  242. }