Move TextField up when the keyboard has appeared in SwiftUI
Asked Answered
W

31

157

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

Warfield answered 7/6, 2019 at 9:43 Comment(9)
You may use ScrollView. developer.apple.com/documentation/swiftui/scrollviewKuhlmann
@PrashantTukadiya Thanks for the quick response. I have added TextField inside Scrollview but still facing the same issue.Warfield
@DimaPaliychuk This won't work. it is SwiftUIKuhlmann
@DimaPaliychuk. IQKeyboardManager is not worked with SwiftUI. It only works with UIKit based component. By the way thanks for replay :DWarfield
No padding, ScrollView or List views are actually necessary. I posted my answer with two full examples that not only moves the view, but it also checks where the textfields are to determine if the move is actually needed. and it only moves it enough to make the textfield unhidden and not a pixel more.Pisarik
The showing of the keyboard and it obscuring content on the screen has been around since what, the first Objective C iPhone app? This is problem that is constantly being solved. I for one am disappointed that Apple has not addressed this with SwiftUi. I know this comment is not helpful to anyone, but I wanted to raise this issue that we really should be putting pressure on Apple to provide a solution and not rely on the community to always supply this most common of problems.Behring
There is a very good article by Vadim vadimbulavin.com/…Notional
go down to the one with over 25 up votes AdaptsToKeyboardPurdum
What @DaveKozikowski said: https://mcmap.net/q/150841/-move-textfield-up-when-the-keyboard-has-appeared-in-swiftui --- Very easy to implement, works in many different cases without issues, including a nice animation!Redingote
P
102

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()
    }
}
Pasteurism answered 11/2, 2020 at 22:32 Comment(11)
I'm getting the error Thread 1: signal SIGABRT on line rect.height - geometry.safeAreaInsets.bottom when I go to the view with the keyboard a second time and click the TextField. It doesn't matter if I click the TextField the first time or not. The app still crashes.Francefrancene
This actually is one of the simpler solutions, but how could we match the view's animation with the keyboard's? Like the view is shrining faster than the keyboard is coming up (since you are manually applying an animation duration value).Aquaplane
On view disappear, rect.height - geometry.safeAreaInsets.bottom is crashing; not sure what's going on since the crash is from GeometryProxy.safeAreaInsets.getter () 😢Aquaplane
I'm on iOS 14 beta 3 and I had the same problem than @user832. It seems that the safeareainset is updated when the keyboard appears and disappears, but the view does not notice it. I made some changes, eliminate the GeometryReader and just change the padding to 0.1 so the view is required to update. It's working for me now.Hedonism
Unfortunately it does have a runtime issue on iPad when the app runs as "picture in picture" similar to Michael Neas's solution. But overall, great improvement.Teddytedeschi
Works perfectly for my VStack (in a NavigationView), containing content like ScrollView and HStack, and all this with a UIKit tab bar at the bottom. Rushing a project, so you have my thanks.Bascule
Crashes on the safeAreaInsets seem to happen on iOS13 only. @JLively, try this bit of code: if #available(iOS 14, *) { return rect.height - geometry.safeAreaInsets.bottom // on iOS 13 the safeAreaInsets are nil and causes a crash. Apparently for now it still works well iOS 14. } else { return rect.height } (Predrag, could you update the answer if you agree?)Redingote
@hitesh-surani This should be the right answer. Works in all situations and is really easy to implement.Adenitis
I found this code does work well on iOS 13.3 but with iOS 13.1 and iOS 13 it gives complete WHITE view. I made my min target as 13.3. For iOS 14. The scroll and padding works out of the box, no need for the modifier.Metaphysic
You can achieve this with JUST ONE LINE OF CODE from iOS 14Hellfire
Related to the crash, while this gives a compiler warning since it shouldn't be nil, on iOS 13 I was able to use this safely: ``` if geometry.safeAreaInsets != nil { return rect.height - geometry.safeAreaInsets.bottom } else { return rect.height } ```Vitkun
P
85

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

        }
    }
}
Pisarik answered 23/6, 2019 at 5:25 Comment(24)
Is it possible to attach GeometryGetter as a view modifier than a background by making it conform to ViewModifier protocol?Notional
It is possible, but what's the gain? You would be attaching it like this: .modifier(GeometryGetter(rect: $kGuardian.rects[1])) instead of .background(GeometryGetter(rect: $kGuardian.rects[1])). Not much of a difference (only 2 characters less).Pisarik
For me the animation didn't work until I wrapped my Content in a ScrollView. Just putting it our there if anyone has similar issues.. also for Beta 4 you need to: - update didChange -> willChange - change basic -> easeInOut or a other animationEmmanuelemmeline
GeometryGetter doesn't compile in beta 5 - the problems start with ShapeView being an undeclared type (it's deprecated and now gone). I'm sure I'm missing something simple, but I can't see how to fix itOdyl
@Odyl I updated the GeometryGetter code. It's an old question, and I forgot about the changes in beta 5.Pisarik
I'm getting there! ObjectBinding is gone too. I tried ObservedObject, but that doesn't work either. (Thanks for the quick response!)Odyl
I changed ObjectBinding to ObservedObject, and then removed <Content> from the line '''struct GeometryGetter<Content>: View {'''. That compiles, but tests don't work. I have split the overall view into subviews, and passed the guardian from the parent to the subviews. That said, there's no scrolling when the keyboard appears.Odyl
I updated the entire code. It is not compatible with the latest beta. (btw, the Content was a typo on my part, sorry).Pisarik
It's certainly not!! Oh well. Perhaps it's a bug they'll fix. Separately, rather than hard code indices into the rects, I made up an enum of type Int, letting me code them symbolically (that is, as RectsEnum.somecase.rawValue, naming should undoubtedly vary). I named the last case count, and use it for the array sizing. It was really bothering me to have to both code each index in two places, get the count right, and not overlap. The enum solves all that.Odyl
@Pisarik - wondering: did you think about using preferences to feed the size back up the view stack instead of the delayed store into the rect using the DispatchQueue.main.async call? (Overall, I'm trying to make this work with nesting NavigationView->Form->Section->TextField. I'm hoping this is something Apple will natively see the need to fix robustly enough that works)Odyl
@Odyl yes, using preferences is probably preferred ;-) (although there's nothing wrong with the current approach). However, preferences are much cleaner. When I answered this question (ages ago), I was still investigating how Preferences worked, and there was zero info about them. That's why I decided to go for the GeometryGetter solution.Pisarik
For what it's worth, I changed this so that KeyboardGuardian is an ObservableObject / ObservableObjectPublisher, and had updateSlide just return the height of the keyboard along with the objectWillChange.send(). That's working with beta 8 / GM seed. I'll post things as a separate answerOdyl
I'm not sure why, but I'm getting a weird error on the onEditingChanged closure saying it needs more context.Afterdeck
Using AnyView you break SwiftUI Metal acceleration benefits. I think that Benjamin Kindle reply is more correct for SwiftUI.Moreta
In some situations you could get a SIGNAL ABORT from the program inside the GeometryGetter when assigning the new rectangle if you are navigating away from this screen. If that happens to you just add some code to verify that the size of the geometry is greater than zero (geometry.size.width > 0 && geometry.size.height > 0) before assigning a value to self.rectArmpit
Instead of moving textField up it goes down with above logic, i think there is issue when textfield is in different view hierarchy.Nickey
@JulioBailon I don't know why but moving geometry.frame out of DispatchQueue.main.async helped with SIGNAL ABORT, now will test your solution. Update: if geometry.size.width > 0 && geometry.size.height > 0 before assigning self.rect helped.Ramer
This is great! how can I add a SecureField [for passwords] to this solution? What would need to be modified. Thanks!Purdum
the now breaks on 13.4 on this line: self.rect = geometry.frame(in: .global)Purdum
this breaks for me as well on self.rect = geometry.frame(in: .global) getting SIGNAL ABORT and tried all proposed solutions to address this errorMagdalenmagdalena
.offset(y: kGuardian.slide) should probably be .offset(y: -kGuardian.slide), no?Deter
You don't need to import combine for the KeyboardGuardian Class.Stibnite
You can achieve this with JUST ONE LINE OF CODE from iOS 14Hellfire
@MojtabaHosseini That one line of code solution only works if there is a bunch of empty space in the view. In your case you have a spacer, and that spacer area ends up shrinking. If you have multiple controls and no free/shrinkable space then that solution doesn't work.Maculation
B
76

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.

Beeves answered 4/10, 2019 at 19:33 Comment(16)
I like this answer for its simplicity. I added .animation(.easeOut(duration: 0.16)) to try to match the speed of the keyboard sliding up.Blaylock
Why have you set a max height of 340 for the keyboard?Esquimau
@DanielRyan Sometimes the keyboard height was returning incorrect values in the simulator. I can't seem to figure out a way to pin down the problem currentlyBeeves
I haven't seen that issue myself. Maybe it's fixed in the latest versions. I didn't want to lock down the size in case there are (or will be) larger keyboards.Esquimau
You could try with keyboardFrameEndUserInfoKey. That should hold the final frame for the keyboard.Parkman
@MathiasClaassen thank you! Updating my answer because that key is much better.Beeves
This is generally working for me, but I'm having some issues using it in a TabView. There seems to be extra padding above the keyboard the size of the tab bar. I can work around it for testing on my iPhone 8 specifically by subtracting 49 from the keyboardSize.height, but that doesn't scale across devices. Does anyone know how to get the tab bar height programmatically?Cantonment
@Imh You could use a GeometryReader and subtract the height of the safeAreaInsets when you setting the padding. Like this: .padding(.bottom, self.keyboard.currentHeight == 0 ? self.keyboard.currentHeight : self.keyboard.currentHeight - geometry.safeAreaInsets.bottom)Limoges
I like this solution, but how would we scroll a little higher so the focused text field is a bit higher above the keyboard? Right now it is right on the edge of the keyboard and doesn't look good that way.Treasatreason
This solution only returns the height of the keyboard to help offset by that height. @Pisarik solution (although longer) provides a slide value that will update depending on wether or not the keyboard is over the textField so you don't offset your view if not needed (and potentially push your textfield out of view if it was on top of screen)Pushkin
The problem with this solution is that it moves the view up even if it the text field is at the top of the screen. Also text fields at the top of the screen get moved off of the screen.Stibnite
When you're on an iPad and display the app as "picture in picture" the keyboard will only use part of the bottom of the app's window. Therefore the move up will be a bit too much (and it's not because of safe area thing, but because the picture in picture window doesn't reach to the very bottom of the screen, while keyboard does). But overall, nice solution.Teddytedeschi
To solve the issue for iPad (easy way), I just updated keyboardHeight considering the difference between window's height and screen's height: .onReceive(Publishers.keyboardHeight) { self.keyboardHeight = $0 - (UIApplication.shared.windows[0].screen.bounds.height - UIApplication.shared.windows[0].frame.height) / 2 }Teddytedeschi
You can achieve this with JUST ONE LINE OF CODE from iOS 14Hellfire
For my use case this works like a charm. ThanksSeafaring
I am getting error 'keyboardFrameEndUserInfoKey' has been renamed to 'UIKeyboardFrameEndUserInfoKey'. When fix with this suggestion I got; "Type 'UIResponder' has no member 'UIKeyboardFrameEndUserInfoKey'"Cardenas
H
58

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

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.

Hellfire answered 25/8, 2020 at 11:9 Comment(12)
It works on Any View, including the TextEditor.Hellfire
@MojtabaHosseini what if I want to prevent this behavior? I have an image that is now being moved up when I open the keyboard, that I do not want to move.Richie
You should apply it on the proper view. As you can see in this preview, the red view stays still. @RichieHellfire
@Mojtaba Hosseini I have the exact same layout as you, except that I have a simple Image("x.png") where you have your TextFieldRichie
@MojtabaHosseini Thanks for the help, but I figured it out already. Just had to change a few things in parent View.Richie
@Richie Then please tell us what you changed. Maybe there are more people having the same issue...Taneka
I figured it out myself. Add .ignoresSafeArea(.keyboard) to your View.Taneka
@Taneka sorry. I did not mention it as it was structural problems. MojtabaHosseini had already said which line of code was needed. It was just a matter of applying it to the correct view.Richie
@MojtabaHosseini please, can you say something about offset jumping at end to start of video? Looks like initial offset is different than after keyboard hides.Swats
This is NOT a solution. This line actually tells the compiler NOT to respect a safe area on the control (but that does nothing). Just delete the line and you will see the exact same behaviour. In iOS14 the keyboard avoidance is default. Your view will shrink to a size of the screen minus the keyboard if present. With .ignoresSafeArea you can actually PREVENT it from happening on views. Thats why it is called ignores-safe-area.Fowling
@Fowling I was wondering the same that isn't this ignoresSafeArea(.keyboard) thing supposed to prevent the default behavior of shifting the views up. Good to see someone's the confirmation, thanks!Twinflower
It works for me (.ignoresSafeArea(.keyboard, edges: .bottom)) and in this case, I can control the offset of objects and define this parameter for all elements or for each separately.Modulation
M
38

I created a View that can wrap any other view to shrink it when the keyboard appears.

It's pretty simple. We create publishers for keyboard show/hide events and then subscribe to them using onReceive. We use the result of that to create a keyboard-sized rectangle behind the keyboard.

struct KeyboardHost<Content: View>: View {
    let view: Content

    @State private var keyboardHeight: CGFloat = 0

    private let showPublisher = NotificationCenter.Publisher.init(
        center: .default,
        name: UIResponder.keyboardWillShowNotification
    ).map { (notification) -> CGFloat in
        if let rect = notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect {
            return rect.size.height
        } else {
            return 0
        }
    }

    private let hidePublisher = NotificationCenter.Publisher.init(
        center: .default,
        name: UIResponder.keyboardWillHideNotification
    ).map {_ -> CGFloat in 0}

    // Like HStack or VStack, the only parameter is the view that this view should layout.
    // (It takes one view rather than the multiple views that Stacks can take)
    init(@ViewBuilder content: () -> Content) {
        view = content()
    }

    var body: some View {
        VStack {
            view
            Rectangle()
                .frame(height: keyboardHeight)
                .animation(.default)
                .foregroundColor(.clear)
        }.onReceive(showPublisher.merge(with: hidePublisher)) { (height) in
            self.keyboardHeight = height
        }
    }
}

You can then use the view like so:

var body: some View {
    KeyboardHost {
        viewIncludingKeyboard()
    }
}

To move the content of the view up rather than shrinking it, padding or offset can be added to view rather than putting it in a VStack with a rectangle.

Maloriemalory answered 27/7, 2019 at 18:42 Comment(6)
I think this is the right answer. Just a minor tweak I did: instead of a rectangle I'm just modifying the padding of self.view and it works great. No problems at all with the animationBlus
Thanks! Works perfectly. As @Taed said, it's better using a padding approach. The final result would be var body: some View { VStack { view .padding(.bottom, keyboardHeight) .animation(.default) } .onReceive(showPublisher.merge(with: hidePublisher)) { (height) in self.keyboardHeight = height } }Cracked
It's a great solution, but the main issue here is that you loose the ability to move up the view only if the keyboard is hiding the textfield you are editing. I mean: if you have a form with several textfields and you start editing the first one on top you probably don't want it to move up because it would move out of the screen.Pteryla
I really like the answer, but like all the other answers it doesn't work if your view is inside a TabBar or the View isn't flush with the bottom of the screen.Saunderson
this solution moves the text field up until its disappears if the container view is a List or ScrollViewBarela
Keyboard height is of little value as it doesn't provide the amount of offset to apply to the view that needs to move. @Pisarik solution uses the frame size of the keyboard within the global coordinate space to offer a proper offset and only move the view if the keyboard is on top of your textfield.Pushkin
B
31

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

Bucher answered 28/12, 2019 at 21:0 Comment(5)
Would be cool, if it would only offset, if necessary (i.e. don't scroll, if the keyboard doesn't cover the input element). Nice to have...Oeo
This works great, thank you. Very clean implementation as well, and for me, only scrolls if required.Reliance
Awesome! Why don't you provide this on Github or elsewhere? :) Or you could suggest this to github.com/hackiftekhar/IQKeyboardManager as they do not have a full SwiftUI support yetBelize
Won't play nice with orientation changes and will offset regardless of if it's needed or not.Pushkin
One issue here is that this is not animating at all... creates a very jittery motion 😢Aquaplane
G
31

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
Genitalia answered 15/2, 2020 at 8:43 Comment(4)
This is indeed the (unexpected) way for me, too. Solid.Vulgate
This framework worked even better than expected. Thank you for sharing!Tyburn
Working ok for me on SwiftUI - thanks @DominatorVbN - I on iPad landscape mode I needed to increase IQKeyboardManager.shared.keyboardDistanceFromTextField to 40 to get comfortable gap.Ib
Also had to set IQKeyboardManager.shared.enable = true to keep keyboard from hiding my text fields. In any case this is the best solution. I have 4 fields arranged vertically and the other solutions would work for my bottom-most field, but would push the top-most out of view.Burgin
L
19

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())
    }
}
Leandro answered 16/5, 2020 at 9:41 Comment(4)
I just see half of height of textview. do you know how to solve this?Addi
Good. this saved my time. Before use this, we know this modifier handle view's bottom padding.Ethbinium
This one was the best one for me. The other ones caused issues with since was using mvvm. And when the view initialized, the view model was initialized again, resetting the text field values. ThanksRambow
wrong keyboard height on iPad - landscapeBarela
Q
15

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.

