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

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

Related

FocusState changes in SwiftUI cause the keyboard to bounce

I'm making a sign-in interface for iOS in SwiftUI. The user should be able to easily switch from the username text field to the password text field by tapping the "next" button on the software keyboard. It's working well but the keyboard always bounces a little when switching between the two text fields for some reason. Edit: As suggested in this answer I've added a Spacer into the VStack to make it fill the available space. The text fields aren't bouncing anymore but the keyboard unfortunately still is. I've updated the code and the GIF to reflect my changes.
After googling a little it seemed like this wasn't a very common issue. This question seemed to be similar to what happens to me but following the answer and wrapping the text fields in a ScrollView or a GeometryReader did not change anything at all. This is my code:
struct AuthenticationView: View {
#State var userName: String = ""
#State var userAuth: String = ""
#FocusState var currentFocus: FocusObject?
enum FocusObject: Hashable { case name, auth }
var body: some View {
VStack(spacing: 8) {
TextField("Username", text: $userName)
.focused($currentFocus, equals: .name)
.padding(8).background(Color.lightGray)
.cornerRadius(8).padding(.bottom, 8)
.textInputAutocapitalization(.never)
.onSubmit { currentFocus = .auth }
.autocorrectionDisabled(true)
.keyboardType(.asciiCapable)
.textContentType(.username)
.submitLabel(.next)
SecureField("Password", text: $userAuth)
.focused($currentFocus, equals: .auth)
.padding(8).background(Color.lightGray)
.cornerRadius(8).padding(.bottom, 16)
.textInputAutocapitalization(.never)
.onSubmit { currentFocus = nil }
.autocorrectionDisabled(true)
.keyboardType(.asciiCapable)
.textContentType(.password)
.submitLabel(.done)
Spacer() // This fixes the text fields
// But it does not fix the keyboard
}.padding(32)
}
}
Your current layout says:
Put the edit fields into a VStack.
Layout the VStack in the parent view by centering it in the available space. Note, that the VStack only uses a minimum size.
Now, when the keyboard appears, the available space of the parent view, i.e. its height, will be reduced accordingly.
Because the VStack is layout in the center, the text fields bounce up and down.
There are a couple of options:
Ensure the VStack extends its height and the text fields are aligned at the top. For example using a Spacer:
VStack(spacing: 8) {
TextField("Username", text: $userName)
...
SecureField("Password", text: $userAuth)
...
Spacer()
}.padding(32)
Using a ScrollView:
ScrollView {
Spacer(minLength: 80) // give some space at the top
VStack(spacing: 8) {
TextField("Username", text: $userName)
...
SecureField("Password", text: $userAuth)
...
}.padding(32)
}
It may not look pretty, but it should give you an idea, where to work on this issue (you may want to use a GeometryReader and a possibly a ScrollView to perfect your layout).
Another option is to use a Form. Put your fields into there, and with a Form you get also a head start which looks pretty nice. The reason why a Form works is because the same reasons why it works with a Spacer (aligns fields on top) and because of a ScrollView.
The fact that the keyboard disappears temporarily when you tap "Next" is unfortunate. I have no solution for this, so far.

ScrollView touch works despite all other touch is disabled

