You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.20 20:48:19