SwiftUI ScrollView does not respond to keyboard - swift

Thanks for taking your time to help others :)
Problem description:
I want to bring up the ScrollView content as keyboard shows up. And I can't.
Despite, if ScrollView is turned upside down... it works!!! But I can't do that because I have to implement .contextMenu(...) and this produces an even worse bug (detailed in this post).
App must support iOS 14.
Simple code demo to show what happens.
import SwiftUI
struct ContentView: View {
#State var text: String = ""
var body: some View {
VStack {
ScrollView() {
LazyVStack {
ForEach(1..<201, id: \.self) { num in
Text("Message \(num)")
}
// .upsideDown() // With these modifiers, will prompt up the scrollView content
}
}
// .upsideDown() // With these modifiers, will prompt up the scrollView content
TextField("Your text here", text: $text)
.textFieldStyle(.roundedBorder)
.padding()
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Example app")
}
}
extension View {
func upsideDown() -> some View {
self.rotationEffect(.degrees(180))
}
}
GIF resources to see behaviour:
Good result (when upside down):
Bad result (on normal scrollView):
What we have checked?
Already tried this solution, but does not help.
Tried setting an offset to ScrollView of keyboards height when it does come up but... nothing happens.
Questions
Why does this happen when it is upside down? (and not at normal scrollview)
Why on both cases it brings up the TextField but does NOT bring up the content of Scrollview if is not upside down???

Related

SwiftUI Keyboard dismiss not interactive

Thanks for taking your time to help others :)
Problem description:
App must support iOS 14 (there's no keyboard toolbar), and cannot use Introspect library, sorry.
When using a TextField, I want to dismiss it interactively. It does dismiss the keyboard, but does not take TextField along. And it should.
Simple demo code to replicate what happens:
import SwiftUI
struct ContentView: View {
#State var text: String = ""
init() {
UIScrollView.appearance().keyboardDismissMode = .interactive // To interactively dismiss
}
var body: some View {
VStack {
ScrollView {
LazyVStack {
ForEach(1...200, id: \.self) { msg in
Text("Message \(msg)")
.padding()
.background(Color.red.cornerRadius(8))
}
}
}
TextField("Hello", text: $text)
.textFieldStyle(.roundedBorder)
.padding()
}
.navigationTitle("Example")
.navigationBarTitleDisplayMode(.inline)
}
}
GIF resources to see behaviour:
Good result:
Bad result:
What I have checked?
Registering the keyboard height through Notifications events, like in: this post and adding that height as offset, bottom padding... nothing works.
Any idea?

Applying padding to SwiftUI Picker with menu style is causing multiline text

I have a Picker with menu style presenting a label with an icon and text. When applying a padding to any views containing the picker, the picker will behave as though there is not enough space and run to multiple lines even though there is enough space.
Sample Code
struct ContentView: View {
let options = [
"Some Long Text"
]
private var selectedOption: Binding<String> =
.constant("Some Long Text")
var body: some View {
HStack {
Text("Some Text")
Picker("Work Type", selection: selectedOption) {
ForEach(self.options, id: \.self) {
Label($0, systemImage: "pencil.tip.crop.circle")
}
}.pickerStyle(.menu)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
VStack {
ContentView()
ContentView()
.padding()
}
}
}
This might be a layout bug in SwiftUI. There is clearly enough space to put it all in one line, even with the padding. You may want to file a bug report to Apple.
However, you can easily fix this by applying the fixedSize() modifier to your Picker:
Picker("Work Type", selection: selectedOption) {
// ...
}
.pickerStyle(.menu)
.fixedSize(horizontal: true, vertical: false)
This will make sure that the Picker doesn't concern itself with external sizing requirements, ignores the proposed with and just uses renders with its ideal size.
Please note that this might lead to unintended behavior. For example, when the user chooses a bigger font size in accessibility settings, the text in the Picker will never break and might be rendered off-screen. So make sure to think through all your use cases.
PS: You can also use a Menu instead of a Picker with .menu style. Of course, you won't get the selection behavior for free and have to do it manually, but it doesn't have this layout bug.
This seems like intended behavior to me. Because of the padding applied around the HStack (specifically at the horizontal edges) there is not enough space to render the form label on one line. If you were to change the size of the screen (for example using an iPad simulator or using landscape mode) the label would have enough room and be displayed on one line. I tested this code on an iPad simulator and it worked as expected.
If you want to force the label text to be rendered on one line, use fixedSize(horizontal:vertical:). Though this can lead to unexpected behavior.
Picker("Work Type", selection: selectedOption) {
ForEach(self.options, id: \.self) {
Label($0, systemImage: "pencil.tip.crop.circle")
}
}
.pickerStyle(.menu)
.fixedSize(horizontal: true, vertical: false)

Why does SwiftUI View background extend into safe area?

Here's a view that navigates to a 2nd view:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink {
SecondView()
} label: {
Text("Go to 2nd view")
}
}
}
}
struct SecondView: View {
var body: some View {
ZStack {
Color.red
VStack {
Text("This is a test")
.background(.green)
//Spacer() // <--If you add this, it pushes the Text to the top, but its background no longer respects the safe area--why is this?
}
}
}
}
On the 2nd screen, the green background of the Text view only extends to its border, which makes sense because the Text is a pull-in view:
Now, if you add Spacer() after the Text(), the Text view pushes to the top of the VStack, which makes sense, but why does its green background suddenly push into the safe area?
In the Apple documentation here, it says:
By default, SwiftUI sizes and positions views to avoid system defined
safe areas to ensure that system content or the edges of the device
won’t obstruct your views. If your design calls for the background to
extend to the screen edges, use the ignoresSafeArea(_:edges:) modifier
to override the default.
However, in this case, I'm not using ignoresSafeArea, so why is it acting as if I did, rather than perform the default like Apple says, which is to avoid the safe areas?
You think that you use old background modifier version.
But actually, the above code uses a new one, introduced in iOS 15 version of background modifier which by default ignores all safe area edges:
func background<S>(_ style: S, ignoresSafeAreaEdges edges: Edge.Set = .all) -> some View where S : ShapeStyle
To fix the issue just replace .background(.green) with .background(Color.green) if your app deployment target < iOS 15.0, otherwise use a new modifier: .background { Color.green }.

