Present sheet with a TextField and its keyboard in a single animation? - ios

I'm building a SwiftUI to-do app. You tap an Add button that pulls up a partial-height sheet where you can enter and save a new to-do. The Add sheet's input (TextField) should be focused when the sheet appears, so in order to keep things feeling fast and smooth, I'd like the sheet and the keyboard to animate onscreen together, at the same time. After much experimentation and Googling, I still can't figure out how to do it.
It seems like there are two paths to doing something like this:
(1) Autofocus the sheet
I can use #FocusState and .onAppear or .task inside the sheet to ensure the TextField is focused as soon as it comes up. It's straightforward functionally, but I can't find a permutation of it that will give me that single animation: it's sheet, then keyboard, presumably because those modifiers don't fire until the sheet is onscreen.
(2) Keyboard accessory view / toolbar
The .toolbar modifier seems tailor-made for a view of custom height that sticks to the keyboard--you lose the nice sheet animation but you gain the ability to have the view auto-size. However, .toolbar is designed to present controls alongside a TextField that itself isn't stuck to the keyboard. That is, the field has to be onscreen before the keyboard so it can receive focus...I don't know of a way to put the input itself inside the toolbar. Seems like chat apps have found a way to do this but I don't know what it is.
Any help would be much appreciated! Thanks!

Regarding option (1), I think there is no way to sync the animation. I decided to do it this way and don't worry about the delay between sheet and keyboard animation. Regarding option (2), you could try something like this:
struct ContentView: View {
#State var text = ""
#FocusState var isFocused: Bool
#FocusState var isFocusedInToolbar: Bool
var body: some View {
Button("Show Keyboard") {
isFocused = true
}
.opacity(isFocusedInToolbar ? 0 : 1)
TextField("Enter Text", text: $text) // Invisible Proxy TextField
.focused($isFocused)
.opacity(0)
.toolbar {
ToolbarItem(placement: .keyboard) {
HStack {
TextField("", text: $text) // Toolbar TextField
.textFieldStyle(.roundedBorder)
.focused($isFocusedInToolbar)
Button("Done") {
isFocused = false
isFocusedInToolbar = false
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
}
}
.onChange(of: isFocused) { newValue in
if newValue {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.05) {
isFocusedInToolbar = true
}
}
}
}
}
The trick is, that you need a TextField in your content that triggers the keyboard initally and then switch focus to the TextField in the toolbar. Otherwise you won't get the keyboard to show up.

Related

How to use .onSubmit() with vertical TextField in SwiftUI?

I'm trying to have a vertically growing TextField in `SwiftUI but also have the software keyboard have a custom submission method.
This uses the new functionality of iOS 16's TextFields being able to take an axis as an argument for which way it should grow. See the docs here: https://developer.apple.com/documentation/swiftui/textfield/init(_:text:axis:)-8rujz.
Here's a sample ContentView showing the setup.
struct ContentView: View {
#State var message: String = ""
var body: some View {
VStack {
Text("Try to submit this using the blue send button on the software keyboard")
TextField("Placeholder", text: $message, axis: .vertical)
.onSubmit {
print("submission!")
}
.submitLabel(.send)
}
}
}
When you run this, you can see the TextField properly grows vertically, but even when you have a custom submission label, pressing the blue "send" button in the software keyboard on iOS just inserts a newline, rather than firing the .onSubmit
When using a hardware keyboard, pressing the return does run the code in .onSubmit, so this seemingly just a limitation of the software keyboard.

TextField(_:text:axis:) breaks keyboard avoidance inside ScrollView in iOS 16

Automatic keyboard avoidance seems to work fine if it's a regular TextField (i.e. one that doesn't expand on an axis), whether or not it is contained in a ScrollView
Keyboard avoidance also seems to work with the new TextField(_:text:axis) introduced in iOS 16 if it's simply placed in a VStack without being wrapped in a ScrollView. It will even continue to avoid the keyboard correctly as the height expands with more text.
But I can't seem to get keyboard avoidance to work with TextField(_:text:axis) if it is placed inside a ScrollView
I can employ the hacky method of using a ScrollViewReader combined with DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(400)) to wrap the proxy.scrollTo() when the TextField is focused. This sort of works when you first focus the field, but I can't seem to get the ScrollView to continue to adjust its position as the TextField expands.
Here is an example:
struct KeyboardAvoidingView: View {
#State var text = ""
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack {
Color.red
.frame(height: 400)
Color.blue
.frame(height: 400)
TextField("Name", text: $text, axis: .vertical)
.padding(.vertical)
.onTapGesture {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(400)) {
withAnimation(.default) {
proxy.scrollTo(0)
}
}
}
.onChange(of: text) { newValue in
proxy.scrollTo(0) // This doesn't seem to do anything
}
Spacer()
.frame(height: 0)
.id(0)
}
}
}
}
}
I guess I'm wondering whether this is expected behavior, or a bug. And regardless if it's one or the other, I'm wondering if I can have an auto-expanding text field inside a scroll view that I can make avoid the keyboard even as the height of the field expands?
UPDATE: It turns out, the issue was with placing the TextField inside a VStack instead of a LazyVStack. I assume ScrollView doesn't know what to do with just a regular VStack in certain situations. If I replace the VStack with a LazyVStack in my example, everything works as expected!
I answered the question with the update posted above. The issue was with using VStack instead of LazyVStack
This is a long time known bug in the TextField component, but you may achieve the desired behavior by using an anchor: .bottom in the proxy.scrollTo call of your onChange.
it'll look like this:
// ...
TextField("Name", text: $text, axis: .vertical)
.padding(.vertical)
.onTapGesture {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(400)) {
withAnimation(.default) {
proxy.scrollTo(0)
}
}
}
.onChange(of: text) { newValue in
// This will always scroll to the bottom of the text editor,
// just make sure to pass the right value in the first parameter
// that will identify your TextEditor
proxy.scrollTo(MyTextEditorId, anchor: .bottom)
}
// ...
You may need some additional work to handle the editing of upper parts of the text editor when it's taller than your screen

