在 SwiftUI 中使用 UIScrollView 时的滚动偏移问题

问题描述 投票:0回答:1

我正在使用

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

我尝试了很多不同的事情,但我无法弄清楚。有谁知道修复方法吗?我在这里附上了该问题的视频。

问题GIF

swiftui uiscrollview swiftui-tabview
1个回答
0
投票

看起来,当滚动视图最初添加到窗口时,会调用委托方法

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 类型。

© www.soinside.com 2019 - 2024. All rights reserved.