Docked Sidebar on iPad in portrait orientation with SwiftUI

SwiftUI helpfully gives you NavigationView which easily lets you define a sidebar and main content for iPad apps that automatically collapse for iPhones.
I have an app and everything works as expected except on iPad, in portrait mode, the sidebar is hidden by default and you are forced click a button to show it.
All I want is to force the sidebar to always be visible, even in portrait mode. And make it work the same way as the settings app.
I’m even willing to use a UIKit view wrapped for SwiftUI, but wrapping NavigationController seems very very challenging.
Is there a better way?
Without your specific code I can't be sure but I think you are describing the initial screen that shows on iPad. That is actually a "ThirdView". You can see it with the code below.
And I am answering the "better way" portion of your question. Which for me is a way of just "graciously" dealing with it.
struct VisibleSideBar2: View {
var body: some View {
NavigationView{
List(0..<10){ idx in
NavigationLink("SideBar \(idx)", destination: Text("Secondary \(idx)"))
}
Text("Welcome Screen")
}
}
}
It is even more apparent if you have a default selection in your "Secondary View" because now you have to click "Back" twice to get to the SideBar
struct VisibleSideBar1: View {
#State var selection: Int? = 1
var body: some View {
NavigationView{
List(0..<10){ idx in
NavigationLink(
destination: Text("Secondary \(idx)"),
tag: idx,
selection: $selection,
label: {Text("SideBar \(idx)")})
}
Text("Third View")
}
}
}
A lot of the "solutions" out there for this just turn the NavigationView into a Stack but then you can't get the double column.
One way of dealing with it is the what is depicted in VisibleSideBar2. You can make/embrace a nice "Welcome Screen" so the user isn't greeted with a blank screen and then the natural navigation instincts kick in. You only see the "Welcome Screen" on iPad Portrait and on Catalyst/MacOS where Stack is unavailable.
Or you can bypass the third screen by using isActive in a NavigationLink and using the Sidebar as a menu like View
struct VisibleSidebar3: View{
#State var mainIsPresented = true
var body: some View {
NavigationView {
ScrollView{
NavigationLink(
destination: Text("Main View").navigationTitle("Main"),
isActive: $mainIsPresented,
label: {
Text("Main View")
})
NavigationLink("List View", destination: ListView())
}.navigationTitle("Sidebar")
//Not visible anymore
Text("Welcome Screen")
}
}
}
struct ListView: View{
var body: some View {
List(0..<10){ idx in
NavigationLink("SideBar \(idx)", destination: Text("Secondary \(idx)"))
}.navigationTitle("List")
}
}
Like I said my answer isn't really a way of "fixing" the issue. Just dealing with it. To fix it we would have to somehow dismiss the "Third Screen/Welcome Screen". Then manipulate the remaining UISplitViewController (Several SO questions on this) to show both the SideBar/Master and the Detail View.
In UIKit it seems to have been done a lot, if you search SO, you will find a way to create a UISplitViewController that behaves like Settings.