SwiftUI: strange offset on tap when reopening app with a `sheet` open

I am facing out a strange behavior that really looks like a SwiftUI bug.
When I leave the app with a .sheet open and reopen it, all content from parent has an offset on tap. It is difficult to explain (and English is not my mother tongue) so here is a really simple example:
struct ContentView: View {
#State private var isOpen = false
var body: some View {
Button(action: {
isOpen.toggle()
}, label: {
Text("Open sheet")
.foregroundColor(.white)
.padding()
.background(.blue)
})
.sheet(isPresented: $isOpen, content: {
Text("Sheet content")
})
}
}
To reproduce the issue follow those steps:
Tap just below to the top border of blue button Open sheet: the sheet opens as expected.
When the sheet is open, close the app (go back to Springboard, cmd+shift+H on iOS Simulator).
Reopen the app. You're still on the sheet view.
Close the sheet. You're back on main view with blue button. Here is the bug:
Tap again on the top of blue button, right below the top border. Nothing happens. You have to click few pixels below. There is an offset that makes all tappable items on main view not aligned.
Does anyone have seen this bug also? Is there something I do wrong?
Other notices:
When closing the app from main view, the bug doesn't appear. And even when the bug is here and I close the app from main view and reopen, the bug disappears.
If I use a .fullScreenCover instead of .sheet, the bug doesn't appear.
It really looks like a bug with .sheets open.
EDIT:
I have tried two workarounds but both don't work:
Embed the Button in an external View.
Replace Button with only the Text and add .onTapGesture{ ... } modifier to toggle isOpen #State property.
EDIT 2:
After hours of tries I could find something interesting: if, in the sheet content, I add a button to dismiss the sheet, the bug doesn't appear anymore. But if I dismiss the sheet with finger (drag from top to bottom), it still appears.
Here is modified code:
struct ContentView: View {
#State private var isOpen = false
var body: some View {
Button(action: {
isOpen.toggle()
}, label: {
Text("Open sheet")
.foregroundColor(.white)
.padding()
.background(.blue)
})
.sheet(isPresented: $isOpen, content: {
SheetContent()
})
}
}
struct SheetContent: View {
#Environment(\.dismiss) var dismiss
var body: some View {
Button(action: { dismiss() }, label: {
Text("Dismiss sheet")
})
}
}
It looks like there is something with calling (or not) the #Environment(\.dismiss) var dismiss.
The current state is a bit better as few days ago as the bug only appears when user dismiss the sheet by dragging down. But there is still something wrong.
Is there a way to programmatically call dismiss() when sheet is closed by dragging down?

Why do SwiftUI Buttons that use .buttonStyle see taps when a Menu is presented?

