20

How is it possible to show the complete List when the keyboard is showing up? The keyboard is hiding the lower part of the list.

I have a textField in my list row. When the keyboard shows up it is not possible to scroll down to see the complete list. The keyboard is in front of the list and not "under" the list. This is my coding:

struct ContentView: View {

    @State private var name = ""

    var body: some View {
        List {
            VStack {
                Text("Begin")
                    .frame(width: UIScreen.main.bounds.width)
                    .padding(.bottom, 400)
                    .background(Color.red)

                TextField($name, placeholder: Text("enter text"), onEditingChanged: { _ in
                    //
                }) {
                    //
                }

                Text("End")
                    .frame(width: UIScreen.main.bounds.width)
                    .padding(.top, 400)
                    .background(Color.green)
            }
            .listRowInsets(EdgeInsets())
        }
    }
}

Can anybody help me how I can do this?

Thank you very much.

Felipe Augusto
  • 7,733
  • 10
  • 39
  • 73
stefOCDP
  • 803
  • 2
  • 12
  • 20
  • try to subscribe to keyboard appear/disappear events and apply bottom margin to `VStack` – imike Jun 22 '19 at 14:39
  • I am currently working on something very similar (using ScrollView instead of List). I think subscribing to keyboard show/hide events is the right approach. But that's not the hard part. The challenge is to figure out where is the active textField to determine if an offset is required, and if so, how much. Your specific example would be easy, because you have a fixed 400 pixels... however, I am assuming that it is just an example. The goal is to being able to determine the textfield relative position to its parent and how much has it scrolled, then we know how much we need to move everything. – kontiki Jun 22 '19 at 15:59

7 Answers7

17

An alternative implementation of the KeyboardResponder object using Compose, as seen here.

final class KeyboardResponder: ObservableObject {

    let willChange = PassthroughSubject<CGFloat, Never>()

    private(set) var currentHeight: Length = 0 {
        willSet {
            willChange.send(currentHeight)
        }
    }

    let keyboardWillOpen = NotificationCenter.default
        .publisher(for: UIResponder.keyboardWillShowNotification)
        .first() // keyboardWillShow notification may be posted repeatedly
        .map { $0.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect }
        .map { $0.height }

    let keyboardWillHide =  NotificationCenter.default
        .publisher(for: UIResponder.keyboardWillHideNotification)
        .map { _ in CGFloat(0) }

    func listen() {
        _ = Publishers.Merge(keyboardWillOpen, keyboardWillHide)
            .subscribe(on: RunLoop.main)
            .assign(to: \.currentHeight, on: self)
    }

    init() {
        listen()
    }
}

An even nicer method is to pack the above as a ViewModifier (loosely adapted from here):

struct AdaptsToSoftwareKeyboard: ViewModifier {

    @State var currentHeight: Length = 0

    func body(content: Content) -> some View {
        content
            .padding(.bottom, currentHeight)
            .edgesIgnoringSafeArea(currentHeight == 0 ? Edge.Set() : .bottom)
            .onAppear(perform: subscribeToKeyboardEvents)
    }

    private let keyboardWillOpen = NotificationCenter.default
        .publisher(for: UIResponder.keyboardWillShowNotification)
        .map { $0.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect }
        .map { $0.height }

    private let keyboardWillHide =  NotificationCenter.default
        .publisher(for: UIResponder.keyboardWillHideNotification)
        .map { _ in Length.zero }

    private func subscribeToKeyboardEvents() {
        _ = Publishers.Merge(keyboardWillOpen, keyboardWillHide)
            .subscribe(on: RunLoop.main)
            .assign(to: \.currentHeight, on: self)
    }
}

And then it could be used like this:

Group {

   ........

}.modifier(AdaptsToSoftwareKeyboard())
Bogdan Farca
  • 3,856
  • 26
  • 38
  • 2
    I like the ViewModifier approach, however rather than apply it to the whole group, it would be nice to be able to apply this to any arbitrary view in the hierarchy to say "This view must not be occluded by the keyboard" In a form, it would always be the text field that is active, but for a login screen perhaps it would be the username, password AND login button. – jjatie Jul 30 '19 at 12:42
  • 3
    The modifier is a an elegant solution, plus a nice example of how to use the new **Combine** framework. On Xcode Version 11.0 (11A420a) I had to change the references to Length to CGFloat. Well done and thanks! – Positron Sep 29 '19 at 16:10
  • @jjatie This would be great. Most of the times we don't actually want the whole form to be moved up. If I have a form with several fields and I click on the first one on top I really don't want to move it up because it would be moved outside the screen. – superpuccio Dec 16 '19 at 10:30
  • In the final example, what will cause the subscription to be removed? It's not clear to me what's retaining it. It retains `self`, but shouldn't the subscription evaporate since it's not assigned to anything? (I know it doesn't; I just don't see why.) – Rob Napier May 22 '20 at 15:40
  • I have the same question as @RobNapier - does anyone know how the subscription is retained here? – jeh Jul 07 '20 at 17:08