SwiftUI Navigation on iPad - How to show master list

My app has simple navigation needs
List View (parent objects)
List View (child objects)
Detail View (child object)
I have this setup and working on iPhone, but when I run the app on iPad in portrait mode the master list is always hidden.
I'm using .isDetailLink(false) on the navigation link from the first list to the second, so both lists always stay in the master column. In iPad landscape everything works as expected but in portrait the detail view fills the screen. I can swipe in from the left side of the screen to show the list but I'd like to provide more clarity to the user.
I'd like to show or add the back button to show the master/list side (sort of like the Apple Notes app). On the iPhone I get the back button by default but on iPad in portrait mode there is nothing in its place.
This is what I see on iPhone
But this is what I see on iPad
Parent list
struct ParentList: View {
let firstList = ["Sample data 01", "Sample data 02", "Sample data 03", "Sample data 04", "Sample data 05"]
var body: some View {
NavigationView {
List{
ForEach(firstList, id: \.self) { item in
NavigationLink(destination: ChildList()){
Text(item)
}
.isDetailLink(false)
}
}
}
}
}
Child list
struct ChildList: View {
let secondList = ["More Sample data 01", "More Sample data 02", "More Sample data 03", "More Sample data 04", "More Sample data 05"]
var body: some View {
List{
ForEach(secondList, id: \.self) { item in
NavigationLink(destination: ChildDetail()){
Text(item)
}
}
}
}
}
Child detail
struct ChildDetail: View {
var body: some View {
Text("Child detail view")
}
}
Update: As of Oct 17, 2019 I have not found a way to get this to work. I decided to use .navigationViewStyle(StackNavigationViewStyle()) for the time being. Interestingly, this needs to go outside of the navigation view like a normal modifier, not inside it with the navigation title.
In portrait the default split view does not work. This may be fixed in future but it appears the current options are:
(a) change the navigation view style of your first list to .navigationViewStyle(StackNavigationViewStyle()) so the navigation will work like on iPhone and push each view.
(b) leave the style to default and only support landscape for iPad
(c) implement a UIKit split view controller
There also is a quite hacky workaround (see https://stackoverflow.com/a/57215664/3187762)
By adding .padding() to the NavigationView it seems to achieve the behaviour of always display the Master.
NavigationView {
MyMasterView()
DetailsView()
}.navigationViewStyle(DoubleColumnNavigationViewStyle())
.padding()
Not sure if it is intended though. Might break in the future (works using Xcode 11.0, in simulator on iOS 13.0 and device with 13.1.2).
You should not rely on it. This comment seems to be the better answer: https://stackoverflow.com/a/57919024/3187762
in iOS 13.4, a "back to master view" button has been added to the iPad layout. From the release notes:
When using a NavigationView with multiple columns, the navigation bar now shows a control to toggle the columns. (49074511)
For example:
struct MyNavView: View {
var body: some View {
NavigationView {
NavigationLink(
destination: Text("navigated"),
label: {Text("Go somwhere")}
)
.navigationBarTitle("Pick Item")
}
}
}
Has the following result:
Look this solution here
I hope this help
For my project I'm using this extension.
They will always use StackNavigationViewStyle for iPhone, iPad in a vertical orientation, and if you provide forceStackedStyle: true.
Otherwise DoubleColumnNavigationViewStyle will be used.
var body: some View {
NavigationView {
Text("Hello world")
}
.resolveNavigationViewStyle(forceStackedStyle: false)
}
extension View {
func resolveNavigationViewStyle(forceStackedStyle: Bool) -> some View {
if forceStackedStyle || UIDevice.current.userInterfaceIdiom == .phone {
return self.navigationViewStyle(StackNavigationViewStyle())
.eraseToAnyView()
} else {
return GeometryReader { p in
if p.size.height > p.size.width { self.navigationViewStyle(StackNavigationViewStyle())
} else {
self.navigationViewStyle(DoubleColumnNavigationViewStyle())
}
}
.eraseToAnyView()
}
}
}