Skip to content

Instantly share code, notes, and snippets.

@dterekhov
Created November 13, 2025 09:19
Show Gist options
  • Select an option

  • Save dterekhov/0880b4a933bc670b6df168b07ccea220 to your computer and use it in GitHub Desktop.

Select an option

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
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