What I'm trying to accomplish
A vertical list of TextField views that user enters values in one at a time. After entering a value in a text field and pressing keyboard submit button the focus moves to the next text field.
The problem I'm facing
The keyboard obstructs the active text field. I was hoping that if I used ScrollView it would automagically scroll to the next focused text field, but that's not the case.
Then I decided to try using ScrollViewReader's ScrollViewProxy object to scroll to the focused text field, which works! But not quite. It scrolls to the selected text field, but not enough to go over the keyboard. It seems to always be just a little bit underneath the top of the keyboard (regardless of whether there's keyboard toolbar or autocorrection bar or nothing).
Here's a screenshot of roughly where the text field ends up after I call proxy.scrollTo(focusedInput):
Here's where I would want it to end up:
Minimal code sample
struct ContentView: View {
#State var inputsValues: [String]
#FocusState var focusedInput: Int?
init() {
inputsValues = (0..<30).map { _ in "" }
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack {
ForEach((0..<inputsValues.count), id: \.self) { i in
TextField("Value", text: $inputsValues[i])
.focused($focusedInput, equals: i)
.submitLabel(.next)
.id(i)
.onSubmit {
if (i + 1) < inputsValues.count {
focusedInput = i + 1
proxy.scrollTo(focusedInput)
} else {
focusedInput = nil
}
}
}
}
}
}.toolbar {
ToolbarItem(placement: .keyboard) {
Text("This is toolbar")
}
}
}
}
Chaning two+ states in one closure (when they are related somehow or affects one area) are handled not very good (or not reliable). The fix is to separate them in different handlers.
Tested with Xcode 13.3 / iOS 15.4
Here is main part:
VStack {
// ... other code
.onSubmit {
// update state here !!
if (i + 1) < inputsValues.count {
focusedInput = i + 1
} else {
focusedInput = nil
}
}
}
}
.onChange(of: focusedInput) {
// react on state change here !!
proxy.scrollTo($0)
}
Complete test module is here
Related
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.
Is it possible to detect the cell (or array position) of the List currently displayed on the View screen from among the 100 Items as shown below?
Also, at that time, I would like to get the position of the cell in detail, such as some cells that are half hidden from the screen at the top and bottom.
struct DemoList: View {
// 1.
#State private var items: [Item] = (0..<100).map { Item(title: "Item #\($0)") }
// 2.
var body: some View {
List {
ForEach(items) { item in
Text(item.title)
}
}
}
}
This might be a starting point. But beware that List always keeps one "buffer" cell before .onAppear/.onDisappear kick in.
List {
ForEach(items) { item in
GeometryReader { geo in
Text(item.title)
.onAppear {
print(item.title, geo.frame(in: .global))
}
.onDisappear {
print(item.title, "---")
}
}
}
}
A more sophisticated approach would use PreferenceKey. I recommend this (look for "GridInfoPreference"): https://swiftui-lab.com/impossible-grids/
Try using ‘onAppear’ modifier as Text(text).onAppear {}
Since iOS 15 there have been improvements around that modifier
I'm using Xcode 13.2 and building for iOS 15.0 + with SwiftUI 3.0 and Swift 5
Here is my code:
ScrollViewReader { value in
VStack {
List(0..<things.count, id: \.self) { index in
VStack {
HStack {
Image(systemName: "circle")
TextField(
"Text Field",
text: $things[index]
)
.focused($focusField, equals: index)
.submitLabel(.next)
.onSubmit {
funcThatScrollsToLastInput()
}
}
}
.padding(.bottom, 20)
.id(index)
}
}
}
I have a ScrollView that on the return key, goes to the next TextField, which is great!
However, I cannot seem to add padding to the bottom of the TextField, nor to its wrapping HStack, VStack, or even if I add a rectangle, from within the confines of the stack that has the .id(index) modifier.
I want the TextField, when scrolled to, to have a padding(.bottom) of 10 or 20, but right now it's a 0.
It always seems to scroll exactly to the bottom the TextField originally had, or in other words without any style modifications.
You are confusing padding for the amount of scrolling that has occurred as a result of your changing the .focus(). Your list rows have 20 padding on them. That is just the visual space around the view using other views away.
The issue you are having is that when you programmatically change the .focus() on a TextField in a List, the TextField is scrolled to high above the keyboard. You could have 0 or 100 padding, and that behavior would not change.
The fix is to finish implementing the ScrollViewReader and add a .scrollTo() in your code. Since you did not post a Minimal, Reproducible Example (MRE), I just simply coded it in the .onSubmit()
.onSubmit {
// I made focusField an optional, so I had to safely unwrap. If your implementation is
// non-optional, then you don't need this.
guard let focusField = focusField else {
return
}
if focusField < things.count - 1 {
self.focusField = focusField + 1
// The DispatchQueue.main.asyncAfter(deadline:) is necessary because the .scrollTo()
// has to wait for the .focus() to finish its scroll
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
// If your implementation of focusField is non-optional, then change this to
// focusField + 1 as the one below is a local constant, so the implementation
// uses focusField + 2 as you want the cell below the current cell to be in view/
value.scrollTo(focusField + 2, anchor: .bottom)
}
}
}
I have two TextEditors next to each other and I want to synchronize their scroll state, for example, if I scroll on the left one, the right one should show the same.
//basic code
#State private var textLeft: String = "view left"
#State private var textRight: String = "view right"
var body: some View {
HStack() {
TextEditor(text: $textLeft)
TextEditor(text: $textLeft)
.gesture(DragGesture(coordinateSpace: .global)
.updating($gestureState, body: { (value, state, transaction) in
print(value.translation)
})
.onChanged { value in
//this blocks the TextEditor to scroll
//or won't be called if the gesture is handled by the editor
//
//if I apply the offset to the other Editor
//it results in weird looking behavior
print(offset)
}
)
}
}
I tried several things like applying a gesture to it and capture the movement and set it to the other as an .offset(value), but either the DragGesture blocks the TextEditor or the TextEditor blocks the DragGesture.
Are there any approaches like copying gestures and apply them to another view or pass through the .gesture() to the view itself?
Thanks for any help!
You can try extracting the Gesture to a computed property:
var dragGesture: some Gesture {
DragGesture(coordinateSpace: .global)
.updating($gestureState) { value, state, transaction in
print(value.translation)
}
.onChanged { value in
// this blocks the TextEditor to scroll
// or won't be called if the gesture is handled by the editor
//
// if I apply the offset to the other Editor
// it results in weird looking behavior
print(offset)
}
}
var body: some View {
HStack {
TextEditor(text: $textLeft)
.gesture(dragGesture)
TextEditor(text: $textLeft)
.gesture(dragGesture)
}
}
I have a game view that has a board and I have several parts in a VStack and the final 2 HStack are some 'buttons' and then a row of tiles for the game.
VStack(spacing: 5) {
HStack {}
HStack {}
......
......
HStack {
ResetButton {
self.returnLetters()
}
NewLine {
self.newLine()
}
CalcButton {
self.calcButton()
}
StartButton {
self.startButton()
}
}
HStack {
ForEach(0..<7) { number in
Letter(text: self.tray[number], index: number, onChanged: self.letterMoved, onEnded: self.letterDropped)
}
}
This sets the screen all very well However I would ideally not want to show the Start and Calc buttons until later in the game, or indeed replace the Reset and NewLine buttons with Start and Calc.
As it looks
Ideally What I would have until later in game
Changing to this on the last go
Is there a way to not show items in the Stack or add items to the stack later please?
thanks
Use #State boolean variable that define if the buttons should be shown or not, when you change their value the view will be refreshed.
#State var isResetVisible = true
...
if isResetVisible {
ResetButton {
self.returnLetters()
}
}
then you set it to false somewhere and it will hide
Here is a pattern, introduce corresponding #State variable with desired initial state value. The state can be changed later at any time somewhere in view and view will be updated automatically showing conditionally dependent button:
#State private var newLineVisible = false // initially invisible
...
var body: some View {
...
HStack {
ResetButton {
self.returnLetters()
}
if self.newLineVisible { // << include conditionally
NewLine {
self.newLine()
}
}
CalcButton {
self.calcButton()
}