Quadrat answered 7/6, 2019 at 15:52 Comment(6)
hey it seems you have experience in bindableobject. I can't get it working as I want. It would be nice if you could look over: #56500647Conk
Why aren't you using @ObjectBindingConk
With BindableObject deprecated, this is not working anymore, unfortunately.Tamaru
@Tamaru For what it's worth, BindableObject was just renamed to ObservableObject, and didChange to objectWillChange. The object updates the view just fine (though I tested using @ObservedObject instead of @State)Drillmaster
Hi, this solution is scrolling the content, but it show some white area above keyboard which hides half of textfield. Please let me know how we can remove the white area.Waistband
Nice solution! There is one slight improvement you can make there. Instead of using UIResponder.keyboardFrameBeginUserInfoKey your should instead use UIResponder.keyboardFrameEndUserInfoKey. With that change the keyboard size does not vary when appearing. See this post for reference: keyboard height varies when appearingLearnt
S
6

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.

Sadiesadira answered 2/4, 2020 at 20:10 Comment(5)
Very nice solution, highly recommend! I added a Bool to indicate whether the keyboard was currently active.Gers
Best and easiest solution, highly recommended!Sheeran
Does it work? For me complete view goes off screen.Sphinx
@SantoshSingh I recommend reading code and understanding what it does rather a vanilla copy paste. Not understanding the code or what it does while blindly taking it is not a good habit to be in…Sadiesadira
Is it possible to allow scrolling thru all the form fields when the keyboard is visible with this solution? I've tried but couldn't get it to work correctly. as mentioned above the view does go off screen.Gerlac
R
6

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
    }
}
Rozanneroze answered 17/5, 2020 at 4:24 Comment(5)
tried your solution... view is scrolled only to half of the textfield. Tried all the above the solution. Got the same issue. Please Help!!!Coverture
@Zeona, try in a simple app, you might be doing something different. Also, try removing '- (UIWindow.keyWindow?.safeAreaInsets.bottom ?? 0)' if you are using safe area.Rozanneroze
removed (UIWindow.keyWindow?.safeAreaInsets.bottom ?? 0) then I am getting a white space above keyboardCoverture
It's worked for me in SwiftUI. thank you :)Kismet
This is awesome! Just made a tiny edit to the UIWindow extension (:Ultramicroscopic
O
4

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://mcmap.net/q/150841/-move-textfield-up-when-the-keyboard-has-appeared-in-swiftui
//

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.

Odyl answered 12/9, 2019 at 23:17 Comment(1)
To the individual who downvoted: why? I attempted to add something useful, so I'd like to know how in your view I went wrongOdyl
D
4

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

Delgadillo answered 17/9, 2019 at 22:43 Comment(9)
this solution doesn't work, it increases Keyboard heightIggy
Can you elaborate on the problems you're seeing @GSerjo? I'm using this code in my app and it's working fine for me.Delgadillo
Could you please turn on Pridictive in iOS keyboard. Settings -> General -> Keyboard -> Pridictive. in this case it doesn't correct calclate and adds padding to the keyboardIggy
@GSerjo: I have Predictive text enabled on an iPad Touch (7th gen) running the iOS 13.1 beta. It correctly adds padding for the height of the prediction row. (Important to note, I'm not adjusting the height of the keyboard here, I'm adding to the padding of the view itself.) Try swapping in the debugging map that is commented out and play with the values you get for the predictive keyboard. I'll post a log in another comment.Delgadillo
With the "debugging" map uncommented you can see the value being assigned to keyboardHeight. On my iPod Touch (in portrait) a keyboard with predictive on is 254 points. Without it is 216 points. I can even turn off predictive with a keyboard onscreen and the padding updates properly. Adding a keyboard with predictive: Received UIKeyboardWillChangeFrameNotification with height 254.0 Received UIKeyboardWillShowNotification with height 254.0 When I turn off predictive text: Received UIKeyboardWillChangeFrameNotification with height 216.0Delgadillo
Received UIKeyboardWillChangeFrameNotification with height 260.0 Received UIKeyboardWillShowNotification with height 260.0 a text field is under the keyboard :( .padding(.bottom, keyboardHeight) changes keyboard heightIggy
maybe the difference is here, i.e. how we're using it. this my code .sheet(isPresented: $showModal, onDismiss: {}){ KeyboardHost { Form() } }Iggy
I can create a very simple project, just to reproduce the issueIggy
If your view is taller than space remaining when the keyboard is up then something has to go under the keyboard. The fix for that is replace your top level VStack with a ScrollView. In my app if I open the view in landscape it will scroll the the view so the field with focus is just above the keyboard. Here's the start of my body: var body: some View { KeyboardHost { ScrollView { self.editingControls() (Apologies for the line breaks. I can't seem to put multi-line code blocks in a comment.)Delgadillo
C
3

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())
Caribou answered 5/11, 2019 at 1:5 Comment(2)
I love this answer, but I'm not quite sure where 'ScreenSize.portrait' is coming from.Anapest
Hi @MishaStone thanks por choose my approach. ScreenSize.portrait is a class that I made to obtain measurements of screen base on Orientation and percentage.... but you can replace it with any value you require for your translationCaribou
G
3

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.

