ScrollView + NavigationView animation glitch SwiftUI - swift

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.

Related

ScrollViewReader doesn't scroll to the correct position in a 2D ScrollView

Here is a simple SwiftUI code that builds a large grid which is put inside a 2D ScrollView. Clicking each item will force the ScrollView to scroll to another item whose id is 50 more than the clicked item's. The ScrollView did scroll but it never scrolled to the correct one. I tried it on macOS and iOS, and neither worked. I also tried using VStack and HStack instead of LazyVGrid, and still it didn't work. What is the correct way to achieve this?
struct ContentView: View {
let columns = Array.init(repeating: GridItem(.fixed(80)), count: 20)
var body: some View {
ScrollView([.horizontal, .vertical]) {
ScrollViewReader { proxy in
LazyVGrid(columns: columns, spacing: 20) {
ForEach(0..<500) { i in
ZStack {
Circle()
.fill(Color.green)
.frame(width: 30, height: 30)
Text("\(i)")
}
.id("\(i)")
.onTapGesture {
proxy.scrollTo("\(i + 50)")
}
}
}
.padding(.horizontal)
}
}
.frame(minWidth: 500, minHeight: 300)
}
}

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.

Center Item Inside Horizontal Stack

If I have 3 items inside a Horizontal Stack, I thought I could do something like this:
HStack{
Text("test")
Spacer()
item2()
Spacer()
Text("test")
}
to center item2() in between the two Text views. However, the problem with this is that item2() isn't necessarily always centered, because, lets say Text("test") changes to Text("a") or something. This causes problems, and the second item isn't always centered on the screen.
How can I make it so item2() is always centered?
Thanks
I would propose the following start point (simplest case... read below why)
As it's seen it really gives centred w/o frame shift with correctly aligned side elements, but ... there is drawback - it will work in such simplest variant only if it is known in advance that those three text elements should never overlap in user run-time. If it is the case (really there are such) then this approach just goes. However if left/right text might grow in run-time, then more calculations will be needed to limit their width by .frame(maxWidth:) depending on the width of centred element... that variant is more complicated, but it is feasible.
var body: some View {
ZStack {
HStack {
Text("Longer side")
Spacer()
Text("One")
}
item2()
}
}
private func item2() -> some View {
Text("CENTER")
.background(Color.yellow)
.border(Color.red)
}
Update: here is possible approach to limit one of the side to not overlap centred one (contains async updates, so should be tested in Live Preview or Simulator)
So... if left text is dynamic and the requirement to cut trailing symbols, here is how it could go ...
and it automatically fit well on device orientation change
struct TestHorizontalPinCenter: View {
#State var centerFrame: CGRect = .zero
private let kSpacing: CGFloat = 4.0
var body: some View {
ZStack {
HStack {
Text("Longer side very long text to fit")
.lineLimit(1)
.frame(maxWidth: (centerFrame == .zero ? .infinity : centerFrame.minX - kSpacing), alignment: .leading)
Spacer()
Text("One")
}
item2()
.background(rectReader($centerFrame))
}
}
private func item2() -> some View {
Text("CENTER")
.background(Color.yellow)
.border(Color.red)
}
func rectReader(_ binding: Binding<CGRect>) -> some View {
return GeometryReader { (geometry) -> AnyView in
let rect = geometry.frame(in: .global)
DispatchQueue.main.async {
binding.wrappedValue = rect
}
return AnyView(Rectangle().fill(Color.clear))
}
}
}
And if it is needed to wrap left side, then .lineLimit(nil) and additional layout will be needed, and solution growth, but the idea is the same. Hope this will be helpful for someone.
I had the same problem and the solution from #Asperi works, but i had problems with multiline texts and some performance issues if i use it in a list.
The following solution solved all the problems.
HStack(alignment: .center) {
Text("test")
.frame(maxWidth: .infinity)
item2()
Text("test")
.frame(maxWidth: .infinity)
}
To center views you can use a ZStack:
ZStack {
item2()
HStack {
Text("test")
Spacer()
Text("test")
}
}
You may need to add some customized Alignment components.
extension HorizontalAlignment {
private enum MyHAlignment: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[HorizontalAlignment.center]
}
}
static let myhAlignment = HorizontalAlignment(MyHAlignment.self)
}
HStack {
Spacer()
Text("jjjjjjjjjj")
Spacer()
Image("image").alignmentGuide(.myhAlignment) { (ViewDimensions) -> CGFloat in
return ViewDimensions[HorizontalAlignment.center]
}
Spacer()
Text("test")
}
.frame(alignment: Alignment(horizontal: .myhAlignment, vertical: .center))

SwiftUI - Using GeometryReader Without Modifying The View Size

I have a header view which extends its background to be under the status bar using edgesIgnoringSafeArea. To align the content/subviews of the header view correctly, I need the safeAreaInsets from GeometryReader. However, when using GeometryReader, my view doesn't have a fitted size anymore.
Code without using GeometryReader
struct MyView : View {
var body: some View {
VStack(alignment: .leading) {
CustomView()
}
.padding(.horizontal)
.padding(.bottom, 64)
.background(Color.blue)
}
}
Preview
Code using GeometryReader
struct MyView : View {
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading) {
CustomView()
}
.padding(.horizontal)
.padding(.top, geometry.safeAreaInsets.top)
.padding(.bottom, 64)
.background(Color.blue)
.fixedSize()
}
}
}
Preview
Is there a way to use GeometryReader without modifying the underlying view size?
Answer to the question in the title:
It is possible to wrap the GeometryReader in an .overlay() or .background(). Doing so will mitigate the layout changing effect of GeometryReader. The view will be laid out as normal, the GeometryReader will expand to the full size of the view and emit the geometry into its content builder closure.
It's also possible to set the frame of the GeometryReader to stop its eagerness in expanding.
For example, this example renders a blue rectangle, and a "Hello world" text inside at 3/4th the height of the rectangle (instead of the rectangle filling up all available space) by wrapping the GeometryReader in an overlay:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.overlay(GeometryReader { geo in
Text("Hello world").padding(.top, geo.size.height * 3 / 4)
})
Spacer()
}
}
Another example to achieve the same effect by setting the frame on the GeometryReader:
struct MyView : View {
var body: some View {
GeometryReader { geo in
Rectangle().fill(Color.blue)
Text("Hello world").padding(.top, geo.size.height * 3 / 4)
}
.frame(height: 150)
Spacer()
}
}
However, there are caveats / not very obvious behaviors
1
View modifiers apply to anything up to the point that they are applied, and not to anything after. An overlay / background that is added after .edgesIgnoringSafeArea(.all) will respect the safe area (not participate in ignoring the safe area).
This code renders "Hello world" inside the safe area, while the blue rectangle ignores the safe area:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.edgesIgnoringSafeArea(.all)
.overlay(VStack {
Text("Hello world")
Spacer()
})
Spacer()
}
}
2
Applying .edgesIgnoringSafeArea(.all) to the background makes GeometryReader ignore the SafeArea:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.overlay(GeometryReader { geo in
VStack {
Text("Hello world")
// No effect, safe area is set to be ignored.
.padding(.top, geo.safeAreaInsets.top)
Spacer()
}
})
.edgesIgnoringSafeArea(.all)
Spacer()
}
}
It is possible to compose many layouts by adding multiple overlays / backgrounds.
3
A measured geometry will be available to the content of the GeometryReader. Not to parent or sibling views; even if the values are extracted into a State or ObservableObject. SwiftUI will emit a runtime warning if that happens:
struct MyView : View {
#State private var safeAreaInsets = EdgeInsets()
var body: some View {
Text("Hello world")
.edgesIgnoringSafeArea(.all)
.background(GeometryReader(content: set(geometry:)))
.padding(.top, safeAreaInsets.top)
Spacer()
}
private func set(geometry: GeometryProxy) -> some View {
self.safeAreaInsets = geometry.safeAreaInsets
return Color.blue
}
}
I tried with the previewLayout and I see what you mean. However, I think the behavior is as expected. The definition of .sizeThatFits is:
Fit the container (A) to the size of the preview (B) when offered the
size of the device (C) on which the preview is running.
I intercalated some letters to define each part and make it more clear:
A = the final size of the preview.
B = The size of what you are modifying with .previewLayout(). In the first case, it's the VStack. But in the second case, it's the GeometryReader.
C = The size of the screen of the device.
Both views act differently, because VStack is not greedy, and only takes what it needs. GeometryReader, on the other side, tries to have it all, because it does not know what its child will want to use. If the child wants to use less, it can do it, but it has to start by being offered everything.
Perhaps if you edit your question to explain exactly what you would like to accomplish, I can refine my answer a little.
If you would like GeometryReader to report the size of the VStack. you may do so by putting it inside a .background modifier. But again, I am not sure what's the goal, so maybe that's a no go.
I have written an article about the different uses of GeometryReader. Here's the link, in case it helps: https://swiftui-lab.com/geometryreader-to-the-rescue/
UPDATE
Ok, with your additional explanation, here you have a working solution. Note that the Preview will not work, because safeInsets are reported as zero. On the simulator, however, it works fine:
As you will see, I use view preferences. They are not explained anywhere, but I am currently writing an article about them that I will post soon.
It may all look too verbose, but if you find yourself using it too often, you can encapsulate it inside a custom modifier.
import SwiftUI
struct InsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct InsetGetter: View {
var body: some View {
GeometryReader { geometry in
return Rectangle().preference(key: InsetPreferenceKey.self, value: geometry.safeAreaInsets.top)
}
}
}
struct ContentView : View {
var body: some View {
MyView()
}
}
struct MyView : View {
#State private var topInset: CGFloat = 0
var body: some View {
VStack {
CustomView(inset: topInset)
.padding(.horizontal)
.padding(.bottom, 64)
.padding(.top, topInset)
.background(Color.blue)
.background(InsetGetter())
.edgesIgnoringSafeArea(.all)
.onPreferenceChange(InsetPreferenceKey.self) { self.topInset = $0 }
Spacer()
}
}
}
struct CustomView: View {
let inset: CGFloat
var body: some View {
VStack {
HStack {
Text("C \(inset)").color(.white).fontWeight(.bold).font(.title)
Spacer()
}
HStack {
Text("A").color(.white)
Text("B").color(.white)
Spacer()
}
}
}
}
I managed to solve this by wrapping the page main view inside a GeometryReader and pass down the safeAreaInsets to MyView. Since it is the main page view where we want the entire screen thus it is ok to be as greedy as possible.

Make ScrollView content fill its parent in SwiftUI

I'd love to build a scrolling screen, so I wanted to embed it ScrollView. But I am not able to achieve it the view just shrinks to its compressed size. Let's say that I want the ScrollView to scroll vertically so I'd love the content to match scrollView's width. So I use such preview:
struct ScrollSubview_Previews : PreviewProvider {
static var previews: some View {
func textfield() -> some View {
TextField(.constant("Text")).background(Color.red)
}
return Group {
textfield()
ScrollView {
textfield()
}
}
}
}
But it ends with result like this:
A simple way for you, using frame(maxWidth: .infinity)
ScrollView(.vertical) {
VStack {
ForEach(0..<100) {
Text("Item \($0)")
}
}
.frame(maxWidth: .infinity)
}
Here is a trick: introduce a HStack that only has one Spacer:
return Group {
HStack {
Spacer()
}
textfield()
ScrollView {
textfield()
}
}
Actually you don't need GeometryReader anymore. ScrollView has been refactored in Xcode beta 3. Now you can declare that you have a .horizontal or .vertical ScrollView.
This makes the ScrollView behave like it should, like any normal View protocol.
Ex:
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach((1...10).reversed()) {
AnyViewYouWant(number: $0)
}
}
}
This will result in a view with the width of its parent scrolling horizontally. The height will be defined by the ScrollView's subviews height.
It's a known issue existing also in beta 2 - check the Apple's Xcode 11 release notes here ⬇️
https://developer.apple.com/documentation/xcode_release_notes/xcode_11_beta_2_release_notes.
The workaround mentioned by Apple themselves is to set fixed frame for ScrollView inside element. In that case I suggest to use GeometryReader for fix screen width, height is automatically fit to content.
For example if you need to fit the ScrollView's content to screen width you can something like:
return GeometryReader { geometry in
Group {
self.textfield()
ScrollView {
self.textfield()
.frame(width: geometry.size.width)
}
}
}
You can also done this using extra HStack and space.
ScrollView(.vertical) {
HStack {
VStack {
ForEach(0..<100) {
Text("Item \($0)")
}
}
Spacer()
}
}
Scroll view content will expand to available size.
GeometryReader { geo in
ScrollView(.vertical) {
YourView().frame(
minWidth: geo.size.width,
minHeight: geo.size.height
)
}
}
PreviewProvider is just about your Canvas (rendering view).
if you want to place text into ScrollView you should create your View
struct MyViewWithScroll : View {
var body: some View {
ScrollView {
Text("Placeholder")
}
}
}
And after that, render your MyViewWithScroll in Canvas (window with iPhone from the right side of code editor)
#if DEBUG
struct MyViewWithScroll_Previews : PreviewProvider {
static var previews: some View {
MyViewWithScroll()
}
}
#endif