TextFieldWithToolBar.swift 12 KB

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