Grenadine answered 6/8, 2020 at 15:56 Comment(1)
this doesn't work very well with scroll view, i opened a question about thatBarela
Y
3

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/

Yusuk answered 19/8, 2020 at 14:43 Comment(1)
i see apple release notes, tested on beta5 and beta6, TextField works, TextEditor NOT, what do i miss? @State var text = "" var body: some View { Form { Section { Text(text) .frame(height: 500) } Section { TextField("5555", text: $text) .frame(height: 50) } Section { TextEditor(text: $text) .frame(height: 120) } } }Multiangular
C
3

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

Cinelli answered 19/5, 2022 at 14:42 Comment(1)
This works really well to be able to position a scroll view at the right place.Rebel
A
2

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)
  }
}
Abbreviated answered 13/1, 2020 at 20:52 Comment(2)
Just an FYI, you own the objects when calling NotificationCenter.default.addObserver... you need to store those and remove the observers at an appropriate time...Sadiesadira
Hi @TheCodingArt, that's right I have tried to do that like this ( oleb.net/blog/2018/01/notificationcenter-removeobserver ) but it does not seem to work for me, any ideas?Johanna
F
2

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)
    }
}
Fagen answered 14/5, 2020 at 20:16 Comment(6)
I added an .animation(.easeIn) to match the animation with which the keyboard appearsMidian
(For iOS 13 go to history of this answer)Hexone
Hi, .assign(to: \.keyboardHeight) is giving this error "Cannot infer key path type from context; consider explicitly specifying a root type". Please let me know the proper and clean solution for both ios 13 and ios 14.Waistband
I had to add another listener for UIResponder.keyboardWillHideNotification. Other than that - this is the only solution which worked for me. Thank you!Very
A few problems: the assign should be .assign(to: \.keyboardHeight, on: self) (in Xcode 12.5 at least). Also, you need to monitor also for UIResponder.keyboardWillHideNotification as well, returning always 0 for the height when that is triggered. Screenshot: cln.sh/Sp6zKcFluvial
@IanDundas on: self) is not needed in iOS 14 - check history of answer if you need that.Fagen
J
2

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!