16

Updating the excellent Combine approach by Bogdan Farca to XCode 11.2:

import Combine
import SwiftUI

struct AdaptsToSoftwareKeyboard: ViewModifier {

    @State var currentHeight: CGFloat = 0

    func body(content: Content) -> some View {
        content
            .padding(.bottom, self.currentHeight)
            .edgesIgnoringSafeArea(self.currentHeight == 0 ? Edge.Set() : .bottom)
            .onAppear(perform: subscribeToKeyboardEvents)
    }

    private let keyboardWillOpen = NotificationCenter.default
        .publisher(for: UIResponder.keyboardWillShowNotification)
        .map { $0.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect }
        .map { $0.height }

    private let keyboardWillHide =  NotificationCenter.default
        .publisher(for: UIResponder.keyboardWillHideNotification)
        .map { _ in CGFloat.zero }

    private func subscribeToKeyboardEvents() {
        _ = Publishers.Merge(keyboardWillOpen, keyboardWillHide)
            .subscribe(on: RunLoop.main)
            .assign(to: \.self.currentHeight, on: self)
    }
}
Gene Z. Ragan
  • 2,643
  • 2
  • 31
  • 41
  • Thank you. This in combination with the original approach, seems to work perfectly. – Viper827 Oct 18 '19 at 17:16
  • 2
    Does not work on iPad correctly. Adds an extra (do not know but apx 20-40px) padding, which shown as white space between the content and the keyboard – Arda Oğul Üçpınar Nov 07 '19 at 20:03
  • 1
    Also on iPhone, its padding is somehow 10px-20px lower than expected when we use the field inside a form – Arda Oğul Üçpınar Nov 07 '19 at 20:14
  • I'll try the code out on an iPad and see if I can figure out the cause of the white space. – Gene Z. Ragan Nov 07 '19 at 22:21
  • The iPhone 11 Pro Max has the same issue as the iPad. I *think* the safeAreaInsets.bottom needs to be subtracted when the keyboard opens, but I don't know how to get this value yet. – P. Ent Nov 12 '19 at 18:20
  • To answer my own question: UIApplication.shared.windows.first?.safeAreaInsets.bottom – P. Ent Feb 14 '20 at 14:04
  • @GeneZ.Ragan hey i noticed u didnt add a ``` storein(:cancellables) ``` , wont this cause a memory leak? i tried adding one but couldnt because i was in a struct lmk – Di Nerd Apps Mar 19 '20 at 19:26
9

there is an answer here to handle keyboard actions, you can subscribe for keyboard events like this:

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) {
        if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
            currentHeight = keyboardSize.height
        }
    }

    @objc func keyBoardWillHide(notification: Notification) {
        currentHeight = 0
    }
}

and then just use it like this:

