我正在使用
TabView
构建 SwiftUI 布局,其中每个选项卡都包含一个为 SwiftUI 包装的 UIScrollView
。使用 UIScrollView
的原因是为了让 TabView
中的每个页面共享相同的滚动偏移量。这样,如果我在第一页上向下滚动,第二页将位于相同位置。
我的实现几乎完美地工作,除了第一次初始化
TabView
时。如果我在第一页上向下滚动,然后滑动到下一页,滚动偏移量将重置。然而,一旦所有页面至少被访问过一次,这个问题就不再发生,并且滚动偏移量在所有 TabView
页面之间共享。
这是我的
ScrollViewWrapper
:
import SwiftUI
public struct ScrollViewWrapper<Content: View>: UIViewRepresentable {
@Binding var contentOffset: CGPoint
let content: () -> Content
public init(
contentOffset: Binding<CGPoint>,
@ViewBuilder _ content: @escaping () -> Content) {
self._contentOffset = contentOffset
self.content = content
}
public func makeUIView(context: UIViewRepresentableContext<ScrollViewWrapper>) -> UIScrollView {
let view = UIScrollView()
view.delegate = context.coordinator
let controller = UIHostingController(rootView: content())
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.backgroundColor = .clear
view.addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
controller.view.topAnchor.constraint(equalTo: view.topAnchor),
controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
controller.view.widthAnchor.constraint(equalTo: view.widthAnchor)
])
context.coordinator.hostingController = controller
return view
}
public func updateUIView(_ uiView: UIScrollView, context: UIViewRepresentableContext<ScrollViewWrapper>) {
uiView.contentOffset = self.contentOffset
if let hostingController = context.coordinator.hostingController {
hostingController.rootView = content()
}
DispatchQueue.main.async {
if let hostedView = uiView.subviews.first {
hostedView.frame = CGRect(origin: .zero, size: uiView.contentSize)
}
}
}
public func makeCoordinator() -> Coordinator {
Coordinator(contentOffset: self._contentOffset)
}
public class Coordinator: NSObject, UIScrollViewDelegate {
let contentOffset: Binding<CGPoint>
var hostingController: UIHostingController<Content>?
init(contentOffset: Binding<CGPoint>) {
self.contentOffset = contentOffset
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
contentOffset.wrappedValue = scrollView.contentOffset
}
}
}
这是我使用
TabView
和 ScrollViewWrapper
的 SwiftUI 视图
import SwiftUI
struct SwiftUIView: View {
@State private var contentOffset: CGPoint = .zero
@State private var currentPageIndex: Int = 0
@State private var topContentHeight: CGFloat = .zero
var body: some View {
VStack(spacing: .zero) {
TabView(selection: $currentPageIndex) {
ForEach(0..<3, id: \.self) { index in
ScrollViewWrapper(
contentOffset: $contentOffset
) {
VStack(spacing: .zero) {
square
.background(
GeometryReader { geo in
Color.clear.preference(key: ViewHeightKey.self, value: geo.size.height)
}
)
VStack(spacing: .zero) {
text
Spacer()
}
.frame(height: 800)
}
.tag(index)
}
.ignoresSafeArea(edges: .top)
.onPreferenceChange(ViewHeightKey.self) { value in
topContentHeight = value
}
}
}
.ignoresSafeArea(edges: .top)
.tabViewStyle(.page(indexDisplayMode: .never))
Text("Footer")
.padding()
.frame(maxWidth: .infinity)
.background(Color.red)
}
.onChange(of: contentOffset) {
print("contentOffset Y is now \(contentOffset.y)")
}
.background(
GeometryReader { geo in
VStack(spacing: .zero) {
let offsetY = contentOffset.y
let greenHeight = max(-offsetY + (topContentHeight), 0)
Color.green
.frame(height: greenHeight)
Color.blue
.ignoresSafeArea(edges: .bottom)
}
.ignoresSafeArea(edges: .top)
}
)
}
var square: some View {
RoundedRectangle(cornerRadius: 29)
.frame(width: 300, height: 300)
}
var text: some View {
VStack(spacing: 16) {
Text("Text")
.font(.title)
}
}
}
private struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
let next = nextValue()
// Take the maximum value to ensure we get the correct height
value = max(value, next)
}
}
我尝试了很多不同的事情,但我无法弄清楚。有谁知道修复方法吗?我在这里附上了该问题的视频。
看起来,当滚动视图最初添加到窗口时,会调用委托方法
scrollViewDidScroll
。
考虑这个 UIKit 代码 - 它会打印“Did Scroll”,而无需我进行任何滚动。
class MyViewController: UIViewController, UIScrollViewDelegate {
let scrollView = UIScrollView(frame: .init(x: 0, y: 0, width: 100, height: 100))
override func viewDidLoad() {
let v = UIView()
v.backgroundColor = .blue
v.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(v)
NSLayoutConstraint.activate([
v.widthAnchor.constraint(equalToConstant: 200),
v.heightAnchor.constraint(equalToConstant: 200),
v.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
v.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
v.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
v.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
])
scrollView.delegate = self
view.addSubview(scrollView)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print("Did Scroll")
}
}
TabView
的选项卡是延迟创建的,因此当您转到下一个选项卡时,会调用该选项卡中滚动视图的scrollViewDidScroll
,再次将绑定设置为初始偏移量。
解决此问题的一个简单方法是忽略
scrollViewDidScroll
的第一次调用。在协调器中添加一个名为 firstScrollCalled
的标志,最初将其设置为 false,然后在 scrollViewDidScroll
中检查它。
这是一个例子:
public struct ScrollViewWrapper<Content: View>: UIViewRepresentable {
@Binding var contentOffset: CGPoint
let content: () -> Content
public init(
contentOffset: Binding<CGPoint>,
@ViewBuilder _ content: @escaping () -> Content) {
self._contentOffset = contentOffset
self.content = content
}
public func makeUIView(context: UIViewRepresentableContext<ScrollViewWrapper>) -> UIScrollView {
let view = UIScrollView()
view.delegate = context.coordinator
let controller = UIHostingController(rootView: content())
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.backgroundColor = .clear
view.addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: view.contentLayoutGuide.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: view.contentLayoutGuide.trailingAnchor),
controller.view.topAnchor.constraint(equalTo: view.contentLayoutGuide.topAnchor),
controller.view.bottomAnchor.constraint(equalTo: view.contentLayoutGuide.bottomAnchor),
controller.view.widthAnchor.constraint(equalTo: view.contentLayoutGuide.widthAnchor)
])
context.coordinator.hostingController = controller
return view
}
public func updateUIView(_ uiView: UIScrollView, context: UIViewRepresentableContext<ScrollViewWrapper>) {
if context.coordinator.firstScrollCalled {
uiView.contentOffset = self.contentOffset
}
if let hostingController = context.coordinator.hostingController {
hostingController.rootView = content()
}
context.coordinator.contentOffsetDidChange = { contentOffset = $0 }
}
public func makeCoordinator() -> Coordinator {
Coordinator()
}
public class Coordinator: NSObject, UIScrollViewDelegate {
var contentOffsetDidChange: ((CGPoint) -> Void)?
var hostingController: UIHostingController<Content>?
var firstScrollCalled = false
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
if firstScrollCalled {
contentOffsetDidChange?(scrollView.contentOffset)
} else {
firstScrollCalled = true
}
}
}
}
我还做了一些其他更改。我向滚动视图的
contentLayoutGuide
添加了约束,而不是滚动视图自己的锚点,并且我还将协调器中的 Binding
更改为回调闭包,因为绑定实际上不属于非 SwiftUI 类型。