Jollification answered 5/8, 2020 at 19:15 Comment(1)
This does not work at all (14.1). What is the idea?Fowling
L
2

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.

Ladylike answered 12/7, 2022 at 7:16 Comment(0)
M
2

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()
 }
}
Moline answered 25/4, 2023 at 9:43 Comment(2)
Have you tested this code? It looks ridiculous exampleGuarneri
@Guarneri yes sir, my mistake and I made some modifications please check now ExampleMoline
S
1

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 🎉.
Saunderson answered 6/2, 2020 at 7:3 Comment(0)
P
1

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

Pelletier answered 21/3, 2020 at 9:20 Comment(0)
A
1

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() }
    }
  }
}
Athabaska answered 7/4, 2020 at 13:49 Comment(0)
A
0

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.

Ayer answered 9/9, 2020 at 9:15 Comment(0)
V
0

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

Veronaveronese answered 29/8, 2022 at 14:18 Comment(0)
G
0

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.

Gerlac answered 6/12, 2022 at 4:25 Comment(0)
F
0

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
        }
    }
}
Fowle answered 30/12, 2023 at 7:1 Comment(0)
D
0

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

Dhaulagiri answered 17/1 at 21:49 Comment(0)
A
-3

The most elegant answer I've managed to this is similar to rraphael's solution. Create a class to listen for keyboard events. Instead of using the keyboard size to modify padding though, return a negative value of the keyboard size, and use the .offset(y:) modifier to adjust the the outer most view containers's offset. It animates well enough, and works with any view.

Aronarondel answered 7/7, 2019 at 3:24 Comment(2)
How did you get this to animate? I have .offset(y: withAnimation { -keyboard.currentHeight }), but the content jumps instead of animates.Carabin
It's been a few betas ago that I mucked with this code, but at the time of my earlier comment, modifying the offset of a vstack during runtime was all that was required, SwiftUI would animate the change for you.Aronarondel

© 2022 - 2024 — McMap. All rights reserved.