SwiftUI - Adjust ScrollView scroll indicator insets - swift

I am creating a chat app and I want the content of the ScrollView to go beneath the input field (while scrolling up). I already put the ScrollView and the input field in a ZStack. Bottom padding on the ScrollView puts the content up, but I also want the scroll indicator to move up with the content.
Is there any way to change the insets of the scroll indicator to match the padding, or any other workaround to achieve what I'm looking for?
Here's the current code:
ZStack(alignment: .bottom) {
ScrollView {
ScrollViewReader { value in
VStack(spacing: 5) {
ForEach(MOCK_MESSAGES) {
mMessage in
MessageView(mMessage: mMessage)
}
.onAppear {
value.scrollTo(MOCK_MESSAGES.count - 1)
}
}
.padding(.top, 10)
.padding(.bottom, 40)
}
}
MessageInputView(messageText: $messageText)
}
Summing up: The idea is to have the ScrollView to be above the input view, but move the content underneath input view when scrolling up.

iOS 15 / Swift 3
You could do this with .safeAreaInset instead of padding, then it handles the scroll indicator insets for you as well.
ScrollView {
ScrollViewReader { value in
VStack(spacing: 5) {
// stuff
}
}
}
.safeAreaInset(edge: .bottom) {
MessageInputView(messageText: $messageText)
}
Note: .safeAreaInsets doesn't seem to be working on List as of Xcode 13.1.
UPDATE: As of iOS 15.2, safeAreaInsets() works with List and Form as well.
iOS 14 / Swift 2 or with List
Like many things in SwiftUI, there doesn't seem to be a to do it without tinkering with the underlying UIKit components. There's an easy solution for this one though, using the Introspect library:
ZStack(alignment: .bottom) {
ScrollView {
ScrollViewReader { value in
VStack(spacing: 5) {
// stuff
}
.padding(.top, 10)
.padding(.bottom, 40)
}
}
.introspectScrollView { sv in
sv.verticalScrollIndicatorInsets.top = 10
sv.verticalScrollIndicatorInsets.bottom = 40
}
MessageInputView(messageText: $messageText)
}
You could also do it with a List with introspectTableView.

Related

SwiftUI Positioning View [duplicate]

