Created
November 13, 2025 09:19
-
-
Save dterekhov/0880b4a933bc670b6df168b07ccea220 to your computer and use it in GitHub Desktop.
InvalidFieldDetector: Detects what the field is invalid/empty, scrolls to it and shakes #uikit #swift-api #real-project
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Foundation | |
| import UIKit | |
| public class InvalidFieldDetector { | |
| public static func isNumeric(string: String) -> Bool { | |
| let nums: Set<Character> = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] | |
| return Set(string).isSubset(of: nums) | |
| } | |
| public static func isValidEmail(email: String) -> Bool { | |
| let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" | |
| let emailTest = NSPredicate(format: "SELF MATCHES %@", emailRegEx) | |
| return emailTest.evaluate(with: email) | |
| } | |
| public static func invalidEmptyFields(_ validatingFields: [AnyKeyPath], | |
| inEntity entity: Any) -> [AnyKeyPath] { | |
| return validatingFields.filter { | |
| let fieldValue: Any? = entity[keyPath: $0] | |
| switch fieldValue { | |
| case let optionalIntValue as Int? where optionalIntValue == nil: return true | |
| case let array as [Any] where array.isEmpty: return true | |
| case let intValue as Int where intValue == 0: return true | |
| case let doubleValue as Double where doubleValue == 0.0: return true | |
| case let stringValue as String where stringValue.fw_trimmed.isEmpty: return true | |
| default: return false | |
| } | |
| } | |
| } | |
| // Using the type `KeyValuePairs` make dictionary be ordered | |
| private static func invalidEmptyInputViews | |
| (fromValidationDict validationDict: KeyValuePairs<AnyKeyPath, UIView>, | |
| byEntity entity: Any) -> [UIView] { | |
| let invalidKeyPaths = invalidEmptyFields(validationDict.map { $0.key }, inEntity: entity) | |
| return validationDict.filter({ invalidKeyPaths.contains($0.key) }).map { $0.value } | |
| } | |
| @MainActor | |
| public static func scrollToViewAndShake(_ view: UIView, | |
| scrollView: UIScrollView, | |
| yOffset: CGFloat = 0) { | |
| UIView.animate(withDuration: 0.3, animations: { | |
| var visibleRect = view.frame | |
| visibleRect.origin.y += yOffset | |
| scrollView.scrollRectToVisible(visibleRect, animated: false) | |
| }, completion: { _ in | |
| view.fw_shakeAnimation() | |
| }) | |
| } | |
| /// Check if all required fields in UI are valid. Shake all invalids and scroll to the first found one. | |
| /// | |
| /// - Returns: Is valid | |
| @MainActor | |
| public static func validateRequiredFields(_ validationDict: KeyValuePairs<AnyKeyPath, UIView>, | |
| inEntity entity: Any, | |
| scrollView: UIScrollView, | |
| yOffset: CGFloat = 0) -> Bool { | |
| /* Validation: required fields are non-empty */ | |
| let invalidViews = invalidEmptyInputViews(fromValidationDict: validationDict, byEntity: entity) | |
| if let firstInvalidView = invalidViews.first { | |
| UIView.animate(withDuration: 0.3, animations: { | |
| // Scroll to the first invalid | |
| var visibleRect = firstInvalidView.frame | |
| visibleRect.origin.y += yOffset | |
| scrollView.scrollRectToVisible(visibleRect, animated: false) | |
| }, completion: { _ in | |
| invalidViews.forEach { $0.fw_shakeAnimation() } // But shake all invalids | |
| }) | |
| return false | |
| } | |
| return true | |
| } | |
| } | |
| private extension String { | |
| /// Returns a new string made by removing `.whitespacesAndNewlines` from both ends of the String | |
| var fw_trimmed: String { | |
| return self.trimmingCharacters(in: .whitespacesAndNewlines) | |
| } | |
| } | |
| extension UIView { | |
| /// Perform shake animation on view. Usually used to show something wrong flow in UI. | |
| public func fw_shakeAnimation() { | |
| let animation = CAKeyframeAnimation(keyPath: "transform") | |
| animation.values = [ | |
| NSValue(caTransform3D: CATransform3DMakeTranslation(-5, 0, 0)), | |
| NSValue(caTransform3D: CATransform3DMakeTranslation(5, 0, 0)) | |
| ] | |
| animation.autoreverses = true | |
| animation.repeatCount = 2 | |
| animation.duration = 7/100 | |
| layer.add(animation, forKey: nil) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment