diff --git a/Sources/SketchKit/Constrainable.swift b/Sources/SketchKit/Constrainable.swift index ffc0f47..1331bea 100644 --- a/Sources/SketchKit/Constrainable.swift +++ b/Sources/SketchKit/Constrainable.swift @@ -53,6 +53,11 @@ extension View: Constrainable { public var layout: SketchKitDSL { return SketchKitDSL(constrainable: self) } + + /// A layout guide that dynamically adjusts to the keyboard's frame. + public var keyboardLayoutGuide: LayoutGuide? { + return SketchKitDSL(constrainable: self).resolveKeyboardLayoutGuide() + } } /// LayoutGuide is an abstraction of Apple layout system diff --git a/Sources/SketchKit/KeyboardLayoutGuide.swift b/Sources/SketchKit/KeyboardLayoutGuide.swift new file mode 100644 index 0000000..0f3c2f4 --- /dev/null +++ b/Sources/SketchKit/KeyboardLayoutGuide.swift @@ -0,0 +1,135 @@ +// +// KeyboardLayoutGuide.swift +// SketchKit +// +// Created by Diogo Autilio on 04/10/19. +// Copyright (c) 2021 Anykey Entertrainment. All rights reserved. +// + +import Foundation +import UIKit + +final class SKKeyboard { + static let shared = SKKeyboard() + var currentHeight: CGFloat = 0 + + private init() { + // Safe singleton + } +} + +final class KeyboardLayoutGuide: LayoutGuide { + + private var bottomConstraint: NSLayoutConstraint? + + init(notificationCenter: NotificationCenter = NotificationCenter.default) { + super.init() + // Observe keyboardWillChangeFrame notifications + notificationCenter.addObserver(self, + selector: #selector(adjustKeyboard(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil) + + // Observe keyboardWillHide notifications + notificationCenter.addObserver(self, + selector: #selector(adjustKeyboard(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil) + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + func setUp() { + guard let owningView else { return } + + self.layout.applyConstraint { + $0.heightAnchor(equalToConstant: SKKeyboard.shared.currentHeight) + $0.leftAnchor(equalTo: owningView.leftAnchor) + $0.rightAnchor(equalTo: owningView.rightAnchor) + } + + updateBottomAnchor() + } + + func updateBottomAnchor() { + guard let owningView else { return } + + bottomConstraint?.isActive = false + bottomConstraint = bottomAnchor.constraint(equalTo: owningView.safeBottomAnchor) + bottomConstraint?.isActive = true + } + + @objc + private func adjustKeyboard(_ notification: Notification) { + if var height = notification.keyboardHeight, let duration = notification.animationDuration { + if height > 0, let bottom = owningView?.safeAreaInsets.bottom { + height -= bottom + } + heightConstraint?.constant = height + if duration > 0.0 { + animate(notification) + } + SKKeyboard.shared.currentHeight = height + } + } + + private func animate(_ notification: Notification) { + if let owningView, isVisible(view: owningView) { + owningView.layoutIfNeeded() + } else { + UIView.performWithoutAnimation { [weak self] in + self?.owningView?.layoutIfNeeded() + } + } + } + + private func isVisible(view: UIView) -> Bool { + func isVisible(view: UIView, inView: UIView?) -> Bool { + guard let inView else { return true } + let viewFrame = inView.convert(view.bounds, from: view) + if viewFrame.intersects(inView.bounds) { + return isVisible(view: view, inView: inView.superview) + } + return false + } + return isVisible(view: view, inView: view.superview) + } +} + +// MARK: - Helpers + +extension UILayoutGuide { + internal var heightConstraint: NSLayoutConstraint? { + return owningView?.constraints.first { + $0.firstItem as? UILayoutGuide == self && $0.firstAttribute == .height + } + } +} + +extension Notification { + var keyboardHeight: CGFloat? { + guard let keyboardFrame = userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { + return nil + } + + if name == UIResponder.keyboardWillHideNotification { + return 0.0 + } else { + // Weirdly enough UIKeyboardFrameEndUserInfoKey doesn't have the same behaviour + // in ios 10 or iOS 11 so we can't rely on v.cgRectValue.width + let screenHeight = UIApplication.shared.keyWindow?.bounds.height ?? UIScreen.main.bounds.height + return screenHeight - keyboardFrame.cgRectValue.minY + } + } + + var animationDuration: CGFloat? { + return self.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? CGFloat + } +} diff --git a/Sources/SketchKit/SketchKitDSL.swift b/Sources/SketchKit/SketchKitDSL.swift index 2382510..212f99a 100644 --- a/Sources/SketchKit/SketchKitDSL.swift +++ b/Sources/SketchKit/SketchKitDSL.swift @@ -41,7 +41,7 @@ public typealias EdgeInsets = UIEdgeInsets /// SketchKitDSL public class SketchKitDSL { - let constrainable: Constrainable + private let constrainable: Constrainable init(constrainable: Constrainable) { self.constrainable = constrainable @@ -55,4 +55,23 @@ public class SketchKitDSL { } block(self.constrainable) } + + func resolveKeyboardLayoutGuide() -> LayoutGuide? { + guard let view = constrainable as? View else { return nil } + + let layoutGuideIdentifier = "KeyboardLayoutGuide" + + // Attempt to find an existing keyboard layout guide by its identifier + if let existingLayoutGuide = view.layoutGuides.first(where: { $0.identifier == layoutGuideIdentifier }) { + return existingLayoutGuide + } + + // Create and configure a new keyboard layout guide if it doesn't exist + let keyboardLayoutGuide = KeyboardLayoutGuide() + keyboardLayoutGuide.identifier = layoutGuideIdentifier + view.addLayoutGuide(keyboardLayoutGuide) + keyboardLayoutGuide.setUp() + + return keyboardLayoutGuide + } }