I've seen similar questions like this one here on Stack Overflow, but none of them have been able to answer my question.
I have created a List, but I want to remove the padding space (marked with a red arrow) above the content in the List. How can I remove it when using a GroupedListStyle?
This is a List within a VStack within a NavigationView that is displayed via fullscreenCover:
var body: some View {
NavigationView {
VStack {
taskEventPicker
myList
}
.navigationBarTitle("Add Task", displayMode: .inline)
}
}
where taskEventPicker is a segmented Picker (boxed in green):
var taskEventPicker: some View {
Picker("Mode", selection: $selection) { /* ... */ }
.pickerStyle(SegmentedPickerStyle())
.padding()
}
and myList is the form in question (boxed in yellow):
var myList: some View {
List { /* ... */ }
.listStyle(GroupedListStyle())
}
What I've Tried
Removing the header of the list
Hiding the Navigation Bar title
Setting UITableView.appearance().tableHeaderView to an empty frame
Attempting UITableView.appearance().contentInset.top = -35
Setting listRowInsets (this affects all list rows)
Note: I'm looking for an answer that can apply to the GroupedListStyle. I understand that this issue does not occur with PlainListStyle. This padding issue also occurs with the default listStyle.
Thanks for the help!
Xcode version: 12.5
Firstly, I would say that GroupedListStyle is working as intended.
On iOS, the grouped list style displays a larger header and footer
than the plain style, which visually distances the members of
different sections.
You say you have tried this, but it does work for me (Xcode 12.5.1):
List { ... }
.onAppear(perform: {
UITableView.appearance().contentInset.top = -35
})
You could also hide the list header, by using a ZStack with the List at the bottom of the stack and the Picker over the top. The Picker does have transparency, so you would also have to add an opaque view to act as background for the Picker.
var body: some View {
NavigationView {
ZStack(alignment: .top) {
List { ... }
.listStyle(.grouped)
.padding(.top, 30)
Color.white
.frame(height: 65)
Picker { ... }
.pickerStyle(.segmented)
.padding()
}
.navigationBarTitle("Add Task", displayMode: .inline)
}
}
As far as I can see this just appears the same as PlainListStyle would do, but I assume you have a particular reason for wanting to use GroupedListStyle.
The contentInsent part of the accepted answer works perfectly fine unless for instance as in my case, at the end of a navigation stack you want to use it in some detail view containing a List with navigationBarTitleDisplayMode set to .inline. When navigating back in the stack to a view using another List with navigationBarTitleDisplayMode set to .large, the List content and NavigationBar interlace awfully. And they probably might as well do whatever the navigationBarTitleDisplayMode is set to.
Switching to listStyle(.plain) in my case isn't a feasable option, because then in the detail view, I loose the beautifully animated built-in transitions to and from List's EditMode, that seem to exist only in the grouped listStyle varieties.
Finally, after quite a few days of frustration I figured out that tableHeaderView is the approach that works best for my problem - and of course, it works just as well to solve the original question. It's all in here ...:
XCode documentation
... and in this sentence:
"When assigning a view to this property [i.e. tableHeaderView], set the height of that view to a nonzero value"
So I do like:
List {
// bla bla bla
}
.listStyle(.grouped)
.onAppear {
let tableHeaderView = UIView(frame: .zero)
tableHeaderView.frame.size.height = 1
UITableView.appearance().tableHeaderView = tableHeaderView
}
It's as simple as that. Hope it might help you, too!
Give a header to the first section in your list: Section(header: Spacer(minLength: 0))
Disclaimer: this doesn't totally remove the top spacing, but it does yield the same result as the native Settings app.
Note: This worked for me on iOS 14.5
VStack {
List {
Section(header: Spacer(minLength: 0)) {
Text(verbatim: "First Section")
}
Section {
Text(verbatim: "Second Section")
}
}
.listStyle(GroupedListStyle())
}
Another option is to add
List { ... }.offset(x: 0, y: -30).edgesIgnoringSafeArea(.bottom)
to the List. Since you won't be able to scroll all the way up when using this add a Spacer(minLength: 30) at the very bottom of the List.
I had a similar setup where I had some views and a List in a VStack and I had an undesired space that appeared on the top of my List. In my case I needed to set the VStack spacing to 0 and that solved the issue because it was actually just spacing from the VStack.
VStack(spacing: 0) { ... }
my 2 cents. I arrived here for the same reason.
take a look at:
https://developer.apple.com/forums/thread/662544
it' seems the correct approach.
extension View {
/// Clear tableview and cell color
func tableClearColor() {
let tableHeaderView = UIView(frame: .zero)
tableHeaderView.frame.size.height = 0.5
UITableView.appearance().tableHeaderView = tableHeaderView
}
}

Why won't a nested scrollview respond to scrolls in swiftUI?

I'm building an SwiftUI app with a dropdown menu with a vertical ScrollView within another vertical ScrollView. However, the dropdown menu one (the nested one) won't scroll. I would like to give it priority somehow. It seems like a simple problem, but I have scoured the internet but cannot find an adequate solution. Here is the basic code for the problem (the code is cleaner in the app but copy and pasting particular snippets did not work very well):
ScrollView{
VStack{
(other stuff)
DropdownSelector()
(other stuff)
}
}
struct DropdownSelector(){
ScrollView{
VStack(alignment: .leading, spacing: 0) {
ForEach(self.options, id: \.self) { option in
(do things with the option)
}
}
}
Creating nested ScrollViews in the first place is probably a bad idea. Nonetheless, there is a solution.
Because with ScrollView it scrolls as much as the content height, this is a problem when they are nested. This is because the inner ScrollView isn't limited in height (because the outer ScrollView height just changes), so it acts as if it wasn't there at all.
Here is a minimal example demonstrating the problem, just for comparison:
struct ContentView: View {
var body: some View {
ScrollView {
VStack {
Text("Top view")
DropdownSelector()
Text("Bottom view")
}
}
}
}
struct DropdownSelector: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(0 ..< 10) { i in
Text("Item: \(i)")
}
}
}
}
}
To fix it, limit the height of the inner scroll view. Add this after DropdownSelector():
.frame(height: 100)

ScrollView + NavigationView animation glitch SwiftUI

I've got a simple view:
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(0..<2) { _ in
CardVew(for: cardData)
}
}
}
.navigationBarTitle("Testing", displayMode: .automatic)
}
}
But you can replace the CardView with anything - the glitch presists. Glitch video
Is there a way to fix it?
Xcode 12.0.1, Swift 5
Setting the top padding to 1 is breaking at least 2 major things:
The scroll view does not extend under NavigationView and TabView - this making it loose the beautiful blur effect of the content that scrolls under the bars.
Setting background on the scroll view will cause Large Title NavigationView to stop collapsing.
I've encountered these issues when i had to change the background color on all screens of the app i was working on.
So i did a little bit more digging and experimenting and managed to figure out a pretty nice solution to the problem.
Here is the raw solution:
We wrap the ScrollView into 2 geometry readers.
The top one is respecting the safe area - we need this one in order to read the safe area insets
The second is going full screen.
We put the scroll view into the second geometry reader - making it size to full screen.
Then we add the content using VStack, by applying safe area paddings.
At the end - we have scroll view that does not flicker and accepts background without breaking the large title of the navigation bar.
struct ContentView: View {
var body: some View {
NavigationView {
GeometryReader { geometryWithSafeArea in
GeometryReader { geometry in
ScrollView {
VStack {
Color.red.frame(width: 100, height: 100, alignment: .center)
ForEach(0..<5) { i in
Text("\(i)")
.frame(maxWidth: .infinity)
.background(Color.green)
Spacer()
}
Color.red.frame(width: 100, height: 100, alignment: .center)
}
.padding(.top, geometryWithSafeArea.safeAreaInsets.top)
.padding(.bottom, geometryWithSafeArea.safeAreaInsets.bottom)
.padding(.leading, geometryWithSafeArea.safeAreaInsets.leading)
.padding(.trailing, geometryWithSafeArea.safeAreaInsets.trailing)
}
.background(Color.yellow)
}
.edgesIgnoringSafeArea(.all)
}
.navigationBarTitle(Text("Example"))
}
}
}
The elegant solution
Since the solution is clear now - lets create an elegant solution that can be reused and applied to any existing ScrollView by just replacing the padding fix.
We create an extension of ScrollView that declares the fixFlickering function.
The logic is basically we wrap the receiver into the geometry readers and wrap its content into the VStack with the safe area paddings - that's it.
The ScrollView is used, because the compiler incorrectly infers the Content of the nested scroll view as should being the same as the receiver. Declaring AnyView explicitly will make it accept the wrapped content.
There are 2 overloads:
the first one does not accept any arguments and you can just call it on any of your existing scroll views, eg. you can replace the .padding(.top, 1) with .fixFlickering() - thats it.
the second one accept a configurator closure, which is used to give you the chance to setup the nested scroll view. Thats needed because we don't use the receiver and just wrap it, but we create a new instance of ScrollView and use only the receiver's configuration and content. In this closure you can modify the provided ScrollView in any way you would like, eg. setting a background color.
extension ScrollView {
public func fixFlickering() -> some View {
return self.fixFlickering { (scrollView) in
return scrollView
}
}
public func fixFlickering<T: View>(#ViewBuilder configurator: #escaping (ScrollView<AnyView>) -> T) -> some View {
GeometryReader { geometryWithSafeArea in
GeometryReader { geometry in
configurator(
ScrollView<AnyView>(self.axes, showsIndicators: self.showsIndicators) {
AnyView(
VStack {
self.content
}
.padding(.top, geometryWithSafeArea.safeAreaInsets.top)
.padding(.bottom, geometryWithSafeArea.safeAreaInsets.bottom)
.padding(.leading, geometryWithSafeArea.safeAreaInsets.leading)
.padding(.trailing, geometryWithSafeArea.safeAreaInsets.trailing)
)
}
)
}
.edgesIgnoringSafeArea(.all)
}
}
}
Example 1
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
VStack {
Color.red.frame(width: 100, height: 100, alignment: .center)
ForEach(0..<5) { i in
Text("\(i)")
.frame(maxWidth: .infinity)
.background(Color.green)
Spacer()
}
Color.red.frame(width: 100, height: 100, alignment: .center)
}
}
.fixFlickering { scrollView in
scrollView
.background(Color.yellow)
}
.navigationBarTitle(Text("Example"))
}
}
}
Example 2
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
VStack {
Color.red.frame(width: 100, height: 100, alignment: .center)
ForEach(0..<5) { i in
Text("\(i)")
.frame(maxWidth: .infinity)
.background(Color.green)
Spacer()
}
Color.red.frame(width: 100, height: 100, alignment: .center)
}
}
.fixFlickering()
.navigationBarTitle(Text("Example"))
}
}
}
Here's a workaround. Add .padding(.top, 1) to the ScrollView:
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(0..<2) { _ in
Color.blue.frame(width: 350, height: 200)
}
}
}
.padding(.top, 1)
.navigationBarTitle("Testing", displayMode: .automatic)
}
}
}
I simplified #KoCMoHaBTa's answer. Here it is:
extension ScrollView {
private typealias PaddedContent = ModifiedContent<Content, _PaddingLayout>
func fixFlickering() -> some View {
GeometryReader { geo in
ScrollView<PaddedContent>(axes, showsIndicators: showsIndicators) {
content.padding(geo.safeAreaInsets) as! PaddedContent
}
.edgesIgnoringSafeArea(.all)
}
}
}
Use like so:
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
/* ... */
}
.fixFlickering()
}
}
}
while facing the same problem I did a bit of investigation.
The glitch seems to come from a combination of scrollview bouncing, and the speed of deceleration of the scrolling context.
For now I have managed to make the glitch disappear by settings the deceleration rate to fast. It seem to let swiftui better compute the layout while keeping the bounce animation active.
My work around is as simple as to set the following in the init of your view. The drawback its that this affects the speed of your scrolling deceleration.
init() {
UIScrollView.appearance().decelerationRate = .fast
}
A possible improvement would be to compute the size of the content being displayed and then switch on the fly the deceleration rate depending what would be needed.