So my goal is to disable touch events for everything on screen and I don't want to use .disable on every view or to use Color.black.opacity(0.00001) because of the code smell. I want it to be a block that isn't visible for the user like if I would overlay Color.clear over the whole view. And I want it to behave like if I were to use Color.black.opacity(0.1) with it disabling touch events on every view underneath.
If I for example use a ZStack with:
Color.black.opacity(0.2) every view underneath will no longer register touch events. (I want this, but it should be transparent)
Color.black.opacity(0) every view underneath will register touch events.
Color.black.opacity(0).contentShape(Rectangle()), some events will register, some won't, for example buttons won't work though scrolling in a ScrollView, or using a toggle will still work.
Here is some example code
struct ContentView: View {
#State var numberOfRows: Int = 10
var body: some View {
ZStack {
Color.white
ScrollView {
VStack {
ForEach(0..<numberOfRows, id: \.self) { (call: Int) in
Text(String(call))
.frame(maxWidth: .infinity)
}
}
}
Button(action: {
numberOfRows += 1
}) {
Color.blue
.frame(width: 300, height: 300)
}
Color.black.opacity(0) // <- change this to 0.x to disable all touch
.contentShape(Rectangle()) // <- Remove this line to make blue button work (opacity needs to be 0)
}
}
}
Why is scrollview still receiving touch events and why is buttons not?
Is there a way to make my touch events for every view underneath, disabled?
Use instead (tested with Xcode 13.4 / iOS 15.5)
Color.clear
// .ignoresSafeArea(.all) // << if need all screen space
.contentShape(Rectangle())
.highPriorityGesture(DragGesture(minimumDistance: 0))

ScrollViewReader .scrollTo method doesn't allow padding when scrolling to TextField

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

Adjust View up with Keyboard show in SwiftUI 3.0 iOS15

I have sign up page with a VStack embedded in a ScrollView that's embedded in a VStack. In the innermost VStack I have a series of TextFields with a custom TextFieldStyle.
The UI of the signup page looks like this:
VStack {
ScrollView {
VStack(spacing: 24) {
TextField(text: $firstNameText) {
Text(.firstNameLabel)
}
.textFieldStyle(.customTextfieldStyle())
TextField(text: $lastNameText) {
Text(.lastNameLabel)
}
.textFieldStyle(.customTextfieldStyle())
}
}
Spacer()
Button(action: submit) {
Text(.nextButtonTitle)
}
}
Next, the customTextfieldStyle looks like this:
VStack(alignment: .leading, spacing: 0) {
configuration
.font(.bodyBook14)
.padding(.bottom, 16)
Rectangle()
.fill(errors.isEmpty ? Color.primary : .errorRed)
.frame(height: 1)
ForEach(errors) { error in
Text(error.title)
.font(.bodyBook14)
.foregroundColor(.errorRed)
.padding(.top, 8)
}
}
In my custom textfield style I added a rectangle and an error text. The problem I'm having is with this custom style, the rectangle and error messages below the textfield is being covered on focus. the keyboard avoidance doesn't offset enough of the scrollview to display them to the user. The keyboard properly offsets the view just enough to show only the textfield but not the rectangle and error messages. Is it possible to make the keyboard recognize the entire custom textfield style should shift up?
Screenshot below shows how error text is all covered by the keyboard / button.

How to have 1 column in a multiple column list be of the same width w/out using a frame modifier of width so to retain flexibility