@State var keyboard = KeyboardResponder()
var body: some View {
        List {
            VStack {
             ...
             ...
             ...
            }.padding(.bottom, keyboard.currentHeight)
}
  • Have you tried your answer? Although keyboard.currentHeight changes, the view does not move. With ```.offset(y: -keyboard.currentHeight)``` it does. However, your approach completely ignores the actual position of the textField. For example, if the list is scrolled and the field is too high, when the keyboard appears, the textfield moves up and away off the screen. – kontiki Jun 22 '19 at 20:03
  • What I do like about your answer, is having the keyboard observer on a separate class, instead of on the view struct itself. I may change my posted answer later to follow on that approach. – kontiki Jun 22 '19 at 20:07
  • 1
    i've tried it with padding and it works(user has to scroll manually but the content will show completely) – Farshad jahanmanesh Jun 22 '19 at 20:41
  • Now that I am reading the OP original question again, I realise he wanted to show the whole thing, without caring where the textfield is. So you're answer should work fine. I would use offset instead of padding. To avoid the need for manual scroll. – kontiki Jun 22 '19 at 21:18
  • but changing offset will hide top textviews and user can not scroll to manually too. – Farshad jahanmanesh Jun 23 '19 at 05:40
  • If your app targets iOS 9.0 and later or macOS 10.11 and later, you don't need to unregister an observer in its dealloc method. – Casper Zandbergen Nov 08 '19 at 10:52
  • This works with a couple minor modification. The frame key should be UIResponder.keyboardFrameEndUserInfoKey instead of "Begin". And it probably should use didShow/didHide. – Chad Jun 20 '20 at 14:50
5

Here's an updated version of the BindableObject implementation (now named ObservableObject).

import SwiftUI
import Combine

class KeyboardObserver: ObservableObject {

  private var cancellable: AnyCancellable?

  @Published private(set) var keyboardHeight: CGFloat = 0

  let keyboardWillShow = NotificationCenter.default
    .publisher(for: UIResponder.keyboardWillShowNotification)
    .compactMap { ($0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height }

  let keyboardWillHide = NotificationCenter.default
    .publisher(for: UIResponder.keyboardWillHideNotification)
    .map { _ -> CGFloat in 0 }

  init() {
    cancellable = Publishers.Merge(keyboardWillShow, keyboardWillHide)
      .subscribe(on: RunLoop.main)
      .assign(to: \.keyboardHeight, on: self)
  }
}

Here's how to use it in your views:

@ObservedObject private var keyboardObserver = KeyboardObserver()

var body: some View {
  ...
  YourViewYouWantToRaise()
    .padding(.bottom, keyboardObserver.keyboardHeight)
    .animation(.easeInOut(duration: 0.3))
  ...
}
Marin Bencevic
  • 301
  • 3
  • 6
3

Have an observer set an EnvironmentValue. Then make that a variable in your View:

 @Environment(\.keyboardHeight) var keyboardHeight: CGFloat
import SwiftUI
import UIKit

extension EnvironmentValues {

  var keyboardHeight : CGFloat {
    get { EnvironmentObserver.shared.keyboardHeight }
  }

}

class EnvironmentObserver {

  static let shared = EnvironmentObserver()

  var keyboardHeight: CGFloat = 0 {
    didSet { print("Keyboard height \(keyboardHeight)") }
  }

  init() {

    // MARK: Keyboard Events

    NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidHideNotification, object: nil, queue: OperationQueue.main) { [weak self ] (notification) in
      self?.keyboardHeight = 0
    }

    let handler: (Notification) -> Void = { [weak self] notification in
        guard let userInfo = notification.userInfo else { return }
        guard let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }

        // From Apple docs:
        // The rectangle contained in the UIKeyboardFrameBeginUserInfoKey and UIKeyboardFrameEndUserInfoKey properties of the userInfo dictionary should be used only for the size information it contains. Do not use the origin of the rectangle (which is always {0.0, 0.0}) in rectangle-intersection operations. Because the keyboard is animated into position, the actual bounding rectangle of the keyboard changes over time.

        self?.keyboardHeight = frame.size.height
    }

    NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidShowNotification, object: nil, queue: OperationQueue.main, using: handler)

    NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidChangeFrameNotification, object: nil, queue: OperationQueue.main, using: handler)

  }
Cenk Bilgen
  • 1,330
  • 9
  • 8
1

One Line Solution with SwiftUIX

If you install SwiftUIX, all you need to do is called, .padding(.keyboard) on the View that contains the list. This is by far the best and simplest solution I have seen!

import SwiftUIX

struct ExampleView: View {
    var body: some View {
        VStack {
           List {
            ForEach(contacts, id: \.self) { contact in
                cellWithContact(contact)
            }
           }
        }.padding(.keyboard) // This is all that's needed, super cool!
    }
}
Zorayr
  • 23,770
  • 8
  • 136
  • 129
0

These examples are a little old, I revamped some code to use the new features recently added to SwiftUI, detailed explanation of the code used in this sample can be found in this article: Article Describing ObservableObject

Keyboard observer class:

import SwiftUI
import Combine

final class KeyboardResponder: ObservableObject {
    let objectWillChange = ObservableObjectPublisher()
    private var _center: NotificationCenter
    @Published var currentHeight: CGFloat = 0

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

    @objc func keyBoardWillShow(notification: Notification) {
        if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
            currentHeight = keyboardSize.height
        }
    }

    @objc func keyBoardWillHide(notification: Notification) {
        currentHeight = 0
    }
}

Usage:

@ObservedObject private var keyboard = KeyboardResponder()

VStack {
 //Views here
}
//Makes it go up, since negative offset
.offset(y: -self.keyboard.currentHeight)