TextFieldWithToolBar.swift 12 KB

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