Hitesh Surani
Hitesh Surani

Reputation: 13537

Move TextField up when the keyboard has appeared in SwiftUI

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:

Output

Upvotes: 166

Views: 149964

Answers (30)

mike.bulgakov
mike.bulgakov

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.

Screen record

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

Mojtaba Hosseini
Mojtaba Hosseini

Reputation: 119118

From iOS 14.2, TextFields 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)

Demo

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

asevko
asevko

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

Coder
Coder

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

  1. doesn't provide a direct way to simply .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)
  2. currently has (imho) a convoluted way to get the keyboard height

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:

  1. Detects when the TextField gains input
  2. Checks if the view is partially or completely hidden by the keyboard
  3. Scrolls to the view so that its bottom is just above the keyboard (+ some padding)

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

Nicolai Harbo
Nicolai Harbo

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

Ajay Sharma
Ajay Sharma

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

Wael
Wael

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

Shrikant Phadke
Shrikant Phadke

Reputation: 418

enter image description here

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.

enter image description here

  .ignoresSafeArea(.keyboard, edges: .bottom)

add this line after parent Vstack.

Upvotes: 2

Abhishek
Abhishek

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

8suhas
8suhas

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

Amit Samant
Amit Samant

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

Predrag Samardzic
Predrag Samardzic

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

Tushar Sharma
Tushar Sharma

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

Positron
Positron

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

VSMelo
VSMelo

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

Mark Krenek
Mark Krenek

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

heiko
heiko

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

Edward
Edward

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

Ralf Ebert
Ralf Ebert

Reputation: 4082

I reviewed and refactored the existing solutions into a handy SPM package that provides a .keyboardAware() modifier:

KeyboardAwareSwiftUI

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

Roland Lariotte
Roland Lariotte

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

TheCodingArt
TheCodingArt

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

anon
anon

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

Ben Patch
Ben Patch

Reputation: 1223

Handling TabView's

I like Benjamin Kindle's answer but it doesn't support TabViews. Here is my adjustment to his code for handling TabViews:

  1. Add an extension to 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
        }
    }
}
  1. You'll need to change his 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)
        }
  1. And that's it! Super simple 🎉.

Upvotes: 1

jberlana
jberlana

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

blacktiago
blacktiago

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

rraphael
rraphael

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

Michael Neas
Michael Neas

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

kontiki
kontiki

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

First Example (show all textfields)

When the keyboard is opened, the 3 textfields are moved up enough to keep then all visible

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))
    }

}

Second Example (show only the active field)

When each text field is clicked, the view is only moved up enough to make the clicked text field visible.

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() }

}

GeometryGetter

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.***

KeyboardGuardian

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

Timothy Sanders
Timothy Sanders

Reputation: 316

I used Benjamin Kindle's answer as as starting point, but I had a few issues I wanted to address.

  1. Most of the answers here do not deal with the keyboard changing its frame, so they break if the user rotates the device with the keyboard onscreen. Adding keyboardWillChangeFrameNotification to the list of notifications processed addresses this.
  2. I didn't want multiple publishers with similar-but-different map closures, so I chained all three keyboard notifications into a single publisher. It's admittedly a long chain but each step is pretty straightforward.
  3. I provided the 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.
  4. As Tae and fdelafuente suggested in comments I swapped out the Rectangle for adjusting the bottom padding.
  5. Instead of using the hard-coded "UIKeyboardFrameEndUserInfoKey" string I wanted to use the strings provided in 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

Feldur
Feldur

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

Related Questions