FieldRow.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. // FieldRow.swift
  2. // Eureka ( https://github.com/xmartlabs/Eureka )
  3. //
  4. // Copyright (c) 2016 Xmartlabs ( http://xmartlabs.com )
  5. //
  6. //
  7. // Permission is hereby granted, free of charge, to any person obtaining a copy
  8. // of this software and associated documentation files (the "Software"), to deal
  9. // in the Software without restriction, including without limitation the rights
  10. // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11. // copies of the Software, and to permit persons to whom the Software is
  12. // furnished to do so, subject to the following conditions:
  13. //
  14. // The above copyright notice and this permission notice shall be included in
  15. // all copies or substantial portions of the Software.
  16. //
  17. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18. // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19. // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20. // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21. // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22. // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  23. // THE SOFTWARE.
  24. import Foundation
  25. import UIKit
  26. public protocol InputTypeInitiable {
  27. init?(string stringValue: String)
  28. }
  29. public protocol FieldRowConformance : FormatterConformance {
  30. var titlePercentage : CGFloat? { get set }
  31. var placeholder : String? { get set }
  32. var placeholderColor : UIColor? { get set }
  33. }
  34. extension Int: InputTypeInitiable {
  35. public init?(string stringValue: String) {
  36. self.init(stringValue, radix: 10)
  37. }
  38. }
  39. extension Float: InputTypeInitiable {
  40. public init?(string stringValue: String) {
  41. self.init(stringValue)
  42. }
  43. }
  44. extension String: InputTypeInitiable {
  45. public init?(string stringValue: String) {
  46. self.init(stringValue)
  47. }
  48. }
  49. extension URL: InputTypeInitiable {}
  50. extension Double: InputTypeInitiable {
  51. public init?(string stringValue: String) {
  52. self.init(stringValue)
  53. }
  54. }
  55. open class FormatteableRow<Cell: CellType>: Row<Cell>, FormatterConformance where Cell: BaseCell, Cell: TextInputCell {
  56. /// A formatter to be used to format the user's input
  57. open var formatter: Formatter?
  58. /// If the formatter should be used while the user is editing the text.
  59. open var useFormatterDuringInput = false
  60. open var useFormatterOnDidBeginEditing: Bool?
  61. public required init(tag: String?) {
  62. super.init(tag: tag)
  63. displayValueFor = { [unowned self] value in
  64. guard let v = value else { return nil }
  65. guard let formatter = self.formatter else { return String(describing: v) }
  66. if (self.cell.textInput as? UIView)?.isFirstResponder == true {
  67. return self.useFormatterDuringInput ? formatter.editingString(for: v) : String(describing: v)
  68. }
  69. return formatter.string(for: v)
  70. }
  71. }
  72. }
  73. open class FieldRow<Cell: CellType>: FormatteableRow<Cell>, FieldRowConformance, KeyboardReturnHandler where Cell: BaseCell, Cell: TextFieldCell {
  74. /// Configuration for the keyboardReturnType of this row
  75. open var keyboardReturnType: KeyboardReturnTypeConfiguration?
  76. /// The percentage of the cell that should be occupied by the textField
  77. @available (*, deprecated, message: "Use titlePercentage instead")
  78. open var textFieldPercentage : CGFloat? {
  79. get {
  80. return titlePercentage.map { 1 - $0 }
  81. }
  82. set {
  83. titlePercentage = newValue.map { 1 - $0 }
  84. }
  85. }
  86. /// The percentage of the cell that should be occupied by the title (i.e. the titleLabel and optional imageView combined)
  87. open var titlePercentage: CGFloat?
  88. /// The placeholder for the textField
  89. open var placeholder: String?
  90. /// The textColor for the textField's placeholder
  91. open var placeholderColor: UIColor?
  92. public required init(tag: String?) {
  93. super.init(tag: tag)
  94. }
  95. }
  96. /**
  97. * Protocol for cells that contain a UITextField
  98. */
  99. public protocol TextInputCell {
  100. var textInput: UITextInput { get }
  101. }
  102. public protocol TextFieldCell: TextInputCell {
  103. var textField: UITextField! { get }
  104. }
  105. extension TextFieldCell {
  106. public var textInput: UITextInput {
  107. return textField
  108. }
  109. }
  110. open class _FieldCell<T> : Cell<T>, UITextFieldDelegate, TextFieldCell where T: Equatable, T: InputTypeInitiable {
  111. @IBOutlet public weak var textField: UITextField!
  112. @IBOutlet public weak var titleLabel: UILabel?
  113. fileprivate var observingTitleText = false
  114. private var awakeFromNibCalled = false
  115. open var dynamicConstraints = [NSLayoutConstraint]()
  116. private var calculatedTitlePercentage: CGFloat = 0.7
  117. public required init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
  118. let textField = UITextField()
  119. self.textField = textField
  120. textField.translatesAutoresizingMaskIntoConstraints = false
  121. super.init(style: style, reuseIdentifier: reuseIdentifier)
  122. setupTitleLabel()
  123. contentView.addSubview(titleLabel!)
  124. contentView.addSubview(textField)
  125. NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: nil) { [weak self] _ in
  126. guard let me = self else { return }
  127. guard me.observingTitleText else { return }
  128. me.titleLabel?.removeObserver(me, forKeyPath: "text")
  129. me.observingTitleText = false
  130. }
  131. NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { [weak self] _ in
  132. guard let me = self else { return }
  133. guard !me.observingTitleText else { return }
  134. me.titleLabel?.addObserver(me, forKeyPath: "text", options: [.new, .old], context: nil)
  135. me.observingTitleText = true
  136. }
  137. NotificationCenter.default.addObserver(forName: UIContentSizeCategory.didChangeNotification, object: nil, queue: nil) { [weak self] _ in
  138. self?.setupTitleLabel()
  139. self?.setNeedsUpdateConstraints()
  140. }
  141. }
  142. required public init?(coder aDecoder: NSCoder) {
  143. super.init(coder: aDecoder)
  144. }
  145. open override func awakeFromNib() {
  146. super.awakeFromNib()
  147. awakeFromNibCalled = true
  148. }
  149. deinit {
  150. textField?.delegate = nil
  151. textField?.removeTarget(self, action: nil, for: .allEvents)
  152. guard !awakeFromNibCalled else { return }
  153. if observingTitleText {
  154. titleLabel?.removeObserver(self, forKeyPath: "text")
  155. }
  156. imageView?.removeObserver(self, forKeyPath: "image")
  157. NotificationCenter.default.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil)
  158. NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil)
  159. NotificationCenter.default.removeObserver(self, name: UIContentSizeCategory.didChangeNotification, object: nil)
  160. }
  161. open override func setup() {
  162. super.setup()
  163. selectionStyle = .none
  164. if !awakeFromNibCalled {
  165. titleLabel?.addObserver(self, forKeyPath: "text", options: [.new, .old], context: nil)
  166. observingTitleText = true
  167. imageView?.addObserver(self, forKeyPath: "image", options: [.new, .old], context: nil)
  168. }
  169. textField.addTarget(self, action: #selector(_FieldCell.textFieldDidChange(_:)), for: .editingChanged)
  170. }
  171. open override func update() {
  172. super.update()
  173. detailTextLabel?.text = nil
  174. if !awakeFromNibCalled {
  175. if let title = row.title {
  176. switch row.cellStyle {
  177. case .subtitle:
  178. textField.textAlignment = .left
  179. textField.clearButtonMode = .whileEditing
  180. default:
  181. textField.textAlignment = title.isEmpty ? .left : .right
  182. textField.clearButtonMode = title.isEmpty ? .whileEditing : .never
  183. }
  184. } else {
  185. textField.textAlignment = .left
  186. textField.clearButtonMode = .whileEditing
  187. }
  188. } else {
  189. textLabel?.text = nil
  190. titleLabel?.text = row.title
  191. if #available(iOS 13.0, *) {
  192. titleLabel?.textColor = row.isDisabled ? .tertiaryLabel : .label
  193. } else {
  194. titleLabel?.textColor = row.isDisabled ? .gray : .black
  195. }
  196. }
  197. textField.delegate = self
  198. textField.text = row.displayValueFor?(row.value)
  199. textField.isEnabled = !row.isDisabled
  200. if #available(iOS 13.0, *) {
  201. textField.textColor = row.isDisabled ? .tertiaryLabel : .label
  202. } else {
  203. textField.textColor = row.isDisabled ? .gray : .black
  204. }
  205. textField.font = .preferredFont(forTextStyle: .body)
  206. if let placeholder = (row as? FieldRowConformance)?.placeholder {
  207. if let color = (row as? FieldRowConformance)?.placeholderColor {
  208. textField.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [.foregroundColor: color])
  209. } else {
  210. textField.placeholder = (row as? FieldRowConformance)?.placeholder
  211. }
  212. }
  213. if row.isHighlighted {
  214. titleLabel?.textColor = tintColor
  215. }
  216. }
  217. open override func cellCanBecomeFirstResponder() -> Bool {
  218. return !row.isDisabled && textField?.canBecomeFirstResponder == true
  219. }
  220. open override func cellBecomeFirstResponder(withDirection: Direction) -> Bool {
  221. return textField?.becomeFirstResponder() ?? false
  222. }
  223. open override func cellResignFirstResponder() -> Bool {
  224. return textField?.resignFirstResponder() ?? true
  225. }
  226. open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
  227. let obj = object as AnyObject?
  228. if let keyPathValue = keyPath, let changeType = change?[NSKeyValueChangeKey.kindKey],
  229. ((obj === titleLabel && keyPathValue == "text") || (obj === imageView && keyPathValue == "image")) &&
  230. (changeType as? NSNumber)?.uintValue == NSKeyValueChange.setting.rawValue {
  231. setNeedsUpdateConstraints()
  232. updateConstraintsIfNeeded()
  233. }
  234. }
  235. // MARK: Helpers
  236. open func customConstraints() {
  237. guard !awakeFromNibCalled else { return }
  238. contentView.removeConstraints(dynamicConstraints)
  239. dynamicConstraints = []
  240. switch row.cellStyle {
  241. case .subtitle:
  242. var views: [String: AnyObject] = ["textField": textField]
  243. if let titleLabel = titleLabel, let text = titleLabel.text, !text.isEmpty {
  244. views["titleLabel"] = titleLabel
  245. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "V:[titleLabel]-3-[textField]", options: .alignAllLeading, metrics: nil, views: views)
  246. // Here we are centering the textField with an offset of -4. This replicates the exact behavior of the default UITableViewCell with .subtitle style
  247. dynamicConstraints.append(NSLayoutConstraint(item: textField!, attribute: .top, relatedBy: .equal, toItem: contentView, attribute: .centerY, multiplier: 1, constant: -4))
  248. dynamicConstraints.append(NSLayoutConstraint(item: titleLabel, attribute: .centerX, relatedBy: .equal, toItem: textField, attribute: .centerX, multiplier: 1, constant: 0))
  249. } else {
  250. dynamicConstraints.append(NSLayoutConstraint(item: textField!, attribute: .centerY, relatedBy: .equal, toItem: contentView, attribute: .centerY, multiplier: 1, constant: 0))
  251. }
  252. if let imageView = imageView, let _ = imageView.image {
  253. views["imageView"] = imageView
  254. if let titleLabel = titleLabel, let text = titleLabel.text, !text.isEmpty {
  255. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "H:[imageView]-(15)-[titleLabel]-|", options: [], metrics: nil, views: views)
  256. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "H:[imageView]-(15)-[textField]-|", options: [], metrics: nil, views: views)
  257. } else {
  258. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "H:[imageView]-(15)-[textField]-|", options: [], metrics: nil, views: views)
  259. }
  260. } else {
  261. if let titleLabel = titleLabel, let text = titleLabel.text, !text.isEmpty {
  262. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "H:|-[titleLabel]-|", options: [], metrics: nil, views: views)
  263. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "H:|-[textField]-|", options: [], metrics: nil, views: views)
  264. } else {
  265. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "H:|-[textField]-|", options: .alignAllLeft, metrics: nil, views: views)
  266. }
  267. }
  268. default:
  269. var views: [String: AnyObject] = ["textField": textField]
  270. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "V:|-11-[textField]-11-|", options: .alignAllLastBaseline, metrics: nil, views: views)
  271. if let titleLabel = titleLabel, let text = titleLabel.text, !text.isEmpty {
  272. views["titleLabel"] = titleLabel
  273. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "V:|-11-[titleLabel]-11-|", options: .alignAllLastBaseline, metrics: nil, views: views)
  274. dynamicConstraints.append(NSLayoutConstraint(item: titleLabel, attribute: .centerY, relatedBy: .equal, toItem: textField, attribute: .centerY, multiplier: 1, constant: 0))
  275. }
  276. if let imageView = imageView, let _ = imageView.image {
  277. views["imageView"] = imageView
  278. if let titleLabel = titleLabel, let text = titleLabel.text, !text.isEmpty {
  279. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "H:[imageView]-(15)-[titleLabel]-[textField]-|", options: [], metrics: nil, views: views)
  280. dynamicConstraints.append(NSLayoutConstraint(item: titleLabel,
  281. attribute: .width,
  282. relatedBy: (row as? FieldRowConformance)?.titlePercentage != nil ? .equal : .lessThanOrEqual,
  283. toItem: contentView,
  284. attribute: .width,
  285. multiplier: calculatedTitlePercentage,
  286. constant: 0.0))
  287. } else {
  288. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "H:[imageView]-(15)-[textField]-|", options: [], metrics: nil, views: views)
  289. }
  290. } else {
  291. if let titleLabel = titleLabel, let text = titleLabel.text, !text.isEmpty {
  292. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "H:|-[titleLabel]-[textField]-|", options: [], metrics: nil, views: views)
  293. dynamicConstraints.append(NSLayoutConstraint(item: titleLabel,
  294. attribute: .width,
  295. relatedBy: (row as? FieldRowConformance)?.titlePercentage != nil ? .equal : .lessThanOrEqual,
  296. toItem: contentView,
  297. attribute: .width,
  298. multiplier: calculatedTitlePercentage,
  299. constant: 0.0))
  300. } else {
  301. dynamicConstraints += NSLayoutConstraint.constraints(withVisualFormat: "H:|-[textField]-|", options: .alignAllLeft, metrics: nil, views: views)
  302. }
  303. }
  304. }
  305. contentView.addConstraints(dynamicConstraints)
  306. }
  307. open override func updateConstraints() {
  308. customConstraints()
  309. super.updateConstraints()
  310. }
  311. @objc open func textFieldDidChange(_ textField: UITextField) {
  312. guard let textValue = textField.text else {
  313. row.value = nil
  314. return
  315. }
  316. guard let fieldRow = row as? FieldRowConformance, let formatter = fieldRow.formatter else {
  317. row.value = textValue.isEmpty ? nil : (T.init(string: textValue) ?? row.value)
  318. return
  319. }
  320. if fieldRow.useFormatterDuringInput {
  321. let unsafePointer = UnsafeMutablePointer<T>.allocate(capacity: 1)
  322. defer {
  323. unsafePointer.deallocate()
  324. }
  325. let value: AutoreleasingUnsafeMutablePointer<AnyObject?> = AutoreleasingUnsafeMutablePointer<AnyObject?>.init(unsafePointer)
  326. let errorDesc: AutoreleasingUnsafeMutablePointer<NSString?>? = nil
  327. if formatter.getObjectValue(value, for: textValue, errorDescription: errorDesc) {
  328. row.value = value.pointee as? T
  329. guard var selStartPos = textField.selectedTextRange?.start else { return }
  330. let oldVal = textField.text
  331. textField.text = row.displayValueFor?(row.value)
  332. selStartPos = (formatter as? FormatterProtocol)?.getNewPosition(forPosition: selStartPos, inTextInput: textField, oldValue: oldVal, newValue: textField.text) ?? selStartPos
  333. textField.selectedTextRange = textField.textRange(from: selStartPos, to: selStartPos)
  334. return
  335. }
  336. } else {
  337. let unsafePointer = UnsafeMutablePointer<T>.allocate(capacity: 1)
  338. defer {
  339. unsafePointer.deallocate()
  340. }
  341. let value: AutoreleasingUnsafeMutablePointer<AnyObject?> = AutoreleasingUnsafeMutablePointer<AnyObject?>.init(unsafePointer)
  342. let errorDesc: AutoreleasingUnsafeMutablePointer<NSString?>? = nil
  343. if formatter.getObjectValue(value, for: textValue, errorDescription: errorDesc) {
  344. row.value = value.pointee as? T
  345. } else {
  346. row.value = textValue.isEmpty ? nil : (T.init(string: textValue) ?? row.value)
  347. }
  348. }
  349. }
  350. // MARK: Helpers
  351. private func setupTitleLabel() {
  352. titleLabel = self.textLabel
  353. titleLabel?.translatesAutoresizingMaskIntoConstraints = false
  354. titleLabel?.setContentHuggingPriority(UILayoutPriority(rawValue: 500), for: .horizontal)
  355. titleLabel?.setContentCompressionResistancePriority(UILayoutPriority(rawValue: 1000), for: .horizontal)
  356. }
  357. private func displayValue(useFormatter: Bool) -> String? {
  358. guard let v = row.value else { return nil }
  359. if let formatter = (row as? FormatterConformance)?.formatter, useFormatter {
  360. return textField?.isFirstResponder == true ? formatter.editingString(for: v) : formatter.string(for: v)
  361. }
  362. return String(describing: v)
  363. }
  364. // MARK: TextFieldDelegate
  365. open func textFieldDidBeginEditing(_ textField: UITextField) {
  366. formViewController()?.beginEditing(of: self)
  367. formViewController()?.textInputDidBeginEditing(textField, cell: self)
  368. if let fieldRowConformance = row as? FormatterConformance, let _ = fieldRowConformance.formatter, fieldRowConformance.useFormatterOnDidBeginEditing ?? fieldRowConformance.useFormatterDuringInput {
  369. textField.text = displayValue(useFormatter: true)
  370. } else {
  371. textField.text = displayValue(useFormatter: false)
  372. }
  373. }
  374. open func textFieldDidEndEditing(_ textField: UITextField) {
  375. formViewController()?.endEditing(of: self)
  376. formViewController()?.textInputDidEndEditing(textField, cell: self)
  377. textFieldDidChange(textField)
  378. textField.text = displayValue(useFormatter: (row as? FormatterConformance)?.formatter != nil)
  379. }
  380. open func textFieldShouldReturn(_ textField: UITextField) -> Bool {
  381. return formViewController()?.textInputShouldReturn(textField, cell: self) ?? true
  382. }
  383. open func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
  384. return formViewController()?.textInput(textField, shouldChangeCharactersInRange:range, replacementString:string, cell: self) ?? true
  385. }
  386. open func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
  387. return formViewController()?.textInputShouldBeginEditing(textField, cell: self) ?? true
  388. }
  389. open func textFieldShouldClear(_ textField: UITextField) -> Bool {
  390. return formViewController()?.textInputShouldClear(textField, cell: self) ?? true
  391. }
  392. open func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
  393. return formViewController()?.textInputShouldEndEditing(textField, cell: self) ?? true
  394. }
  395. open override func layoutSubviews() {
  396. super.layoutSubviews()
  397. guard let row = (row as? FieldRowConformance) else { return }
  398. defer {
  399. // As titleLabel is the textLabel, iOS may re-layout without updating constraints, for example:
  400. // swiping, showing alert or actionsheet from the same section.
  401. // thus we need forcing update to use customConstraints()
  402. setNeedsUpdateConstraints()
  403. updateConstraintsIfNeeded()
  404. }
  405. guard let titlePercentage = row.titlePercentage else { return }
  406. var targetTitleWidth = bounds.size.width * titlePercentage
  407. if let imageView = imageView, let _ = imageView.image, let titleLabel = titleLabel {
  408. var extraWidthToSubtract = titleLabel.frame.minX - imageView.frame.minX // Left-to-right interface layout
  409. if UIView.userInterfaceLayoutDirection(for: self.semanticContentAttribute) == .rightToLeft {
  410. extraWidthToSubtract = imageView.frame.maxX - titleLabel.frame.maxX
  411. }
  412. targetTitleWidth -= extraWidthToSubtract
  413. }
  414. calculatedTitlePercentage = targetTitleWidth / contentView.bounds.size.width
  415. }
  416. }