SwiftUI实现Finder功能:窗口调整/侧边栏切换时保持滚动偏移
SwiftUI中维持ScrollView窗口调整时的滚动偏移
问题描述
我在SwiftUI里复刻Finder功能,用ScrollView展示内容,但调整窗口大小时,ScrollView会自动滚动,无法保持原来的滚动偏移。示例代码如下:
ScrollView([.horizontal, .vertical], showsIndicators: true) { VStack { HStack { ForEach(1...100, id: \.self) { num in Text("num") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.red) HStack { ForEach(1...100, id: \.self) { num in Text("duo") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.blue) HStack { ForEach(1...100, id: \.self) { num in Text("tre") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.green) HStack { ForEach(1...100, id: \.self) { num in Text("quat") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.gray) }.frame(width: 1500, height: 1000, alignment: .topLeading) }
解决方案
要解决这个问题,核心是手动记录滚动偏移,并在窗口尺寸变化后恢复该偏移。推荐两种实现方式:
方式一:UIKit桥接封装(更稳定)
直接基于系统的UIScrollView封装自定义ScrollView,利用其代理实时记录contentOffset,窗口更新时强制恢复偏移,行为和原生Finder更一致:
import SwiftUI struct StableScrollView<Content: View>: UIViewRepresentable { let axes: Axis.Set let showsIndicators: Bool @ViewBuilder let content: () -> Content @State private var contentOffset: CGPoint = .zero func makeUIView(context: Context) -> UIScrollView { let scrollView = UIScrollView() scrollView.showsVerticalScrollIndicator = showsIndicators scrollView.showsHorizontalScrollIndicator = showsIndicators scrollView.delegate = context.coordinator // 嵌入SwiftUI内容 let hostingView = UIHostingController(rootView: content()).view hostingView.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(hostingView) NSLayoutConstraint.activate([ hostingView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), hostingView.heightAnchor.constraint(equalTo: scrollView.contentLayoutGuide.heightAnchor), hostingView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), hostingView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor) ]) return scrollView } func updateUIView(_ uiView: UIScrollView, context: Context) { // 窗口变化时恢复偏移 if uiView.contentOffset != contentOffset { uiView.contentOffset = contentOffset } } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } class Coordinator: NSObject, UIScrollViewDelegate { let parent: StableScrollView init(parent: StableScrollView) { self.parent = parent } func scrollViewDidScroll(_ scrollView: UIScrollView) { // 实时记录滚动位置 parent.contentOffset = scrollView.contentOffset } } } // 使用示例 struct FinderLikeView: View { var body: some View { StableScrollView(axes: [.horizontal, .vertical], showsIndicators: true) { VStack { HStack { ForEach(1...100, id: \.self) { num in Text("num") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.red) HStack { ForEach(1...100, id: \.self) { num in Text("duo") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.blue) HStack { ForEach(1...100, id: \.self) { num in Text("tre") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.green) HStack { ForEach(1...100, id: \.self) { num in Text("quat") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.gray) }.frame(width: 1500, height: 1000, alignment: .topLeading) } } }
方式二:纯SwiftUI实现(无UIKit依赖)
通过ScrollViewReader和自定义PreferenceKey监听滚动偏移,窗口尺寸变化时恢复:
import SwiftUI struct FinderLikeScrollView: View { @State private var scrollOffset: CGPoint = .zero @State private var canTrackOffset = false var body: some View { GeometryReader { windowGeo in ScrollView([.horizontal, .vertical], showsIndicators: true) { ScrollViewReader { proxy in VStack { // 你的内容视图,和原代码一致 HStack { ForEach(1...100, id: \.self) { num in Text("num") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.red) HStack { ForEach(1...100, id: \.self) { num in Text("duo") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.blue) HStack { ForEach(1...100, id: \.self) { num in Text("tre") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.green) HStack { ForEach(1...100, id: \.self) { num in Text("quat") } } .frame(width: 1500, height: 200, alignment: .topLeading) .background(Color.gray) } .frame(width: 1500, height: 1000, alignment: .topLeading) // 监听滚动锚点偏移 .anchorPreference(key: ScrollOffsetKey.self, value: .topLeading) { anchor in proxy.frame(in: .global)[anchor] } .onPreferenceChange(ScrollOffsetKey.self) { newOffset in if canTrackOffset { scrollOffset = newOffset } } } } // 窗口大小变化时恢复偏移 .onChange(of: windowGeo.size) { _ in DispatchQueue.main.async { withAnimation(.none) { // 若内容有固定id,可直接用proxy.scrollTo(targetId, anchor: .topLeading) } } } .onAppear { canTrackOffset = true } } } } // 自定义PreferenceKey传递偏移 struct ScrollOffsetKey: PreferenceKey { static var defaultValue: CGPoint = .zero static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {} }
方案对比
- UIKit桥接方案:稳定性更高,完全复用系统UIScrollView的行为,适合复刻Finder这类需要原生滚动体验的场景。
- 纯SwiftUI方案:无UIKit依赖,但在复杂滚动场景下可能存在偏移恢复不及时的问题,适合简单场景。
内容的提问来源于stack exchange,提问作者Jason Krowl
相关产品推荐
相关产品推荐

