Works for iOS 15 with Swift 5.2
External Package Required: Introspect
I figured this out after sifting through multiple SO answers. The main idea is from this SO answer, but in his code, he used something like Publishers
that just doesn't work on my end. Then, I figured out that he probably meant a keyboard-height observer that conforms to Publisher
protocol.
KeyboardResponder (to get and broadcast keyboard height)
Not my code. All credit to to this SO answer:
import SwiftUI
final class KeyboardResponder: ObservableObject {
private var notificationCenter: NotificationCenter
@Published private(set) var currentHeight: CGFloat = 0
init(center: NotificationCenter = .default) {
notificationCenter = center
notificationCenter.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
deinit {
notificationCenter.removeObserver(self)
}
@objc func keyBoardWillShow(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
currentHeight = keyboardSize.height
}
}
@objc func keyBoardWillHide(notification: Notification) {
currentHeight = 0
}
}
View
Then, in my view struct, I use introspectScrollView
to get access to UIScrollView
, which would allow me to modify the content offsets based on data received from the KeyboardResponder
class.
import Introspect
import SwiftUI
struct ContentView: View {
@ObservedObject var keyboard = KeyboardResponder()
@State private var keyboardHeight: CGFloat = 0
@State private var scrollView: UIScrollView? = nil
var body: some View {
VStack{
ScrollView {
// ...
}.introspectScrollView {scrollView = $0}
YourTextField(...)
}
.onReceive(keyboard.$currentHeight) { height in
// I use a conditional unwrap because in my app, the Scrollview above
// is conditional depending on whether there are messeges fetched
// from the server, which means the scrollView will be nil initially
if let _ = scrollView {
// The following conditional check makes sure the content offset does not reset when user starts typing
if height > 0 && (TextInYourTextField == "" || keyboardHeight == 0) {
self.scrollView!.setContentOffset(CGPoint(x: 0, y: self.scrollView!.contentOffset.y + height), animated: true)
} else {
// For my app, I don't need the following but maybe helfpful for your situation
//self.scrollView!.contentOffset.y = max(self.scrollView!.contentOffset.y - keyboardHeight, 0)
}
keyboardHeight = height
}
}
}
}
The .onReceive()
is applied to the VStack
, NOT on the ScrollView
.
Minor Issues
The only thing that's bothering me is that my console keeps streaming things like:
ignoring singular matrix: ProjectionTransform(m11: 5e-324, m12: 0.0, m13: 0.0, m21: 0.0, m22: 5e-324, m23: 0.0, m31: 24.666666666666664, m32: 25.333333333333332, m33: 1.0)
ignoring singular matrix: ProjectionTransform(m11: 5e-324, m12: 0.0, m13: 0.0, m21: 0.0, m22: 5e-324, m23: 0.0, m31: 24.666666666666664, m32: 25.333333333333332, m33: 1.0)
ignoring singular matrix: ProjectionTransform(m11: 5e-324, m12: 0.0, m13: 0.0, m21: 0.0, m22: 5e-324, m23: 0.0, m31: 24.666666666666664, m32: 25.333333333333332, m33: 1.0)
ignoring singular matrix: ProjectionTransform(m11: 5e-324, m12: 0.0, m13: 0.0, m21: 0.0, m22: 5e-324, m23: 0.0, m31: 24.666666666666664, m32: 25.333333333333332, m33: 1.0)
I'm still trying to figure out whether this should concern me and if not, how to mute it.