Skip to content

Instantly share code, notes, and snippets.

@crayment
Last active April 29, 2020 17:17
Show Gist options
  • Save crayment/930024068a8558f56fb6781094aebf68 to your computer and use it in GitHub Desktop.
Save crayment/930024068a8558f56fb6781094aebf68 to your computer and use it in GitHub Desktop.
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