ScrollView + NavigationView 动画故障 SwiftUI

24

我有一个简单的观点:

var body: some View {
        NavigationView {
            ScrollView {
                VStack {
                    ForEach(0..<2) { _ in
                        CardVew(for: cardData)
                    }
                }
            }
            .navigationBarTitle("Testing", displayMode: .automatic)
        }
    }

但你可以用任何东西替换CardView-故障仍然存在。故障视频

有没有办法修复它?

Xcode 12.0.1,Swift 5

5个回答

32

将顶部填充设置为1会破坏至少2个重要的事情:

  1. 滚动视图不会延伸到NavigationView和TabView下方,这使得它失去了在栏下滚动的内容的美丽模糊效果。
  2. 在滚动视图上设置背景将导致大标题NavigationView停止折叠。

当我不得不更改我正在工作的应用程序的所有屏幕的背景颜色时,我遇到了这些问题。因此,我进行了更多的挖掘和实验,并设法找到了一个相当好的解决方案。

以下是原始解决方案:

我们将ScrollView包装成2个几何读取器。

顶部的几何读取器尊重安全区域——我们需要它才能读取安全区域插图。 第二个则是全屏的。

我们将滚动视图放入第二个几何读取器中,使其大小调整为全屏。

然后,我们使用VStack添加内容,通过应用安全区域填充。

最后,我们拥有了一个滚动视图,它不会闪烁并接受背景,而不会破坏导航栏的大标题。

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

优雅的解决方案

既然解决方案已经清晰明了 - 让我们创建一个优雅的解决方案,可以通过替换填充修复来重用并应用于任何现有的ScrollView。

我们创建了一个ScrollView的扩展,声明了fixFlickering函数。

逻辑基本上是将接收器包装到几何读取器中,并使用安全区域填充将其内容包装到VStack中 - 就是这样。

ScrollView被使用,因为编译器错误地推断嵌套滚动视图的内容应该与接收器相同。显式声明AnyView将使其接受包装的内容。

有两个重载:

  • 第一个不接受任何参数,您只需在任何现有滚动视图上调用它即可,例如,您可以用.fixFlickering()替换.padding(.top, 1) - 就是这样。
  • 第二个接受一个配置器闭包,用于让您有机会设置嵌套滚动视图。这是必需的,因为我们不使用接收器,只是将其包装起来,但是我们创建了一个ScrollView的新实例,仅使用接收器的配置和内容。在此闭包中,您可以以任何您想要的方式修改提供的ScrollView,例如设置背景颜色。
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)
        }
    }
}

示例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"))
        }
    }
}

例子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"))
        }
    }
}

4
非常感谢你提供如此详细的解释,我真的非常感激。 - belotserkovtsev
1
这几乎完美,除了它不更新 scrollIndicatorInsets!不幸的是,我想知道你是否需要 UIKit 才能真正做到这一点。 - Coder-256
很遗憾,它不能与 Stack V Lazy 的 pinnedView 一起使用。 - Jacek Grygiel
1
很好的解释,但由于 AnyView 的原因,我不会实现它。 - Martin
能否更新ScrollView指示器,因为指示器扩展到顶部,看起来不正确。 - sheldor

18

这里有一个解决方法。在ScrollView中添加.padding(.top, 1):

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

2
@PeterFriese,我开始在scrollView上方放置了一个额外的视图,认为scrollView和navigationBar之间存在交互。这起作用了。然后我减小了该视图的大小,发现它可以非常短,仍然可以工作。由于这个视图充当填充,我决定尝试用填充替换它,并发现也可以工作。因此,这个公式是直觉和尝试的结合。 - vacawama
1
@PeterFriese,如果您查看编辑历史记录,您将看到我的第一个解决方案,其中包含额外的视图。 - vacawama
1
真是疯狂,经过两年的SwiftUI之后我们仍然需要这些解决方法!不管怎样,谢谢你! - Rico Crescenzio
1
顺便说一下,这会破坏ScrollView,在扩展内容时会超出导航视图下方。 - KoCMoHaBTa
非常感谢!不再需要使用NavigationView来解决大标题时的闪烁问题了! - finalpets
显示剩余2条评论

6

我简化了@KoCMoHaBTa的回答。这是它:

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

使用方法如下:

struct ContentView: View {
    var body: some View {
        NavigationView {
            ScrollView {
                /* ... */
            }
            .fixFlickering()
        }
    }
}

我了解了 _PaddingLayout - 这个有文档吗?这似乎更优雅,但也更脆弱。 - steipete
@steipete 我通常通过打印 SwiftUI 视图并阅读其生成的类型来找到这样的东西。我没有在任何地方找到它的文档记录。如果您感兴趣,我还发现了 Xcode 自动完成初始化程序,例如 AnyView(_fromValue:),我认为这些是私有的。 - George
@steipete 尽管这个解决方案很“健壮”,但我不确定使用 _PaddingLayout 这样的东西是否算作“私有 API”。毕竟,类型通常会被推断出来。如果你像我一样有点不确定,可以创建一个类似于 struct ContentAcceptingScrollView<Content: View> { ... } 的东西,在 body 中只是创建了一个 ScrollView,但内容类型是推断出来的,所以我们不需要这种类型转换。 - George

3

在遇到相同问题时,我进行了一些调查。该故障似乎由scrollview反弹和滚动上下文的减速速度组合而成。

目前,我已经通过将减速率设置为快速来使故障消失。它似乎让SwiftUI更好地计算布局,同时保持弹跳动画活动。

我的解决方法很简单,只需在视图的init中设置以下内容。缺点是这会影响您的滚动减速速度。

 init() {
    UIScrollView.appearance().decelerationRate = .fast
}

一种可能的改进是计算显示内容的大小,然后根据需要实时切换减速率。

0
这是对我有效的解决方案。
ScrollView {
    LazyVStack {
        Rectangle().foregroundColor(.clear).frame(height: 1.0)
        //Your Content
    }
}

在实际设备 iPhone 6S iOS 15.7 上进行了测试。

iPhone 15 Pro iOS 17.0 模拟器

iPad iOS 17.0 模拟器

对我来说,问题是由于行高是可变的而不是固定的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接