TextFieldWithToolBar.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  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. @State private var alertMessage: String? = nil
  14. @State private var showAlert: Bool = false
  15. var textColor: UIColor
  16. var textAlignment: NSTextAlignment
  17. var autocapitalizationType: UITextAutocapitalizationType
  18. var autocorrectionType: UITextAutocorrectionType
  19. var shouldBecomeFirstResponder: Bool
  20. var maxLength: Int?
  21. var isDismissible: Bool
  22. var textFieldDidBeginEditing: (() -> Void)?
  23. var unit: HKUnit
  24. var allowDecimalSeparator: Bool
  25. var minValue: HKQuantity?
  26. var maxValue: HKQuantity?
  27. public init(
  28. quantity: Binding<HKQuantity>,
  29. textColor: UIColor = .label,
  30. textAlignment: NSTextAlignment = .right,
  31. autocapitalizationType: UITextAutocapitalizationType = .none,
  32. autocorrectionType: UITextAutocorrectionType = .no,
  33. shouldBecomeFirstResponder: Bool = false,
  34. maxLength: Int? = nil,
  35. isDismissible: Bool = true,
  36. textFieldDidBeginEditing: (() -> Void)? = nil,
  37. unit: HKUnit,
  38. allowDecimalSeparator: Bool = true,
  39. minValue: HKQuantity? = nil,
  40. maxValue: HKQuantity? = nil
  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. }
  56. private func formattedPlaceholder(for unit: HKUnit) -> String {
  57. let formatter = NumberFormatter()
  58. formatter.minimumFractionDigits = unit.preferredFractionDigits
  59. formatter.maximumFractionDigits = unit.preferredFractionDigits
  60. formatter.numberStyle = .decimal
  61. return formatter.string(from: NSNumber(value: 0)) ?? "0"
  62. }
  63. public func makeUIView(context: Context) -> UITextField {
  64. let textField = UITextField()
  65. context.coordinator.textField = textField
  66. textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
  67. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
  68. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidEnd), for: .editingDidEnd)
  69. textField.delegate = context.coordinator
  70. textField.text = quantity.doubleValue(for: unit) == 0 ? "" : context.coordinator.format(quantity: quantity, for: unit)
  71. textField.placeholder = formattedPlaceholder(for: unit)
  72. textField.keyboardType = unit.preferredFractionDigits == 0 ? .numberPad : .decimalPad
  73. if showAlert {
  74. let alert = UIAlertController(title: "Input Error", message: alertMessage, preferredStyle: .alert)
  75. alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
  76. showAlert = false
  77. })
  78. UIApplication.shared.windows.first?.rootViewController?.present(alert, animated: true, completion: nil)
  79. }
  80. return textField
  81. }
  82. private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar {
  83. let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
  84. let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
  85. let doneButton = UIBarButtonItem(
  86. image: UIImage(systemName: "keyboard.chevron.compact.down"),
  87. style: .done,
  88. target: textField,
  89. action: #selector(UITextField.resignFirstResponder)
  90. )
  91. let clearButton = UIBarButtonItem(
  92. image: UIImage(systemName: "trash"),
  93. style: .plain,
  94. target: context.coordinator,
  95. action: #selector(Coordinator.clearText)
  96. )
  97. toolbar.items = [clearButton, flexibleSpace, doneButton]
  98. toolbar.sizeToFit()
  99. return toolbar
  100. }
  101. public func updateUIView(_ textField: UITextField, context: Context) {
  102. if !context.coordinator.isEditing {
  103. let newText = quantity.doubleValue(for: unit) == 0 ? "" : context.coordinator.format(quantity: quantity, for: unit)
  104. if textField.text != newText {
  105. textField.text = newText
  106. }
  107. }
  108. textField.textColor = textColor
  109. textField.textAlignment = textAlignment
  110. textField.keyboardType = unit.preferredFractionDigits == 0 ? .numberPad : .decimalPad
  111. textField.autocapitalizationType = autocapitalizationType
  112. textField.autocorrectionType = autocorrectionType
  113. if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
  114. if textField.window != nil, textField.becomeFirstResponder() {
  115. context.coordinator.didBecomeFirstResponder = true
  116. }
  117. } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
  118. context.coordinator.didBecomeFirstResponder = false
  119. }
  120. }
  121. public func makeCoordinator() -> Coordinator {
  122. Coordinator(self, maxLength: maxLength, unit: unit, minValue: minValue, maxValue: maxValue)
  123. }
  124. public final class Coordinator: NSObject, UITextFieldDelegate {
  125. var parent: TextFieldWithToolBar
  126. var textField: UITextField?
  127. let maxLength: Int?
  128. var didBecomeFirstResponder = false
  129. var isEditing = false
  130. var unit: HKUnit
  131. var minValue: HKQuantity?
  132. var maxValue: HKQuantity?
  133. init(_ parent: TextFieldWithToolBar, maxLength: Int?, unit: HKUnit, minValue: HKQuantity?, maxValue: HKQuantity?) {
  134. self.parent = parent
  135. self.maxLength = maxLength
  136. self.unit = unit
  137. self.minValue = minValue
  138. self.maxValue = maxValue
  139. }
  140. @objc fileprivate func clearText() {
  141. DispatchQueue.main.async {
  142. self.parent.quantity = HKQuantity(unit: self.unit, doubleValue: 0)
  143. self.textField?.text = ""
  144. }
  145. }
  146. @objc fileprivate func editingDidBegin(_ textField: UITextField) {
  147. isEditing = true
  148. DispatchQueue.main.async {
  149. if self.parent.quantity.doubleValue(for: self.unit) == 0 {
  150. textField.text = ""
  151. }
  152. textField.moveCursorToEnd()
  153. }
  154. }
  155. @objc fileprivate func editingDidEnd(_ textField: UITextField) {
  156. isEditing = false
  157. DispatchQueue.main.async {
  158. let decimalSeparator = Locale.current.decimalSeparator ?? "."
  159. let text = textField.text?.replacingOccurrences(of: decimalSeparator, with: ".") ?? ""
  160. if text.isEmpty {
  161. self.parent.quantity = HKQuantity(unit: self.unit, doubleValue: 0)
  162. } else if let number = Double(text) {
  163. let quantity = HKQuantity(unit: self.unit, doubleValue: number)
  164. if self.isWithinLimits(quantity) {
  165. self.parent.quantity = quantity
  166. textField.text = self.format(quantity: self.parent.quantity, for: self.unit)
  167. } else {
  168. var message = "Value outside of guardrails: \(text)\n"
  169. if let minValue = self.parent.minValue {
  170. message += "Minimum: \(self.format(quantity: minValue, for: self.unit))\n"
  171. }
  172. if let maxValue = self.parent.maxValue {
  173. message += "Maximum: \(self.format(quantity: maxValue, for: self.unit))"
  174. }
  175. self.showInvalidInputAlert(textField, message: message)
  176. }
  177. } else {
  178. self.showInvalidInputAlert(textField, message: "Invalid number format")
  179. }
  180. }
  181. }
  182. private func showInvalidInputAlert(_ textField: UITextField, message: String) {
  183. var message = "Value outside of guardrails\n"
  184. let preferredFractionDigits = self.unit.preferredFractionDigits
  185. let roundingFactor = pow(10.0, Double(preferredFractionDigits))
  186. if let minValue = self.parent.minValue {
  187. let minValueValue = minValue.doubleValue(for: self.unit)
  188. let minValueRoundedUp = ceil(minValueValue * roundingFactor) / roundingFactor
  189. message += "Minimum: \(self.format(quantity: HKQuantity(unit: self.unit, doubleValue: minValueRoundedUp), for: self.unit))\n"
  190. }
  191. if let maxValue = self.parent.maxValue {
  192. let maxValueValue = maxValue.doubleValue(for: self.unit)
  193. let maxValueRoundedDown = floor(maxValueValue * roundingFactor) / roundingFactor
  194. message += "Maximum: \(self.format(quantity: HKQuantity(unit: self.unit, doubleValue: maxValueRoundedDown), for: self.unit))"
  195. }
  196. let alert = UIAlertController(title: "Input Validation Error", message: message, preferredStyle: .alert)
  197. alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
  198. textField.becomeFirstResponder()
  199. })
  200. alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
  201. })
  202. UIApplication.shared.windows.first?.rootViewController?.present(alert, animated: true, completion: nil)
  203. }
  204. public func textField(
  205. _ textField: UITextField,
  206. shouldChangeCharactersIn range: NSRange,
  207. replacementString string: String
  208. ) -> Bool {
  209. let decimalSeparator = Locale.current.decimalSeparator ?? "."
  210. let isNumber = CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string))
  211. let isDecimalSeparator = (string == decimalSeparator && textField.text?.contains(decimalSeparator) == false)
  212. let currentText = textField.text ?? ""
  213. let proposedText = (currentText as NSString).replacingCharacters(in: range, with: string)
  214. if let maxLength = maxLength, proposedText.count > maxLength {
  215. return false
  216. }
  217. let isValidInput = isNumber || (isDecimalSeparator && parent.allowDecimalSeparator && unit.preferredFractionDigits > 0)
  218. if isValidInput, let number = Double(proposedText.replacingOccurrences(of: decimalSeparator, with: ".")) {
  219. let quantity = HKQuantity(unit: unit, doubleValue: number)
  220. if isWithinLimits(quantity) {
  221. parent.quantity = quantity
  222. } else {
  223. parent.quantity = HKQuantity(unit: unit, doubleValue: 0)
  224. }
  225. } else {
  226. parent.quantity = HKQuantity(unit: unit, doubleValue: 0)
  227. }
  228. return isValidInput
  229. }
  230. public func textFieldDidBeginEditing(_: UITextField) {
  231. parent.textFieldDidBeginEditing?()
  232. }
  233. func format(quantity: HKQuantity, for unit: HKUnit) -> String {
  234. let value = quantity.doubleValue(for: unit)
  235. let formatter = NumberFormatter()
  236. formatter.minimumFractionDigits = unit.preferredFractionDigits
  237. formatter.maximumFractionDigits = unit.preferredFractionDigits
  238. formatter.numberStyle = .decimal
  239. return formatter.string(from: NSNumber(value: value)) ?? ""
  240. }
  241. private func isWithinLimits(_ quantity: HKQuantity) -> Bool {
  242. if let minValue = minValue, quantity.doubleValue(for: unit) < minValue.doubleValue(for: unit) {
  243. return false
  244. }
  245. if let maxValue = maxValue, quantity.doubleValue(for: unit) > maxValue.doubleValue(for: unit) {
  246. return false
  247. }
  248. return true
  249. }
  250. }
  251. }
  252. extension UITextField {
  253. func moveCursorToEnd() {
  254. dispatchPrecondition(condition: .onQueue(.main))
  255. let newPosition = endOfDocument
  256. selectedTextRange = textRange(from: newPosition, to: newPosition)
  257. }
  258. }
  259. extension UIApplication {
  260. func endEditing() {
  261. sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
  262. }
  263. }