Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ScrollView observes and adjusts for keyboard position #55

Merged
merged 5 commits into from Apr 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
195 changes: 195 additions & 0 deletions BlueprintUICommonControls/Sources/Internal/KeyboardObserver.swift
@@ -0,0 +1,195 @@
//
// KeyboardObserver.swift
// BlueprintUICommonControls
//
// Created by Kyle Van Essen on 2/16/20.
//

import UIKit


protocol KeyboardObserverDelegate : AnyObject {

func keyboardFrameWillChange(
for observer : KeyboardObserver,
animationDuration : Double,
options : UIView.AnimationOptions
)
}

/**
Encapsulates listening for system keyboard updates, plus transforming the visible frame of the keyboard into the coordinates of a requested view.

You use this class by providing a delegate, which receives callbacks when changes to the keyboard frame occur. You would usually implement
the delegate somewhat like this:

```
func keyboardFrameWillChange(
for observer : KeyboardObserver,
animationDuration : Double,
options : UIView.AnimationOptions
) {
UIView.animate(withDuration: animationDuration, delay: 0.0, options: options, animations: {
// Use the frame from the keyboardObserver to update insets or sizing where relevant.
})
}
```

Notes
-----
Implementation borrowed from Listable:
https://github.com/kyleve/Listable/blob/master/Listable/Sources/Internal/KeyboardObserver.swift

iOS Docs for keyboard management:
https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html
*/
kyleve marked this conversation as resolved.
Show resolved Hide resolved
final class KeyboardObserver {

private let center : NotificationCenter

weak var delegate : KeyboardObserverDelegate?

//
// MARK: Initialization
//

init(center : NotificationCenter = .default) {

self.center = center

/// We need to listen to both `will` and `keyboardDidChangeFrame` notifications. Why?
/// When dealing with an undocked or floating keyboard, moving the keyboard
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What actually happens with an undocked keyboard? We don't want to treat it as a bottom content inset in those cases, do we? e.g. if the keyboard is floating near the top of the screen, we should probably just leave the content offset alone.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went back and forth on this... there's four forms of keyboards:

  1. Docked, takes up full width
  2. Undocked, takes up full width
  3. Also undocked, split, also semantically is full width
  4. Floating on iPad, does not take up full width

I figured we generally want this for the first three cases, so kept it simple for now. I'll follow up with something for #4, to not inset in this case?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#71

/// around the screen does NOT call `willChangeFrame`; only `didChangeFrame` is called.
/// Before calling the delegate, we compare `old.endingFrame != new.endingFrame`,
/// which ensures that the delegate is notified if the frame really changes, and
/// prevents duplicate calls.

self.center.addObserver(self, selector: #selector(keyboardFrameChanged(_:)), name: UIWindow.keyboardWillChangeFrameNotification, object: nil)
self.center.addObserver(self, selector: #selector(keyboardFrameChanged(_:)), name: UIWindow.keyboardDidChangeFrameNotification, object: nil)
kyleve marked this conversation as resolved.
Show resolved Hide resolved
}

private var latestNotification : NotificationInfo?

//
// MARK: Handling Changes
//

enum KeyboardFrame : Equatable {

/// The current frame does not overlap the current view at all.
case nonOverlapping

/// The current frame does overlap the view, by the provided rect, in the view's coordinate space.
case overlapping(frame: CGRect)
}

/// How the keyboard overlaps the view provided. If the view is not on screen (eg, no window),
/// or the observer has not yet learned about the keyboard's position, this method returns nil.
func currentFrame(in view : UIView) -> KeyboardFrame? {

guard view.window != nil else {
return nil
}

guard let notification = self.latestNotification else {
return nil
}

let frame = view.convert(notification.endingFrame, from: nil)

if frame.intersects(view.bounds) {
return .overlapping(frame: frame)
} else {
return .nonOverlapping
}
}

//
// MARK: Receiving Updates
//

private func receivedUpdatedKeyboardInfo(_ new : NotificationInfo) {

let old = self.latestNotification

self.latestNotification = new

/// Only communicate a frame change to the delegate if the frame actually changed.

if let old = old, old.endingFrame == new.endingFrame {
return
}

/**
Create an animation curve with the correct curve for showing or hiding the keyboard.

This is unfortunately a private UIView curve. However, we can map it to the animation options' curve
like so: https://stackoverflow.com/questions/26939105/keyboard-animation-curve-as-int
*/
let animationOptions = UIView.AnimationOptions(rawValue: new.animationCurve << 16)

self.delegate?.keyboardFrameWillChange(
for: self,
animationDuration: new.animationDuration,
options: animationOptions
)
}

//
// MARK: Notification Listeners
//

@objc private func keyboardFrameChanged(_ notification : Notification) {

do {
let info = try NotificationInfo(with: notification)
self.receivedUpdatedKeyboardInfo(info)
} catch {
assertionFailure("Blueprint could not read system keyboard notification. This error needs to be fixed in Blueprint. Error: \(error)")
}
}
}

extension KeyboardObserver
{
struct NotificationInfo : Equatable {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to be internal for testing access.


var endingFrame : CGRect = .zero

var animationDuration : Double = 0.0
var animationCurve : UInt = 0

init(with notification : Notification) throws {

guard let userInfo = notification.userInfo else {
throw ParseError.missingUserInfo
}

guard let endingFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else {
throw ParseError.missingEndingFrame
}

self.endingFrame = endingFrame

guard let animationDuration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue else {
throw ParseError.missingAnimationDuration
}

self.animationDuration = animationDuration

guard let animationCurve = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue else {
throw ParseError.missingAnimationCurve
}

self.animationCurve = animationCurve
}

enum ParseError : Error, Equatable {

case missingUserInfo
case missingEndingFrame
case missingAnimationDuration
case missingAnimationCurve
}
}
}