I have a list of entries that consist of multiple columns of UI with all except the first free to be uniquely sized horizontally (i.e. they’re as short/long as their content demands). I know with the first consistently sized column I can set a frame modifier width to achieve this, but I was hoping there is a better and more flexible way to get the desired behaviour. The reason being I don’t believe the solution is optimised to consider the user’s display size nor the actual max content width of the columns. That is, the width set will either not be wide enough when the display size is set to the largest, or, if it is, then it will be unnecessarily wide on a smaller/regular display size.
This is my current best attempt:
GeometryReader { geometry in
VStack {
HStack {
HStack {
Text("9am")
Image(systemName: "cloud.drizzle").font(Font.title2)
.offset(y: 4)
}.padding(.all)
.background(Color.blue.opacity(0.2))
.cornerRadius(16)
VStack {
HStack {
Text("Summary")
.padding(.trailing, 4)
.background(Color.white)
.layoutPriority(1)
VStack {
Spacer()
Divider()
Spacer()
}
VStack {
Text("12°")
Text("25%")
.foregroundColor(Color.black)
.background(Color.white)
}.offset(y: -6)
Spacer()
}.frame(width: geometry.size.width/1.5)
}
Spacer()
}
HStack {
HStack {
Text("10am")
.customFont(.subheadline)
Image(systemName: "cloud.drizzle").font(Font.title2)
.offset(y: 4)
.opacity(0)
}
.padding(.horizontal)
.padding(.vertical,4)
.background(Color.blue.opacity(0.2))
.cornerRadius(16)
VStack {
HStack {
ZStack {
Text("Mostly cloudy")
.customFont(.body)
.padding(.trailing, 4)
.background(Color.white)
.opacity(0)
VStack {
Spacer()
Divider()
Spacer()
}
}
VStack {
Text("13°")
Text("25%")
.foregroundColor(Color.black)
.background(Color.white)
}.offset(y: -6)
Spacer()
}.frame(width: geometry.size.width/1.75)
}
Spacer()
}
}
}
For me, this looks like:
As you can tell, 10 am is slightly wider than 9 am. To keep them as closely sized as possible, I’m including a cloud icon in it too, albeit with zero opacity. Ideally, 10 am would be sized the same as 9 am without needing a transparent cloud icon. More generally speaking, what would make sense is the widest HStack in this column is identified and then whatever its width is will be applied to all other columns. Keep in mind, my code above is static for demo purposes. It will be a view that is rendered iterating through a collection of rows.
You can use dynamic frame modifiers, such as frame(.maxWidth: .infinity) modifier to extend views so that they fill up the entire frame, even if the frame is dynamic. Here is an example that should help you get going:
struct CustomContent: View {
var body: some View {
VStack {
VStack {
CustomRow(timeText: "9am", systemIcon: "cloud.drizzle", centerText: "Summary", temperature: "12°", percent: "25%")
CustomRow(timeText: "10am", systemIcon: nil, centerText: nil, temperature: "13°", percent: "25%")
}
VStack {
CustomRow(timeText: "9am", systemIcon: "cloud.drizzle", centerText: "Summary", temperature: "12°", percent: "25%")
CustomRow(timeText: "10am", systemIcon: nil, centerText: nil, temperature: "13°", percent: "25%")
}
.frame(width: 300)
}
}
}
struct CustomContent_Previews: PreviewProvider {
static var previews: some View {
CustomContent()
}
}
struct CustomRow: View {
let timeText: String
let systemIcon: String?
let centerText: String?
let temperature: String
let percent: String
var body: some View {
HStack {
//Left column
HStack(alignment: .center) {
Text(timeText)
if let icon = systemIcon {
Image(systemName: icon)
.font(.title2)
}
}
.padding(.all)
.frame(width: 105, height: 60)
.background(Color.blue.opacity(0.2))
.cornerRadius(16)
// Center column
ZStack(alignment: .leading) {
Capsule()
.fill(Color.black.opacity(0.3))
.frame(height: 0.5)
if let text = centerText {
Text(text)
.lineLimit(1)
.background(Color.white)
}
}
// Right column
VStack {
Text(temperature)
Text(percent)
.foregroundColor(Color.black)
}
}
}
}
Guided by https://www.wooji-juice.com/blog/stupid-swiftui-tricks-equal-sizes.html, I accomplished this.
This is the piece of UI I want to make sure is horizontally sized equally across all rows with the width set to whatever is the highest:
HStack {
VStack {
Spacer()
Text("9am")
Spacer()
}
}.frame(minWidth: self.maximumSubViewWidth)
.overlay(DetermineWidth())
The stack the above is contained in has an OnPreferenceChange modifier:
.onPreferenceChange(DetermineWidth.Key.self) {
if $0 > maximumSubViewWidth {
maximumSubViewWidth = $0
}
}
The magic happens here:
struct MaximumWidthPreferenceKey: PreferenceKey
{
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat)
{
value = max(value, nextValue())
}
}
struct DetermineWidth: View
{
typealias Key = MaximumWidthPreferenceKey
var body: some View
{
GeometryReader
{
proxy in
Color.clear
.anchorPreference(key: Key.self, value: .bounds)
{
anchor in proxy[anchor].size.width
}
}
}
}
The link at the top best describes each’s purpose.
MaximumWidthPreferenceKey
This defines a new key, sets the default to zero, and as new values get added, takes the widest
DetermineWidth
This view is just an empty (Color.clear) background, but with our new preference set to its width. We’ll get back to that clear background part in a moment, but first: there are several ways to set preferences, here, we’re using anchorPreference. Why?
Well, anchorPreference has “No Overview Available” so I don’t actually have a good answer for that, other than it seems to be more reliable in practice. Yeah, cargo-cult code. Whee! I have a hunch that, what with it taking a block and all, SwiftUI can re-run that block to get an updated value when there are changes that affect layout.
Another hope I have is that this stuff will get better documented, so that we can better understand how these different types are intended to be used and new SwiftUI developers can get on board without spending all their time on Stack Overflow or reading blog posts like this one.
Anyway, an anchor is a token that represents a dimension or location in a view, but it doesn’t give you the value directly, you have to cash it in with a GeometryProxy to get the actual value, so, that’s what we did — to get the value, you subscript a proxy with it, so proxy[anchor].size.width gets us what we want, when anchor is .bounds (which is the value we passed in to the anchorPreference call). It’s kind of twisted, but it gets the job done.
maximumSubViewWidth is a binding variable passed in from the parent view to ensure the maximumSubViewWidth each subview refers to is always the the up-to-date maximum.
ForEach(self.items) { item, in
ItemSubview(maximumSubViewWidth: $maximumSubViewWidth, item: item)
}
The one issue with this was there was an undesired subtle but still noticeable animation on the entire row with any UI that gets resized to the max width. What I did to work around this is add an animation modifier to the parent container that’s nil to start with that switches back to .default after an explicit trigger.
.animation(self.initialised ? .default : nil)
I set self.initialised to be true after the user explicitly interacts with the row (In my case, they tap on a row to expand to show additional info) – this ensured the initial animation doesn't incorrectly happen but animations go back to normal after that. My original attempt toggled initialised's state in the .onAppear modifier so that the change is automatic but that didn't work because I’m assuming resizing can occur after the initial appearance.
The other thing to note (which possibly suggests although this solution works that it isn't the best method) is I'm seeing this message in the console repeated for either every item, or just the ones that needed to be resized (unclear but the total number of warnings = number of items):
Bound preference MaximumWidthPreferenceKey tried to update multiple
times per frame.
If anyone can think of a way to achieve the above whilst avoiding this warning then great!
UPDATE: I figured the above out.
It’s actually an important change because without addressing this I was seeing the column keep getting wider on subsequent visits to the screen.
The view has a new widthDetermined #State variable that’s set to false, and becomes true inside .onAppeared.
I then only determine the width for the view IF widthDetermined is false i.e. not set. I do this by using the conditional modifier proposed at https://fivestars.blog/swiftui/conditional-modifiers.html:
func `if`<Content: View>(_ conditional: Bool, content: (Self) -> Content) -> TupleView<(Self?, Content?)> {
if conditional { return TupleView((nil, content(self))) }
else { return TupleView((self, nil)) }
}
and in the view:
.if(!self.widthDetermined) {
$0.overlay(DetermineWidth())
}
I had similar issue. My text in one of the label in a row was varying from 2 characters to 20 characters. It messes up the horizontal alignment as you have seen. I was looking to make this column in row as fixed width. I came up with something very simple. And it worked for me.
var body: some View { // view for each row in list
VStack(){
HStack {
Text(wire.labelValueDate)
.
.
.foregroundColor(wire.labelColor)
.fixedSize(horizontal: true, vertical: false)
.frame(width: 110.0, alignment: .trailing)
}
}
}

Resources