How to avoid parent ScrollView to clip internal ScrollView?

When a horizontal child ScrollView is nested inside a vertical parent ScrollView, internal element is clipped by parent ScrollView.
Is there currently (Xcode 11.4) a way to change this behavior?
It's works as expected when ScrollView is not nested. (commented out the parent)
Red element is drawn over SafeArea.
Here is possible approach to consider.
However there is a drawback - bug seems in SwiftUI, on change orientation to portrait horizontal scrollview has got unexpected offset (internal, because by borders all external is ok). I haven't find workaround for this yet, but... anyway
Tested with Xcode 11.4 / iOS 13.4
var body: some View {
GeometryReader { gp in
ScrollView(showsIndicators: true) {
VStack {
ForEach(0..<3) { i in
ScrollView(.horizontal, showsIndicators: true) {
HStack {
ForEach(0..<10) { j in
Color.red.frame(width: 100, height: 100)
}
}
}.background(Color.blue)
}
}.frame(width: gp.size.width)
}.background(Color.green)
}.edgesIgnoringSafeArea(.horizontal)
}

SwiftUI - ScrollView has 0 width and my content is not visible

I am trying to use a scroll view for scrollable content, but whenever I nest my views inside the Scroll View, I have noticed that the views from my stacks vanish back into the view hierarchy and nothing remains visible on the screen. I have also seen that whenever I am using a ScrollView, it adds another Hosting View Controller and I don't know if this is the normal behaviour.
var body: some View {
NavigationView {
ScrollView(.vertical, showsIndicators: false) {
VStack {
ForEach(bookLibrary.indices, id: \.self) { index in
HStack {
ForEach(self.bookLibrary[index], id: \.self) { book in
BookView(book: book)
}
}
}
}
}
}
}
Getting this view hierarchy. You can also see that the HostingScrollView has a width of 0.
If you don't want to use GeometryReader just insert zero height view with correct width like this
var body: some View {
ScrollView {
VStack {
Color.clear
.frame(maxWidth: .infinity, maxHeight: 0)
ForEach(...) { each in
...
}
}
}
}
While not a perfect solution, you can use GeometryReader to set the scroll view's frame to the same width as its superview.
NavigationView {
GeometryReader {geometry in
ScrollView(.vertical) {
// TODO: Add content
}
.frame(width: geometry.size.width)
}
}
This workaround was inspired by Rob Mayoff's answer on another question.