Goal Display a list of "card like" rows where a tap highlights the entire card and then performs an action. The rows need to support a Menu in their content.
The code below does this and at first seems to work fine. However, long pressing on a row while the menu is presented breaks the SwiftUI view hierarchy and requires the app be restarted to restore normal functionality.
struct ContentView: View {
let strings: [String] = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot"]
#State var shouldDoSomething = false
var body: some View {
List(strings, id: \.self) { string in
HStack {
Text(string)
Spacer()
Menu {
Button {} label: { Label("Show Less", systemImage: "hand.thumbsdown") }
Button {} label: { Label("Show More", systemImage: "hand.thumbsup") }
} label: { Image(systemName: "ellipsis.circle") }
}
.padding()
// Make entire card tappable and highlight on tap
.background() {
Button(action: {
shouldDoSomething = true
}, label: { Color.white })
.buttonStyle(cardButtonStyle())
}
// Card style
.listRowSeparator(.hidden)
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.gray, lineWidth: 0.5))
.cornerRadius(12)
.shadow(color: Color.black.opacity(0.1), radius: 10, x: 0, y: 5)
}
.listStyle(.plain)
.alert(isPresented: $shouldDoSomething) {
Alert(title: Text("Row Tapped"), message: Text("Do something useful here."), dismissButton: .cancel())
}
}
}
struct cardButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.overlay {
configuration.isPressed ? Color.black.opacity(0.25) : Color.clear
}
}
}
The Problem
If the user taps the ellipses to bring up the Menu, then taps on any card. The Menu is dismissed and an Alert is displayed. The tap should not present the alert, it should only dismiss the Menu. This is an annoying tiger trap, but I could convince myself to live with it.
Worse, when the user taps the ellipses to bring up the Menu, then long presses on any card. Three things are different from the use case above.
The Alert does not appear (it shouldn't)
The Menu does not dismiss (it doesn't in normal cases either)
The console prints an error Attempt to present...which is already presenting... (Uh oh...)
The app is now broken, tap to dismiss the Menu and now the card rows are no longer tappable unless the app is restarted.
What I have tried to resolve the issue:
A. Dispatch the shouldDoSomething = true on the main queue, with and without a delay. This did not help.
B. I tried manually dismissing the Menu by declaring the dismiss environment variable and calling dismiss() when the button is pressed. This did not help.
C. Using .onAppear() and added tap and long press gestures, I tried to keep track of when the Menu was being displayed and using that information, disable the background button. Unfortunately, I was never able to detect the Menu is being displayed when triggered by a long press and when it is dismissed.
D. I tried a standard button in the row instead of a button in the view's background. This did not help.
E: Removed the four lines of "Card Style" code and .listStyle to see if they contributed to the problem. This did not help.
F. Through trial and error. I commented out the .buttonStyle(cardButtonStyle()) line and the problem went away. I switched the buttonStyle to an Apple standard one and the problem still persists. So .buttonStyle manifests the problem, why, is the question.

SwiftUI Changing KeyboardType Per TextField

I have multiple TextFields and for one of them I need to switch the keyboardType to .numberPad. Obviously this is really easy using the .keyboardType(.numberPad) modifier, but the issue is that when the default keyboard is already shown it doesn't dismiss and then bring up the numberPad. Instead, it just switches immediately which causes the user to have to manually swipe up to see the TextField in which they will be entering numbers.
What I need to do is completely dismiss the default keyboard and then show the numberPad so that the correct TextField is pushed up on screen.
I've tried various modifiers for the numberField TextField such as:
.onAppear {
isFocused = nil
isFocused = .numberField
}
.onAppear {
isFocused = nil
sleep(1)
isFocused = .numberField
}
.focused($isFocused, equals: nil)
.keyboardType(.numberPad)
.focused($isFocused, equals: .numberField)
None work. The keyboard switches, but the screen does not scroll to the correct field.
As this is a multi-field form, I am using the submit button to switch between fields like this:
TextField("", text: $field1)
.focused($isFocused, equals: .field1)
.keyboardType(.default)
.onSubmit {
if isFocused == .field1 {
isFocused = .numberField
}
}
.submitLabel(.next)
TextField("", text: $numberField)
.focused($isFocused, equals: .numberField)
.keyboardType(.numberPad)
If I manually dismiss the default keyboard for field1 and then tap on the numberField TextField, the numberPad comes up and the field focuses correctly. So, I need to somehow automatically dismiss the default keyboard and then show the numberPad so the correct field is focused on screen for the user.
In order to dismiss an existing keyboard and then show a new type of keyboard, while maintaining keyboard avoidance and focusing on the correct TextField, you need to use DispatchQueue as follows:
TextField("", text: $field1)
.focused($isFocused, equals: .field1)
.keyboardType(.default)
.onSubmit {
if isFocused == .field1 {
isFocused = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
isFocused = .numberField
}
}
}
By using DispatchQueue you allow the app to keep running, while completely dismissing the keyboard, waiting x seconds (in my example it's 0.3 seconds which feels pretty instantaneous) and then bringing up the new keyboard.

Resources