Reputation: 13537
I have seven TextField
inside my main ContentView
. When user open keyboard some of the TextField
are hidden under the keyboard frame. So I want to move all TextField
up respectively when the keyboard has appeared.
I have used the below code to add TextField
on the screen.
struct ContentView : View {
@State var textfieldText: String = ""
var body: some View {
VStack {
TextField($textfieldText, placeholder: Text("TextField1"))
TextField($textfieldText, placeholder: Text("TextField2"))
TextField($textfieldText, placeholder: Text("TextField3"))
TextField($textfieldText, placeholder: Text("TextField4"))
TextField($textfieldText, placeholder: Text("TextField5"))
TextField($textfieldText, placeholder: Text("TextField6"))
TextField($textfieldText, placeholder: Text("TextField6"))
TextField($textfieldText, placeholder: Text("TextField7"))
}
}
}
Output:
Upvotes: 166
Views: 149964
Reputation: 31
Here's a simple solution I found. I don't know why, but solutions with paddings don't work in my case (iOS 17.5). So I decided to just add a safe area at the bottom when the keyboard appears. The solution works with ScrollView, Form and List, but it doesn't work with VStack.
import SwiftUI
struct FieldWithKeyboard: View {
@State var text = ""
@State private var isKeyboardVisible = false
var body: some View {
ScrollView {
Spacer(minLength: 500)
TextField("Text here", text: $text)
Spacer(minLength: 200)
TextField("Text here", text: $text)
}
// Here it is
.safeAreaInset(edge: .bottom) {
if isKeyboardVisible {
Color.clear.frame(height: 50)
}
}
// Keyboard appearance notifications
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
isKeyboardVisible = true
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
isKeyboardVisible = false
}
}
}
#Preview {
FieldWithKeyboard()
}
Upvotes: 3
Reputation: 119118
From iOS 14.2, TextField
s are keyboard aware by default if they have enough space to move. For example, if it is in a VStack
with a Spacer
(Look at the old demo code below without the modifier)
⚠️ It seems the following code is not working as expected for +iOS 14.2
🛑 I know it seems weird to say ignore
and expect the opposite, but unfortunately it is how it is on older SwiftUIs 🤷🏻♂️
Xcode 12 (to iOS 14.2) - One line code
Add this modifier to the TextField
.ignoresSafeArea(.keyboard, edges: .bottom)
Apple added the keyboard as a region for the safe area, so you can use it to move any View
with the keyboard like other regions.
Upvotes: 59
Reputation: 86
That solution worked well for me, but it didn't have proper animation. Here is a solution, that animates content height change with animation of keyobard.
struct KeyboardHost<Content>: View where Content: View {
private let content: Content
/// The current height of the keyboard rect.
@State private var keyboardHeight = 0.0
private struct KeyboardChange {
let height: CGFloat
let animation: Animation
}
/// A publisher that combines all of the relevant keyboard changing notifications and maps them into a `CGFloat` representing the new height of the
/// keyboard rect.
private let keyboardChangePublisher = NotificationCenter.Publisher(
center: .default,
name: UIResponder.keyboardWillShowNotification
)
.merge(
with: NotificationCenter.Publisher(
center: .default,
name: UIResponder.keyboardWillChangeFrameNotification)
)
.merge(
with: NotificationCenter.Publisher(
center: .default,
name: UIResponder.keyboardWillHideNotification
)
.map { Notification(name: $0.name, object: $0.object, userInfo: nil) })
.map { notification -> KeyboardChange in
let height = (notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect ?? .zero).size.height
let isHiding = height == .zero
let defaultDuration = isHiding ? 0.16 : 0.25
let duration = (notification.userInfo?[UIWindow.keyboardAnimationDurationUserInfoKey] as? Double) ?? defaultDuration
return KeyboardChange(
height: height,
animation: isHiding ? .easeOut(duration: duration) : .easeIn(duration: duration)
)
}
init(@ViewBuilder _ content: @escaping () -> Content) {
self.content = content()
}
var body: some View {
content
.onReceive(keyboardChangePublisher) { change in
withAnimation(change.animation) {
self.keyboardHeight = change.height
}
}
.padding(.bottom, keyboardHeight)
}
}
Upvotes: 2
Reputation: 166
In my case I couldn't use the default behavior because I'm using .ignoresSafeArea()
and the solutions I tried here didn't work for my specific UI so decided to just manually scroll to the view (only when it is hidden by the keyboard).
The idea is simply to check the bottom position of the view and see if it is below the top position of the keyboard, if it is, scroll.
Honestly the result code should have been much shorter but unfortunately SwiftUI
.scrollTo(x: , y: )
to a specific x,y location, it only accepts an id and an anchor to that id in the form of a ratio (0 to 1)Took me a while to figure this out so sharing it in case some of it maybe helps someone
struct CustomInput: View {
@Binding var text: String
@State private var containerFrame: CGRect = .zero
@State private var containerId = UUID().uuidString
@FocusState private var hasFocus: Bool
@StateObject private var keyboard = KeyboardObserver.shared
// The parent ScrollView should provide this
let scrollProxy: ScrollViewProxy?
var body: some View {
TextField("", text: $text)
.id(containerId)
.focused($hasFocus)
.onChange(of: hasFocus) { isFocused in
if isFocused {
scrollToInputIfNeeded()
}
}
.onTapGesture {
hasFocus = true
}
.getFrame { frame in
// Not sure if the != check is actually necessary, but kept getting
// "... tried to update multiple times per frame" before adding it
if containerFrame != frame {
containerFrame = frame
}
}
}
private func scrollToInputIfNeeded() {
if let scrollProxy {
// Had to add a delay because when the input gets focused,
// the keyboard height is still 0 (not sure if it's because of the slide animation)
// The alternative to using a delay was to trigger this code when the keyboard height
// reports a change with
// `.onChange(of: keyboard.keyboardHeight) { newKeyboardHeight in ... }`
// But for some reason keyboardHeight sometimes triggers twice with a different
// height so it was unreliable
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
let screenHeight = UIScreen.main.bounds.height
let keyboardTopY = UIScreen.main.bounds.height - keyboard.keyboardHeight
// To get a bit of space between the bottom
// of the view and the top of the keyboard
let padding = 16.0
let itemTopY = containerFrame.minY
let itemBottomY = containerFrame.maxY
let itemHeight = containerFrame.height
if itemBottomY > (keyboardTopY - padding) {
let targetTopY = itemTopY - (itemBottomY - keyboardTopY) - padding
let targetTopYRatio = targetTopY / screenHeight
let anchor = (targetTopYRatio * screenHeight) / (screenHeight - itemHeight)
withAnimation {
// `.scrollTo()` doesn't accept a x,y in absolute pixel values
// like x: 0, y: 300, instead, it seems to accept a ratio
scrollProxy.scrollTo(containerId, anchor: UnitPoint(x: 0.5, y: anchor))
}
}
}
}
}
}
The above does the following:
To use it I simply put the view in a ScrollView + ScrollViewReader and I add the keyboard height at the bottom of the ScrollView (I need this since I'm using .ignoresSafeArea) like so:
ScrollViewReader { scrollProxy in
VStack {
ScrollView {
// ...
CustomInput(text: $text, scrollProxy: scrollProxy)
// ...
Spacer().frame(height: keyboardHeight == 0
? UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0
: keyboardHeight
)
}
}
}
To get the frame of the view and the keyboard's height I use the same standard ways I've seen posted elsewhere but I'll include it here as well for completeness:
extension View {
func getFrame(onChange: @escaping (CGRect) -> Void) -> some View {
background(
GeometryReader { geo in
Color.clear
.preference(key: FramePreferenceKey.self, value: geo.frame(in: .global))
}
)
.onPreferenceChange(FramePreferenceKey.self, perform: onChange)
}
}
private struct FramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
}
class KeyboardObserver: ObservableObject {
static let shared = KeyboardObserver()
@Published var keyboardHeight: CGFloat = 0
private init() {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@objc func keyboardWillShow(notification: Notification) {
if let rect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
DispatchQueue.main.async {
self.keyboardHeight = rect.height
}
}
}
@objc func keyboardWillHide(notification: Notification) {
DispatchQueue.main.async {
self.keyboardHeight = 0
}
}
}
Upvotes: 0
Reputation: 1169
Here's a different approach that I had to do for making it work in iOS 15
import Combine
import UIKit
public final class KeyboardResponder: ObservableObject {
@Published public var keyboardHeight: CGFloat = 0
var showCancellable: AnyCancellable?
var hideCancellable: AnyCancellable?
public init() {
showCancellable = NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
.map { notification in
(notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.height ?? 0.0
}
.receive(on: DispatchQueue.main)
.sink(receiveValue: { height in
print(height)
self.keyboardHeight = height
})
hideCancellable = NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)
.receive(on: DispatchQueue.main)
.sink(receiveValue: { _ in
self.keyboardHeight = 0
})
}
}
And then use it like this:
@StateObject private var keyboardResponder = KeyboardResponder()
SomeView
.padding(.bottom, keyboardResponder.keyboardHeight)
It's not the cleanest solution, but I wasn't able to get 0 in return from the notification when dismissing the keyboard, so I had to split them up like this.. Hope this will help someone :)
Upvotes: 0
Reputation: 63
I faced the same scenario and issue with multiple text field scrolling. I'm not an expert but I found this solution works perfectly
import SwiftUI
struct MyView: View {
@State var titlesArray = ["ATitle" , "BTitle" , "CTitle" , "DTitle"
, "ETitle" , "FTitle" , "GTitle", "HTitle", "ITitle", "JTitle", "KTitle", "LTitle", "MTitle", "NTitle", "OTitle", "PTitle", "QTitle", "RTitle", "STitle", "TTitle", "UTitle", "VTitle", "WTitle", "XTitle", "YTitle", "ZTitle"]
@State var name = ""
@State private var isKeyboardVisible = false
var body: some View {
VStack {
ScrollViewReader { proxy in // Use a ScrollViewReader to scroll to fields
ScrollView {
LazyVStack(spacing : 20) {
ForEach(Array(titlesArray.indices), id: \.self) { index in
TextField("Text Field \(index+1)", text: $name, onEditingChanged: { isFocused in
if isFocused {
withAnimation {
proxy.scrollTo(index,anchor : .top)// scroll the selected textfield
}
}
})
.id(index) // provide the unique id for ScrollViewReader to read which text field should go on top
.frame(height: 45)
.padding([.leading,.trailing],20)
.disableAutocorrection(true)
.keyboardType(.alphabet)
.submitLabel(.return)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(Colors().mheroon, lineWidth: 1)
)
.padding([.leading,.trailing],20)
}
}
.padding(.bottom, isKeyboardVisible ? 180 : 0) // to give some extra space for scorll view else last text field will not scroll on top
}
}
.padding(.top,20)
Spacer()
VStack {
Spacer()
Button {
} label: {
Text("continue")
.padding()
}
Spacer()
}
.frame(height: 80)
}
.ignoresSafeArea(.keyboard, edges: .bottom)
//if you provide such padding .ignoresSafeArea(.keyboard, edges: .bottom) this line of code willn't work and default scrolling will go on
// .padding(.top,50)
// .padding()
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
self.isKeyboardVisible = true
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
self.isKeyboardVisible = false
}
}
}
struct MyView_Previews: PreviewProvider {
static var previews: some View {
MyView()
}
}
Upvotes: 3
Reputation: 563
I've gone thru every single solution here and whilst some of them are nicely implemented, none of them worked correctly displaying half of the text field. Also none of the solutions work at all with a TextEditor control unless you offset the -y coordinate of the content which would look odd anyway. The user needs to be able to scroll thru all the form fields even when the keyboard is displayed.
The scenario is when you have a view which contains a form with a ScrollView that has a number of text fields including a Text editor field and a button that is always visible at the bottom of the form using .ignoresSafeArea(.keyboard). I am still working on this issue. If anyone has a complete solution please kindly assist.
Also I found that unfortunately when using .ignoresSafeArea(.keyboard) to make the button displayed always at the bottom if I use a ScrollViewReader in combination with any of the solutions above, scrollTo just doesn't work at all.
Upvotes: 2
Reputation: 418
If you want the screen to be designed like this, Then you can use the overlays like follow.
struct LoginView: View {
var body: some View {
VStack(spacing: 0) {
Color.clear
.overlay {
LogoImageView()
// Here you can add your any Logo image
}
Text("Login to your account")
Color.clear
.overlay {
TextFieldView()
// Here you can add multiple text field in separate
VStack.
}
Text("version text")
}
}
}
If you want the keyboard to be overlapped on textField, use the following code.
.ignoresSafeArea(.keyboard, edges: .bottom)
add this line after parent Vstack.
Upvotes: 2
Reputation: 494
If you are using iOS 14+ with scrollview or have the option to use scrollview.
https://developer.apple.com/documentation/swiftui/scrollviewproxy https://developer.apple.com/documentation/swiftui/scrollviewreader
Below might help
ScrollViewReader { (proxy: ScrollViewProxy) in
ScrollView {
view1().frame(height: 200)
view2().frame(height: 200)
view3() <-----this has textfields
.onTapGesture {
proxy.scrollTo(1, anchor: .center)
}
.id(1)
view4() <-----this has text editor
.onTapGesture {
proxy.scrollTo(2, anchor: .center)
}
.id(2)
view5().frame(height: 200)
view6().frame(height: 200)
submtButton().frame(height: 200)
}
}
imp part from above is
anyView().onTapGesture {
proxy.scrollTo(_ID, anchor: .center)
}.id(_ID)
Hope this helps someone :)
Upvotes: 3
Reputation: 1460
Usage:
import SwiftUI
var body: some View {
ScrollView {
VStack {
/*
TextField()
*/
}
}.keyboardSpace()
}
Code:
import SwiftUI
import Combine
let keyboardSpaceD = KeyboardSpace()
extension View {
func keyboardSpace() -> some View {
modifier(KeyboardSpace.Space(data: keyboardSpaceD))
}
}
class KeyboardSpace: ObservableObject {
var sub: AnyCancellable?
@Published var currentHeight: CGFloat = 0
var heightIn: CGFloat = 0 {
didSet {
withAnimation {
if UIWindow.keyWindow != nil {
//fix notification when switching from another app with keyboard
self.currentHeight = heightIn
}
}
}
}
init() {
subscribeToKeyboardEvents()
}
private let keyboardWillOpen = NotificationCenter.default
.publisher(for: UIResponder.keyboardWillShowNotification)
.map { $0.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect }
.map { $0.height - (UIWindow.keyWindow?.safeAreaInsets.bottom ?? 0) }
private let keyboardWillHide = NotificationCenter.default
.publisher(for: UIResponder.keyboardWillHideNotification)
.map { _ in CGFloat.zero }
private func subscribeToKeyboardEvents() {
sub?.cancel()
sub = Publishers.Merge(keyboardWillOpen, keyboardWillHide)
.subscribe(on: RunLoop.main)
.assign(to: \.self.heightIn, on: self)
}
deinit {
sub?.cancel()
}
struct Space: ViewModifier {
@ObservedObject var data: KeyboardSpace
func body(content: Content) -> some View {
VStack(spacing: 0) {
content
Rectangle()
.foregroundColor(Color(.clear))
.frame(height: data.currentHeight)
.frame(maxWidth: .greatestFiniteMagnitude)
}
}
}
}
extension UIWindow {
static var keyWindow: UIWindow? {
let keyWindow = UIApplication.shared.connectedScenes
.first { $0.activationState == .foregroundActive }
.flatMap { $0 as? UIWindowScene }?.windows
.first { $0.isKeyWindow }
return keyWindow
}
}
Upvotes: 6
Reputation: 14265
Or You can just use IQKeyBoardManagerSwift
and can optionally add this to your app delegate to hide the toolbar and enable hiding of keyboard on click on any view other then keyboard.
IQKeyboardManager.shared.enableAutoToolbar = false
IQKeyboardManager.shared.shouldShowToolbarPlaceholder = false
IQKeyboardManager.shared.shouldResignOnTouchOutside = true
IQKeyboardManager.shared.previousNextDisplayMode = .alwaysHide
Upvotes: 31
Reputation: 3009
I tried many of the proposed solutions, and even though they work in most cases, I had some issues - mainly with safe area (I have a Form inside TabView's tab).
I ended up combining few different solutions, and using GeometryReader in order to get specific view's safe area bottom inset and use it in padding's calculation:
import SwiftUI
import Combine
struct AdaptsToKeyboard: ViewModifier {
@State var currentHeight: CGFloat = 0
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.padding(.bottom, self.currentHeight)
.onAppear(perform: {
NotificationCenter.Publisher(center: NotificationCenter.default, name: UIResponder.keyboardWillShowNotification)
.merge(with: NotificationCenter.Publisher(center: NotificationCenter.default, name: UIResponder.keyboardWillChangeFrameNotification))
.compactMap { notification in
withAnimation(.easeOut(duration: 0.16)) {
notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
}
}
.map { rect in
rect.height - geometry.safeAreaInsets.bottom
}
.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight))
NotificationCenter.Publisher(center: NotificationCenter.default, name: UIResponder.keyboardWillHideNotification)
.compactMap { notification in
CGFloat.zero
}
.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight))
})
}
}
}
extension View {
func adaptsToKeyboard() -> some View {
return modifier(AdaptsToKeyboard())
}
}
Usage:
struct MyView: View {
var body: some View {
Form {...}
.adaptsToKeyboard()
}
}
Upvotes: 104
Reputation: 31
My View:
struct AddContactView: View {
@Environment(\.presentationMode) var presentationMode : Binding<PresentationMode>
@ObservedObject var addContactVM = AddContactVM()
@State private var offsetValue: CGFloat = 0.0
@State var firstName : String
@State var lastName : String
@State var sipAddress : String
@State var phoneNumber : String
@State var emailID : String
var body: some View {
VStack{
Header(title: StringConstants.ADD_CONTACT) {
self.presentationMode.wrappedValue.dismiss()
}
ScrollView(Axis.Set.vertical, showsIndicators: false){
Image("contactAvatar")
.padding(.top, 80)
.padding(.bottom, 100)
//.padding(.vertical, 100)
//.frame(width: 60,height : 60).aspectRatio(1, contentMode: .fit)
VStack(alignment: .center, spacing: 0) {
TextFieldBorder(placeHolder: StringConstants.FIRST_NAME, currentText: firstName, imageName: nil)
TextFieldBorder(placeHolder: StringConstants.LAST_NAME, currentText: lastName, imageName: nil)
TextFieldBorder(placeHolder: StringConstants.SIP_ADDRESS, currentText: sipAddress, imageName: "sipPhone")
TextFieldBorder(placeHolder: StringConstants.PHONE_NUMBER, currentText: phoneNumber, imageName: "phoneIcon")
TextFieldBorder(placeHolder: StringConstants.EMAILID, currentText: emailID, imageName: "email")
}
Spacer()
}
.padding(.horizontal, 20)
}
.padding(.bottom, self.addContactVM.bottomPadding)
.onAppear {
NotificationCenter.default.addObserver(self.addContactVM, selector: #selector(self.addContactVM.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self.addContactVM, selector: #selector(self.addContactVM.keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
}
}
My VM:
class AddContactVM : ObservableObject{
@Published var contact : Contact = Contact(id: "", firstName: "", lastName: "", phoneNumbers: [], isAvatarAvailable: false, avatar: nil, emailID: "")
@Published var bottomPadding : CGFloat = 0.0
@objc func keyboardWillShow(_ notification : Notification){
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
let keyboardRectangle = keyboardFrame.cgRectValue
let keyboardHeight = keyboardRectangle.height
self.bottomPadding = keyboardHeight
}
}
@objc func keyboardWillHide(_ notification : Notification){
self.bottomPadding = 0.0
}
}
Basically, Managing bottom padding based on keyboard height.
Upvotes: 0
Reputation: 2431
As Mark Krenek and Heiko have pointed out, Apple seemed to be addressing this issue at long last in Xcode 12 beta 4. Things are moving quickly. According to the release notes for Xcode 12 beta 5 published August 18, 2020 "Form, List, and TextEditor no longer hide content behind the keyboard. (66172025)". I just download it and gave it a quick test in the beta 5 simulator (iPhone SE2) with a Form container in an app I started a a few days ago.
It now "just works" for a TextField. SwiftUI will automatically provide the appropriate bottom padding to the encapsulating Form to make room for the keyboard. And it will automatically scroll the Form up to display the TextField just above the keyboard. The ScrollView container now behaves nicely when the keyboard comes up as well.
However, as Андрей Первушин pointed out in a comment, there is a problem with TextEditor. Beta 5 & 6 will automatically provide the appropriate bottom padding to the encapsulating Form to make room for the keyboard. But it will NOT automatically scroll the Form up. The keyboard will cover the TextEditor. So unlike TextField, the user has to scroll the Form to make the TextEditor visible. I will file a bug report. Perhaps Beta 7 will fix it. So close …
https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-14-beta-release-notes/
Upvotes: 3
Reputation: 355
Answer copied from here: TextField always on keyboard top with SwiftUI
I've tried different approaches, and none of them worked for me. This one below is the only one that worked for different devices.
Add this extension in a file:
import SwiftUI
import Combine
extension View {
func keyboardSensible(_ offsetValue: Binding<CGFloat>) -> some View {
return self
.padding(.bottom, offsetValue.wrappedValue)
.animation(.spring())
.onAppear {
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { notification in
let keyWindow = UIApplication.shared.connectedScenes
.filter({$0.activationState == .foregroundActive})
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?.windows
.filter({$0.isKeyWindow}).first
let bottom = keyWindow?.safeAreaInsets.bottom ?? 0
let value = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
let height = value.height
offsetValue.wrappedValue = height - bottom
}
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in
offsetValue.wrappedValue = 0
}
}
}
}
In your view, you need a variable to bind offsetValue:
struct IncomeView: View {
@State private var offsetValue: CGFloat = 0.0
var body: some View {
VStack {
//...
}
.keyboardSensible($offsetValue)
}
}
Upvotes: 2
Reputation: 4947
Xcode 12 beta 4 adds a new view modifier ignoresSafeArea
that you can now use to avoid the keyboard.
.ignoresSafeArea([], edges: [])
This avoids the keyboard and all safe area edges. You can set the first parameter to .keyboard
if you don’t want it avoided. There are some quirks to it, at least in my view hierarchy setup, but it does seem that this is the way Apple wants us to avoid the keyboard.
Upvotes: 3
Reputation: 1436
As for iOS 14 (beta 4) it works quite simple:
var body: some View {
VStack {
TextField(...)
}
.padding(.bottom, 0)
}
And the size of the view adjusts to the top of the keyboard. There are certainly more refinements possible with frame(.maxHeight: ...) etc. You will figure it out.
Unfortunately the floating keyboard on iPad still causes problems when moved. But the above mentioned solutions would too, and it's still beta, I hope they will figure it out.
Thx Apple, finally!
Upvotes: 2
Reputation: 2974
A lot of these answer's just seem really bloated to be honest. If you are using SwiftUI then you may as well make use of Combine as well.
Create a KeyboardResponder
as shown below, then you can use as previously demonstrated.
Updated for iOS 14.
import Combine
import UIKit
final class KeyboardResponder: ObservableObject {
@Published var keyboardHeight: CGFloat = 0
init() {
NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
.compactMap { notification in
(notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.height
}
.receive(on: DispatchQueue.main)
.assign(to: \.keyboardHeight)
}
}
struct ExampleView: View {
@ObservedObject private var keyboardResponder = KeyboardResponder()
@State private var text: String = ""
var body: some View {
VStack {
Text(text)
Spacer()
TextField("Example", text: $text)
}
.padding(.bottom, keyboardResponder.keyboardHeight)
}
}
Upvotes: 2
Reputation: 4082
I reviewed and refactored the existing solutions into a handy SPM package that provides a .keyboardAware()
modifier:
Example:
struct KeyboardAwareView: View {
@State var text = "example"
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading) {
ForEach(0 ..< 20) { i in
Text("Text \(i):")
TextField("Text", text: self.$text)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.bottom, 10)
}
}
.padding()
}
.keyboardAware() // <--- the view modifier
.navigationBarTitle("Keyboard Example")
}
}
}
Source:
import UIKit
import SwiftUI
public class KeyboardInfo: ObservableObject {
public static var shared = KeyboardInfo()
@Published public var height: CGFloat = 0
private init() {
NotificationCenter.default.addObserver(self, selector: #selector(self.keyboardChanged), name: UIApplication.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(self.keyboardChanged), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(self.keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}
@objc func keyboardChanged(notification: Notification) {
if notification.name == UIApplication.keyboardWillHideNotification {
self.height = 0
} else {
self.height = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height ?? 0
}
}
}
struct KeyboardAware: ViewModifier {
@ObservedObject private var keyboard = KeyboardInfo.shared
func body(content: Content) -> some View {
content
.padding(.bottom, self.keyboard.height)
.edgesIgnoringSafeArea(self.keyboard.height > 0 ? .bottom : [])
.animation(.easeOut)
}
}
extension View {
public func keyboardAware() -> some View {
ModifiedContent(content: self, modifier: KeyboardAware())
}
}
Upvotes: 19
Reputation: 3488
This is the way I handle the keyboard in SwiftUI. The thing to remember is that it is making the calculations on the VStack to which it is attached.
You use it on a View as a Modifier. This way:
struct LogInView: View {
var body: some View {
VStack {
// Your View
}
.modifier(KeyboardModifier())
}
}
So to come to this modifier, first, create an extension of UIResponder to get the selected TextField position in the VStack:
import UIKit
// MARK: Retrieve TextField first responder for keyboard
extension UIResponder {
private static weak var currentResponder: UIResponder?
static var currentFirstResponder: UIResponder? {
currentResponder = nil
UIApplication.shared.sendAction(#selector(UIResponder.findFirstResponder),
to: nil, from: nil, for: nil)
return currentResponder
}
@objc private func findFirstResponder(_ sender: Any) {
UIResponder.currentResponder = self
}
// Frame of the superview
var globalFrame: CGRect? {
guard let view = self as? UIView else { return nil }
return view.superview?.convert(view.frame, to: nil)
}
}
You can now create the KeyboardModifier using Combine to avoid a keyboard hiding a TextField:
import SwiftUI
import Combine
// MARK: Keyboard show/hide VStack offset modifier
struct KeyboardModifier: ViewModifier {
@State var offset: CGFloat = .zero
@State var subscription = Set<AnyCancellable>()
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.padding(.bottom, self.offset)
.animation(.spring(response: 0.4, dampingFraction: 0.5, blendDuration: 1))
.onAppear {
NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)
.handleEvents(receiveOutput: { _ in self.offset = 0 })
.sink { _ in }
.store(in: &self.subscription)
NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
.map(\.userInfo)
.compactMap { ($0?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.size.height }
.sink(receiveValue: { keyboardHeight in
let keyboardTop = geometry.frame(in: .global).height - keyboardHeight
let textFieldBottom = UIResponder.currentFirstResponder?.globalFrame?.maxY ?? 0
self.offset = max(0, textFieldBottom - keyboardTop * 2 - geometry.safeAreaInsets.bottom) })
.store(in: &self.subscription) }
.onDisappear {
// Dismiss keyboard
UIApplication.shared.windows
.first { $0.isKeyWindow }?
.endEditing(true)
self.subscription.removeAll() }
}
}
}
Upvotes: 1
Reputation: 3429
A few of the solutions above had some issues and weren't necessarily the "cleanest" approach. Because of this, I've modified a few things for the implementation below.
extension View {
func onKeyboard(_ keyboardYOffset: Binding<CGFloat>) -> some View {
return ModifiedContent(content: self, modifier: KeyboardModifier(keyboardYOffset))
}
}
struct KeyboardModifier: ViewModifier {
@Binding var keyboardYOffset: CGFloat
let keyboardWillAppearPublisher = NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
let keyboardWillHidePublisher = NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)
init(_ offset: Binding<CGFloat>) {
_keyboardYOffset = offset
}
func body(content: Content) -> some View {
return content.offset(x: 0, y: -$keyboardYOffset.wrappedValue)
.animation(.easeInOut(duration: 0.33))
.onReceive(keyboardWillAppearPublisher) { notification in
let keyWindow = UIApplication.shared.connectedScenes
.filter { $0.activationState == .foregroundActive }
.map { $0 as? UIWindowScene }
.compactMap { $0 }
.first?.windows
.filter { $0.isKeyWindow }
.first
let yOffset = keyWindow?.safeAreaInsets.bottom ?? 0
let keyboardFrame = (notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue ?? .zero
self.$keyboardYOffset.wrappedValue = keyboardFrame.height - yOffset
}.onReceive(keyboardWillHidePublisher) { _ in
self.$keyboardYOffset.wrappedValue = 0
}
}
}
struct RegisterView: View {
@State var name = ""
@State var keyboardYOffset: CGFloat = 0
var body: some View {
VStack {
WelcomeMessageView()
TextField("Type your name...", text: $name).bordered()
}.onKeyboard($keyboardYOffset)
.background(WelcomeBackgroundImage())
.padding()
}
}
I would have liked a cleaner approach and to move responsibility to the constructed view (not the modifier) in how to offset the content, but it would seem I couldn't get the publishers to properly trigger when moving the offset code to the view....
Also note that Publishers had to be used in this instance as final class
currently causes unknown exception crashes (even though it meets interface requirements) and a ScrollView overall is the best approach when applying offset code.
Upvotes: 6
Reputation:
I took a totally different approach, by extending UIHostingController
and adjusting its additionalSafeAreaInsets
:
class MyHostingController<Content: View>: UIHostingController<Content> {
override init(rootView: Content) {
super.init(rootView: rootView)
}
@objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
NotificationCenter.default.addObserver(self,
selector: #selector(keyboardDidShow(_:)),
name: UIResponder.keyboardDidShowNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(keyboardWillHide),
name: UIResponder.keyboardWillHideNotification,
object: nil)
}
@objc func keyboardDidShow(_ notification: Notification) {
guard let info:[AnyHashable: Any] = notification.userInfo,
let frame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
return
}
// set the additionalSafeAreaInsets
let adjustHeight = frame.height - (self.view.safeAreaInsets.bottom - self.additionalSafeAreaInsets.bottom)
self.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: adjustHeight, right: 0)
// now try to find a UIResponder inside a ScrollView, and scroll
// the firstResponder into view
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
if let firstResponder = UIResponder.findFirstResponder() as? UIView,
let scrollView = firstResponder.parentScrollView() {
// translate the firstResponder's frame into the scrollView's coordinate system,
// with a little vertical padding
let rect = firstResponder.convert(firstResponder.frame, to: scrollView)
.insetBy(dx: 0, dy: -15)
scrollView.scrollRectToVisible(rect, animated: true)
}
}
}
@objc func keyboardWillHide() {
self.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
}
}
/// IUResponder extension for finding the current first responder
extension UIResponder {
private struct StaticFirstResponder {
static weak var firstResponder: UIResponder?
}
/// find the current first responder, or nil
static func findFirstResponder() -> UIResponder? {
StaticFirstResponder.firstResponder = nil
UIApplication.shared.sendAction(
#selector(UIResponder.trap),
to: nil, from: nil, for: nil)
return StaticFirstResponder.firstResponder
}
@objc private func trap() {
StaticFirstResponder.firstResponder = self
}
}
/// UIView extension for finding the receiver's parent UIScrollView
extension UIView {
func parentScrollView() -> UIScrollView? {
if let scrollView = self.superview as? UIScrollView {
return scrollView
}
return superview?.parentScrollView()
}
}
Then change SceneDelegate
to use MyHostingController
instead of UIHostingController
.
When that's done, I don't need to worry about the keyboard inside my SwiftUI code.
(Note: I haven't used this enough, yet, to fully understand any implications of doing this!)
Upvotes: 1
Reputation: 1223
TabView
'sI like Benjamin Kindle's answer but it doesn't support TabViews. Here is my adjustment to his code for handling TabViews:
UITabView
to store the size of the tabView when it's frame is set.
We can store this in a static variable because there is usually only one tabView in a project (if yours has more than one, then you'll need to adjust).extension UITabBar {
static var size: CGSize = .zero
open override var frame: CGRect {
get {
super.frame
} set {
UITabBar.size = newValue.size
super.frame = newValue
}
}
}
onReceive
at the bottom of the KeyboardHost
view to account for the Tab Bar's height:.onReceive(showPublisher.merge(with: hidePublisher)) { (height) in
self.keyboardHeight = max(height - UITabBar.size.height, 0)
}
Upvotes: 1
Reputation: 455
I have created a really simple to use view modifier.
Add a Swift file with the code below and simply add this modifier to your views:
.keyboardResponsive()
import SwiftUI
struct KeyboardResponsiveModifier: ViewModifier {
@State private var offset: CGFloat = 0
func body(content: Content) -> some View {
content
.padding(.bottom, offset)
.onAppear {
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { notif in
let value = notif.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
let height = value.height
let bottomInset = UIApplication.shared.windows.first?.safeAreaInsets.bottom
self.offset = height - (bottomInset ?? 0)
}
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { notif in
self.offset = 0
}
}
}
}
extension View {
func keyboardResponsive() -> ModifiedContent<Self, KeyboardResponsiveModifier> {
return modifier(KeyboardResponsiveModifier())
}
}
Upvotes: 31
Reputation: 393
I'm not sure if the transition / animation API for SwiftUI is complete, but you could use CGAffineTransform
with .transformEffect
Create an observable keyboard object with a published property like this:
final class KeyboardResponder: ObservableObject {
private var notificationCenter: NotificationCenter
@Published var readyToAppear = false
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) {
readyToAppear = true
}
@objc func keyBoardWillHide(notification: Notification) {
readyToAppear = false
}
}
then you could use that property to rearrange your view like this:
struct ContentView : View {
@State var textfieldText: String = ""
@ObservedObject private var keyboard = KeyboardResponder()
var body: some View {
return self.buildContent()
}
func buildContent() -> some View {
let mainStack = VStack {
TextField("TextField1", text: self.$textfieldText)
TextField("TextField2", text: self.$textfieldText)
TextField("TextField3", text: self.$textfieldText)
TextField("TextField4", text: self.$textfieldText)
TextField("TextField5", text: self.$textfieldText)
TextField("TextField6", text: self.$textfieldText)
TextField("TextField7", text: self.$textfieldText)
}
return Group{
if self.keyboard.readyToAppear {
mainStack.transformEffect(CGAffineTransform(translationX: 0, y: -200))
.animation(.spring())
} else {
mainStack
}
}
}
}
or simpler
VStack {
TextField("TextField1", text: self.$textfieldText)
TextField("TextField2", text: self.$textfieldText)
TextField("TextField3", text: self.$textfieldText)
TextField("TextField4", text: self.$textfieldText)
TextField("TextField5", text: self.$textfieldText)
TextField("TextField6", text: self.$textfieldText)
TextField("TextField7", text: self.$textfieldText)
}.transformEffect(keyboard.readyToAppear ? CGAffineTransform(translationX: 0, y: -50) : .identity)
.animation(.spring())
Upvotes: 3
Reputation: 11057
You need to add a ScrollView
and set a bottom padding of the size of the keyboard so the content will be able to scroll when the keyboard appears.
To get the keyboard size, you will need to use the NotificationCenter
to register for keyboards event. You can use a custom class to do so:
import SwiftUI
import Combine
final class KeyboardResponder: BindableObject {
let didChange = PassthroughSubject<CGFloat, Never>()
private var _center: NotificationCenter
private(set) var currentHeight: CGFloat = 0 {
didSet {
didChange.send(currentHeight)
}
}
init(center: NotificationCenter = .default) {
_center = center
_center.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
_center.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
deinit {
_center.removeObserver(self)
}
@objc func keyBoardWillShow(notification: Notification) {
print("keyboard will show")
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
currentHeight = keyboardSize.height
}
}
@objc func keyBoardWillHide(notification: Notification) {
print("keyboard will hide")
currentHeight = 0
}
}
The BindableObject
conformance will allow you to use this class as a State
and trigger the view update. If needed look at the tutorial for BindableObject
: SwiftUI tutorial
When you get that, you need to configure a ScrollView
to reduce its size when the keyboard appear. For convenience I wrapped this ScrollView
into some kind of component:
struct KeyboardScrollView<Content: View>: View {
@State var keyboard = KeyboardResponder()
private var content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ScrollView {
VStack {
content
}
}
.padding(.bottom, keyboard.currentHeight)
}
}
All you have to do now is to embed your content inside the custom ScrollView
.
struct ContentView : View {
@State var textfieldText: String = ""
var body: some View {
KeyboardScrollView {
ForEach(0...10) { index in
TextField(self.$textfieldText, placeholder: Text("TextField\(index)")) {
// Hide keyboard when uses tap return button on keyboard.
self.endEditing(true)
}
}
}
}
private func endEditing(_ force: Bool) {
UIApplication.shared.keyWindow?.endEditing(true)
}
}
Edit:
The scroll behaviour is really weird when the keyboard is hiding. Maybe using an animation to update the padding would fix this, or you should consider using something else than the padding
to adjust the scroll view size.
Upvotes: 15
Reputation: 984
To build off of @rraphael 's solution, I converted it to be usable by today's xcode11 swiftUI support.
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
}
}
Usage:
struct ContentView: View {
@ObservedObject private var keyboard = KeyboardResponder()
@State private var textFieldInput: String = ""
var body: some View {
VStack {
HStack {
TextField("uMessage", text: $textFieldInput)
}
}.padding()
.padding(.bottom, keyboard.currentHeight)
.edgesIgnoringSafeArea(.bottom)
.animation(.easeOut(duration: 0.16))
}
}
The published currentHeight
will trigger a UI re-render and move your TextField up when the keyboard shows, and back down when dismissed. However I didn't use a ScrollView.
Upvotes: 76
Reputation: 40489
Code updated for the Xcode, beta 7.
You do not need padding, ScrollViews or Lists to achieve this. Although this solution will play nice with them too. I am including two examples here.
The first one moves all textField up, if the keyboard appears for any of them. But only if needed. If the keyboard doesn't hide the textfields, they will not move.
In the second example, the view only moves enough just to avoid hiding the active textfield.
Both examples use the same common code found at the end: GeometryGetter and KeyboardGuardian
struct ContentView: View {
@ObservedObject private var kGuardian = KeyboardGuardian(textFieldCount: 1)
@State private var name = Array<String>.init(repeating: "", count: 3)
var body: some View {
VStack {
Group {
Text("Some filler text").font(.largeTitle)
Text("Some filler text").font(.largeTitle)
}
TextField("enter text #1", text: $name[0])
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("enter text #2", text: $name[1])
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("enter text #3", text: $name[2])
.textFieldStyle(RoundedBorderTextFieldStyle())
.background(GeometryGetter(rect: $kGuardian.rects[0]))
}.offset(y: kGuardian.slide).animation(.easeInOut(duration: 1.0))
}
}
struct ContentView: View {
@ObservedObject private var kGuardian = KeyboardGuardian(textFieldCount: 3)
@State private var name = Array<String>.init(repeating: "", count: 3)
var body: some View {
VStack {
Group {
Text("Some filler text").font(.largeTitle)
Text("Some filler text").font(.largeTitle)
}
TextField("text #1", text: $name[0], onEditingChanged: { if $0 { self.kGuardian.showField = 0 } })
.textFieldStyle(RoundedBorderTextFieldStyle())
.background(GeometryGetter(rect: $kGuardian.rects[0]))
TextField("text #2", text: $name[1], onEditingChanged: { if $0 { self.kGuardian.showField = 1 } })
.textFieldStyle(RoundedBorderTextFieldStyle())
.background(GeometryGetter(rect: $kGuardian.rects[1]))
TextField("text #3", text: $name[2], onEditingChanged: { if $0 { self.kGuardian.showField = 2 } })
.textFieldStyle(RoundedBorderTextFieldStyle())
.background(GeometryGetter(rect: $kGuardian.rects[2]))
}.offset(y: kGuardian.slide).animation(.easeInOut(duration: 1.0))
}.onAppear { self.kGuardian.addObserver() }
.onDisappear { self.kGuardian.removeObserver() }
}
This is a view that absorbs the size and position of its parent view. In order to achieve that, it is called inside the .background modifier. This is a very powerful modifier, not just a way to decorate the background of a view. When passing a view to .background(MyView()), MyView is getting the modified view as the parent. Using GeometryReader is what makes it possible for the view to know the geometry of the parent.
For example: Text("hello").background(GeometryGetter(rect: $bounds))
will fill variable bounds, with the size and position of the Text view, and using the global coordinate space.
struct GeometryGetter: View {
@Binding var rect: CGRect
var body: some View {
GeometryReader { geometry in
Group { () -> AnyView in
DispatchQueue.main.async {
self.rect = geometry.frame(in: .global)
}
return AnyView(Color.clear)
}
}
}
}
Update I added the DispatchQueue.main.async, to avoid the possibility of modifying the state of the view while it is being rendered.***
The purpose of KeyboardGuardian, is to keep track of keyboard show/hide events and calculate how much space the view needs to be shifted.
Update: I modified KeyboardGuardian to refresh the slide, when the user tabs from one field to another
import SwiftUI
import Combine
final class KeyboardGuardian: ObservableObject {
public var rects: Array<CGRect>
public var keyboardRect: CGRect = CGRect()
// keyboardWillShow notification may be posted repeatedly,
// this flag makes sure we only act once per keyboard appearance
public var keyboardIsHidden = true
@Published var slide: CGFloat = 0
var showField: Int = 0 {
didSet {
updateSlide()
}
}
init(textFieldCount: Int) {
self.rects = Array<CGRect>(repeating: CGRect(), count: textFieldCount)
}
func addObserver() {
NotificationCenter.default.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyBoardDidHide(notification:)), name: UIResponder.keyboardDidHideNotification, object: nil)
}
func removeObserver() {
NotificationCenter.default.removeObserver(self)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc func keyBoardWillShow(notification: Notification) {
if keyboardIsHidden {
keyboardIsHidden = false
if let rect = notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect {
keyboardRect = rect
updateSlide()
}
}
}
@objc func keyBoardDidHide(notification: Notification) {
keyboardIsHidden = true
updateSlide()
}
func updateSlide() {
if keyboardIsHidden {
slide = 0
} else {
let tfRect = self.rects[self.showField]
let diff = keyboardRect.minY - tfRect.maxY
if diff > 0 {
slide += diff
} else {
slide += min(diff, 0)
}
}
}
}
Upvotes: 85
Reputation: 316
I used Benjamin Kindle's answer as as starting point, but I had a few issues I wanted to address.
keyboardWillChangeFrameNotification
to the list of notifications processed addresses this.init
function that accepts a @ViewBuilder
so that you can use the KeyboardHost
view like any other View and simply pass your content in a trailing closure, as opposed to passing the content view as a parameter to init
.Rectangle
for adjusting the bottom padding.UIWindow
as UIWindow.keyboardFrameEndUserInfoKey
.Pulling that all together I have:
struct KeyboardHost<Content>: View where Content: View {
var content: Content
/// The current height of the keyboard rect.
@State private var keyboardHeight = CGFloat(0)
/// A publisher that combines all of the relevant keyboard changing notifications and maps them into a `CGFloat` representing the new height of the
/// keyboard rect.
private let keyboardChangePublisher = NotificationCenter.Publisher(center: .default,
name: UIResponder.keyboardWillShowNotification)
.merge(with: NotificationCenter.Publisher(center: .default,
name: UIResponder.keyboardWillChangeFrameNotification))
.merge(with: NotificationCenter.Publisher(center: .default,
name: UIResponder.keyboardWillHideNotification)
// But we don't want to pass the keyboard rect from keyboardWillHide, so strip the userInfo out before
// passing the notification on.
.map { Notification(name: $0.name, object: $0.object, userInfo: nil) })
// Now map the merged notification stream into a height value.
.map { ($0.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect ?? .zero).size.height }
// If you want to debug the notifications, swap this in for the final map call above.
// .map { (note) -> CGFloat in
// let height = (note.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect ?? .zero).size.height
//
// print("Received \(note.name.rawValue) with height \(height)")
// return height
// }
var body: some View {
content
.onReceive(keyboardChangePublisher) { self.keyboardHeight = $0 }
.padding(.bottom, keyboardHeight)
.animation(.default)
}
init(@ViewBuilder _ content: @escaping () -> Content) {
self.content = content()
}
}
struct KeyboardHost_Previews: PreviewProvider {
static var previews: some View {
KeyboardHost {
TextField("TextField", text: .constant("Preview text field"))
}
}
}
Upvotes: 4
Reputation: 1169
This is adapted from what @kontiki built. I have it running in an app under beta 8 / GM seed, where the field needing scrolled is part of a form inside a NavigationView. Here's KeyboardGuardian:
//
// KeyboardGuardian.swift
//
// https://stackoverflow.com/questions/56491881/move-textfield-up-when-thekeyboard-has-appeared-by-using-swiftui-ios
//
import SwiftUI
import Combine
/// The purpose of KeyboardGuardian, is to keep track of keyboard show/hide events and
/// calculate how much space the view needs to be shifted.
final class KeyboardGuardian: ObservableObject {
let objectWillChange = ObservableObjectPublisher() // PassthroughSubject<Void, Never>()
public var rects: Array<CGRect>
public var keyboardRect: CGRect = CGRect()
// keyboardWillShow notification may be posted repeatedly,
// this flag makes sure we only act once per keyboard appearance
private var keyboardIsHidden = true
var slide: CGFloat = 0 {
didSet {
objectWillChange.send()
}
}
public var showField: Int = 0 {
didSet {
updateSlide()
}
}
init(textFieldCount: Int) {
self.rects = Array<CGRect>(repeating: CGRect(), count: textFieldCount)
NotificationCenter.default.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyBoardDidHide(notification:)), name: UIResponder.keyboardDidHideNotification, object: nil)
}
@objc func keyBoardWillShow(notification: Notification) {
if keyboardIsHidden {
keyboardIsHidden = false
if let rect = notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect {
keyboardRect = rect
updateSlide()
}
}
}
@objc func keyBoardDidHide(notification: Notification) {
keyboardIsHidden = true
updateSlide()
}
func updateSlide() {
if keyboardIsHidden {
slide = 0
} else {
slide = -keyboardRect.size.height
}
}
}
Then, I used an enum to track the slots in the rects array and the total number:
enum KeyboardSlots: Int {
case kLogPath
case kLogThreshold
case kDisplayClip
case kPingInterval
case count
}
KeyboardSlots.count.rawValue
is the necessary array capacity; the others as rawValue give the appropriate index you'll use for .background(GeometryGetter) calls.
With that set up, views get at the KeyboardGuardian with this:
@ObservedObject private var kGuardian = KeyboardGuardian(textFieldCount: SettingsFormBody.KeyboardSlots.count.rawValue)
The actual movement is like this:
.offset(y: kGuardian.slide).animation(.easeInOut(duration: 1))
attached to the view. In my case, it's attached to the entire NavigationView, so the complete assembly slides up as the keyboard appears.
I haven't solved the problem of getting a Done toolbar or a return key on a decimal keyboard with SwiftUI, so instead I'm using this to hide it on a tap elsewhere:
struct DismissingKeyboard: ViewModifier {
func body(content: Content) -> some View {
content
.onTapGesture {
let keyWindow = UIApplication.shared.connectedScenes
.filter({$0.activationState == .foregroundActive})
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?.windows
.filter({$0.isKeyWindow}).first
keyWindow?.endEditing(true)
}
}
}
You attach it to a view as
.modifier(DismissingKeyboard())
Some views (e.g., pickers) don't like having that attached, so you may need to be somewhat granular in how you attach the modifier rather than just slapping it on the outermost view.
Many thanks to @kontiki for the hard work. You'll still need his GeometryGetter above (nope, I didn't do the work to convert it to use preferences either) as he illustrates in his examples.
Upvotes: 4