Last active
April 29, 2020 17:17
-
-
Save crayment/930024068a8558f56fb6781094aebf68 to your computer and use it in GitHub Desktop.
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 ObjectiveC | |
import UIKit | |
// MARK: - AutoScrollView | |
/** | |
A scroll view that automatically scrolls to first responders when the keyboard is shown. | |
Supports the `focusView` and `focusPadding` properties of the first responder if it conforms to `KeyboardAdjustingResponder` | |
Vertical scrolling support only. | |
*/ | |
class AutoScrollView: UIScrollView { | |
fileprivate var showing = false | |
fileprivate var startingContentInset: UIEdgeInsets? | |
fileprivate var startingIndicatorInsets: UIEdgeInsets? | |
var dismissGesture: UITapGestureRecognizer? | |
var keyboardWillShowAnimations: (() -> Void)? | |
var keyboardWillHideAnimations: (() -> Void)? | |
fileprivate struct Constants { | |
static let DefaultAnimationDuration: TimeInterval = 0.25 | |
static let DefaultAnimationCurve = UIView.AnimationCurve.easeInOut | |
static let ScrollAnimationID = "AutoscrollAnimation" | |
} | |
fileprivate func commonInit() { | |
NotificationCenter.default.addObserver( | |
self, | |
selector: #selector(AutoScrollView.keyboardWillChangeFrame(_:)), | |
name: UIWindow.keyboardWillChangeFrameNotification, | |
object: nil | |
) | |
NotificationCenter.default.addObserver( | |
self, | |
selector: #selector(AutoScrollView.keyboardWillHide(_:)), | |
name: UIWindow.keyboardWillHideNotification, | |
object: nil | |
) | |
NotificationCenter.default.addObserver( | |
self, | |
selector: #selector(AutoScrollView.firstResponderChanged(_:)), | |
name: UITextField.textDidBeginEditingNotification, | |
object: nil | |
) | |
NotificationCenter.default.addObserver( | |
self, | |
selector: #selector(AutoScrollView.firstResponderChanged(_:)), | |
name: UITextView.textDidBeginEditingNotification, | |
object: nil | |
) | |
dismissGesture = { | |
let dismissGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) | |
dismissGesture.delegate = self | |
dismissGesture.cancelsTouchesInView = false | |
addGestureRecognizer(dismissGesture) | |
return dismissGesture | |
}() | |
} | |
override init(frame: CGRect) { | |
super.init(frame: frame) | |
commonInit() | |
} | |
required init?(coder aDecoder: NSCoder) { | |
super.init(coder: aDecoder) | |
commonInit() | |
} | |
deinit { | |
NotificationCenter.default.removeObserver(self) | |
} | |
// MARK: Notifications | |
@objc fileprivate func firstResponderChanged(_: Notification) { | |
// For some reason UIKit is handling this for us in a way I can't figure out how to opt out of | |
adjustForFirstResponder(keyboardNotification: nil) | |
} | |
// Implementation based on code from Apple documentation | |
// https://developer.apple.com/library/ios/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html | |
@objc fileprivate func keyboardWillChangeFrame(_ notification: Notification) { | |
if !showing { | |
showing = true | |
startingContentInset = contentInset | |
startingIndicatorInsets = verticalScrollIndicatorInsets | |
} | |
let keyboardFrameValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue | |
guard | |
let screenKeyboardFrame = keyboardFrameValue?.cgRectValue, | |
let windowKeyboardFrame = window?.convert(screenKeyboardFrame, from: nil) | |
else { | |
return | |
} | |
let keyboardFrame = convert(windowKeyboardFrame, from: nil) | |
let keyboardIntersectionRect = bounds.intersection(keyboardFrame) | |
if !keyboardIntersectionRect.isNull { | |
contentInset.bottom = keyboardIntersectionRect.height | |
scrollIndicatorInsets = contentInset | |
} | |
adjustForFirstResponder(keyboardNotification: notification) | |
} | |
@objc fileprivate func keyboardWillHide(_ notification: Notification) { | |
if showing { | |
contentInset = startingContentInset ?? UIEdgeInsets.zero | |
scrollIndicatorInsets = startingIndicatorInsets ?? UIEdgeInsets.zero | |
showing = false | |
} | |
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval | |
?? Constants.DefaultAnimationDuration | |
let curve: UIView.AnimationOptions = | |
((notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue) | |
.flatMap { UIView.AnimationOptions(rawValue: $0) } | |
?? UIView.AnimationOptions.curveEaseOut | |
UIView.animate( | |
withDuration: duration, | |
delay: 0, | |
options: [curve, .beginFromCurrentState], | |
animations: { | |
self.layoutIfNeeded() | |
self.keyboardWillHideAnimations?() | |
}, | |
completion: nil | |
) | |
} | |
@objc fileprivate func dismissKeyboard() { | |
findFirstResponder()?.endEditing(false) | |
} | |
private func adjustForFirstResponder(keyboardNotification: Notification?) { | |
guard | |
showing, | |
let firstResponder = findFirstResponder() | |
else { | |
return | |
} | |
adjust(forView: firstResponder, keyboardNotification: nil) | |
} | |
func adjust(forView firstResponder: UIView, keyboardNotification: Notification?) { | |
// Compute the rect that is actually visible (similar to bounds but not obstructed by transparent nav or keyboard) | |
let visibleRect = CGRect( | |
x: contentOffset.x, | |
y: contentOffset.y + adjustedContentInset.top, | |
width: visibleSize.width, | |
height: visibleSize.height - adjustedContentInset.bottom - adjustedContentInset.top | |
) | |
func findFittingFocusFrame(_ view: UIView) -> CGRect? { | |
let responder = (view as? KeyboardAdjustingResponder) | |
// First try focusView | |
if let responder = responder, let focusView = responder.focusView { | |
let focusFrame = convert(focusView.bounds, from: focusView).insetBy(dx: 0, dy: -responder.focusPadding) | |
let canFit = visibleRect.height > focusFrame.height | |
if canFit { | |
return focusFrame | |
} | |
} | |
// Then try view.bounds | |
let focusFrame = convert(view.bounds, from: view).insetBy(dx: 0, dy: -(responder?.focusPadding ?? 10)) | |
let canFit = visibleRect.height > focusFrame.height | |
if canFit { | |
return focusFrame | |
} | |
// Finally try alternateConstrainedFrame | |
if let responder = responder, let alternateFrame = responder.alternateConstrainedFrame { | |
let focusFrame = convert(alternateFrame, from: view).insetBy(dx: 0, dy: -responder.focusPadding) | |
let canFit = visibleRect.height > focusFrame.height | |
if canFit { | |
return focusFrame | |
} | |
} | |
// None of our rects fit | |
return nil | |
} | |
guard | |
let focusFrame = findFittingFocusFrame(firstResponder), | |
!visibleRect.contains(focusFrame) | |
else { | |
return | |
} | |
if let notification = keyboardNotification { | |
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval | |
?? Constants.DefaultAnimationDuration | |
let curve: UIView.AnimationOptions = | |
((notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue) | |
.flatMap { UIView.AnimationOptions(rawValue: $0) } | |
?? UIView.AnimationOptions.curveEaseOut | |
UIView.animate( | |
withDuration: duration, | |
delay: 0, | |
options: [curve, .beginFromCurrentState], | |
animations: { | |
self.layoutIfNeeded() | |
self.keyboardWillHideAnimations?() | |
}, | |
completion: nil | |
) | |
} else { | |
UIView.animate(withDuration: 0.4) { | |
self.scrollRectToVisible(focusFrame, animated: false) | |
} | |
} | |
} | |
} | |
extension AutoScrollView: UIGestureRecognizerDelegate { | |
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { | |
if gestureRecognizer === dismissGesture, touch.view is UIControl { | |
return false | |
} | |
return true | |
} | |
} | |
// MARK: - Helpers | |
private extension UIView { | |
/// Find the first responder in the view hierarchy | |
func findFirstResponder() -> UIView? { | |
if isFirstResponder { | |
return self | |
} | |
for subview in subviews { | |
if let responder = subview.findFirstResponder() { | |
return responder | |
} | |
} | |
return nil | |
} | |
func findContainingAutoScrollView() -> AutoScrollView? { | |
if let scrollView = self as? AutoScrollView { | |
return scrollView | |
} | |
return superview?.findContainingAutoScrollView() | |
} | |
} | |
protocol KeyboardAdjustingResponder: AnyObject { | |
/// An alternate view to try to bring into view when scrolling on keyboard events. | |
var focusView: UIView? { get } | |
/// An alternate padding to use beyond the frame of focusview | |
var focusPadding: CGFloat { get } | |
/// An alternate frame to use if the view (or focusView) is too large to fit in the visible space. | |
var alternateConstrainedFrame: CGRect? { get } | |
} | |
private struct AssociatedKeys { | |
static var focusView = "focusView" | |
static var focusPadding = "focusPadding" | |
} | |
extension KeyboardAdjustingResponder { | |
/// Associated (weak) property to aid in adding conformance through extensions (since we can't add @IBOutlet through protocols :( | |
var _focusView: UIView? { | |
get { | |
return objc_getAssociatedObject(self, &AssociatedKeys.focusView) as? UIView | |
} | |
set { | |
if let newValue = newValue { | |
objc_setAssociatedObject( | |
self, | |
&AssociatedKeys.focusView, | |
newValue as UIView?, | |
.OBJC_ASSOCIATION_ASSIGN | |
) | |
} | |
} | |
} | |
var _focusPadding: CGFloat { | |
get { | |
return (objc_getAssociatedObject(self, &AssociatedKeys.focusPadding) as? NSNumber) | |
.map { CGFloat($0.floatValue) } | |
?? 10 | |
} | |
set { | |
objc_setAssociatedObject( | |
self, | |
&AssociatedKeys.focusPadding, | |
NSNumber(value: Double(newValue)), | |
.OBJC_ASSOCIATION_COPY | |
) | |
} | |
} | |
} | |
extension UIView { | |
func adjustContainingAutoScrollView() { | |
findContainingAutoScrollView()?.adjust(forView: self, keyboardNotification: nil) | |
} | |
} | |
extension UITextField: KeyboardAdjustingResponder { | |
@IBOutlet var focusView: UIView? { | |
get { return _focusView } | |
set { _focusView = newValue } | |
} | |
@IBInspectable var focusPadding: CGFloat { | |
get { return _focusPadding } | |
set { _focusPadding = newValue } | |
} | |
var alternateConstrainedFrame: CGRect? { | |
return (selectedTextRange?.start) | |
.map(caretRect(for:)) | |
.map { $0.insetBy(dx: 0, dy: -10) } | |
} | |
} | |
extension UITextView: KeyboardAdjustingResponder { | |
@IBOutlet var focusView: UIView? { | |
get { return _focusView } | |
set { _focusView = newValue } | |
} | |
@IBInspectable var focusPadding: CGFloat { | |
get { return _focusPadding } | |
set { _focusPadding = newValue } | |
} | |
var alternateConstrainedFrame: CGRect? { | |
return (selectedTextRange?.start) | |
.map(caretRect(for:)) | |
.map { $0.insetBy(dx: 0, dy: -10) } | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment