1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
|
//
// AutomaticKeyboardResponder.swift
// MullvadVPN
//
// Created by pronebird on 24/03/2021.
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
import MullvadLogging
import UIKit
@MainActor
class AutomaticKeyboardResponder {
weak var targetView: UIView?
private let handler: (UIView, CGFloat) -> Void
private var lastKeyboardRect: CGRect?
init<T: UIView>(targetView: T, handler: @escaping (T, CGFloat) -> Void) {
self.targetView = targetView
self.handler = { view, adjustment in
if let view = view as? T {
handler(view, adjustment)
}
}
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillChangeFrame(_:)),
name: UIResponder.keyboardWillChangeFrameNotification,
object: nil
)
}
func updateContentInsets() {
guard let keyboardRect = lastKeyboardRect else { return }
adjustContentInsets(convertedKeyboardFrameEnd: keyboardRect)
}
// MARK: - Keyboard notifications
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
handleKeyboardNotification(notification)
}
// MARK: - Private
private func handleKeyboardNotification(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let targetView
else { return }
// In iOS 16.1 and later, the keyboard notification object is the screen the keyboard appears on.
if #available(iOS 16.1, *) {
guard let screen = notification.object as? UIScreen,
// Get the keyboard’s frame at the end of its animation.
let keyboardFrameEnd = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
// Use that screen to get the coordinate space to convert from.
let fromCoordinateSpace = screen.coordinateSpace
// Get your view's coordinate space.
let toCoordinateSpace: UICoordinateSpace = targetView
// Convert the keyboard's frame from the screen's coordinate space to your view's coordinate space.
let convertedKeyboardFrameEnd = fromCoordinateSpace.convert(keyboardFrameEnd, to: toCoordinateSpace)
lastKeyboardRect = convertedKeyboardFrameEnd
adjustContentInsets(convertedKeyboardFrameEnd: convertedKeyboardFrameEnd)
} else {
guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue
else { return }
let keyboardFrameEnd = keyboardValue.cgRectValue
let convertedKeyboardFrameEnd = targetView.convert(keyboardFrameEnd, from: targetView.window)
lastKeyboardRect = convertedKeyboardFrameEnd
adjustContentInsets(convertedKeyboardFrameEnd: convertedKeyboardFrameEnd)
}
}
private func adjustContentInsets(convertedKeyboardFrameEnd: CGRect) {
guard let targetView else { return }
// Get the safe area insets when the keyboard is offscreen.
var bottomOffset = targetView.safeAreaInsets.bottom
// Get the intersection between the keyboard's frame and the view's bounds to work with the
// part of the keyboard that overlaps your view.
let viewIntersection = targetView.bounds.intersection(convertedKeyboardFrameEnd)
// Check whether the keyboard intersects your view before adjusting your offset.
if !viewIntersection.isEmpty {
// Adjust the offset by the difference between the view's height and the height of the
// intersection rectangle.
bottomOffset = targetView.bounds.maxY - viewIntersection.minY
}
handler(targetView, bottomOffset)
}
}
extension AutomaticKeyboardResponder {
/// A convenience initializer that automatically assigns the offset to the scroll view
/// subclasses
convenience init(targetView: some UIScrollView) {
self.init(targetView: targetView) { scrollView, offset in
if scrollView.canBecomeFirstResponder {
scrollView.contentInset.bottom = targetView.isFirstResponder ? offset : 0
scrollView.verticalScrollIndicatorInsets.bottom =
targetView.isFirstResponder
? offset
: 0
} else {
scrollView.contentInset.bottom = offset
scrollView.verticalScrollIndicatorInsets.bottom = offset
}
}
}
}
|