SwiftUI 下拉刷新数据

Pull down to refresh data in SwiftUI

我使用 List 使用了简单的数据列表。我想添加下拉刷新功能,但我不确定哪种方法最好。

下拉刷新视图仅在用户尝试从第一个索引下拉时可见,就像我们在 UITableViewUIRefreshControlUIKit[=16 中所做的一样=]

这是在 SwiftUI.

中列出数据的简单代码
struct CategoryHome: View {
    var categories: [String: [Landmark]] {
        .init(
            grouping: landmarkData,
            by: { [=10=].category.rawValue }
        )
    }

    var body: some View {
        NavigationView {
            List {
                ForEach(categories.keys.sorted().identified(by: \.self)) { key in
                    Text(key)
                }
            }
            .navigationBarTitle(Text("Featured"))
        }
    }
}

我正在玩的应用程序需要同样的东西,看起来 SwiftUI API 目前不包含 ScrollView 的刷新控制功能。

随着时间的推移,API 将开发并纠正这些情况,但 SwiftUI 中缺少功能的一般回退将始终实现实现 UIViewRepresentable 的结构。这是用于 UIScrollView 的带有刷新控件的快速而肮脏的。

struct LegacyScrollView : UIViewRepresentable {
    // any data state, if needed

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UIScrollView {
        let control = UIScrollView()
        control.refreshControl = UIRefreshControl()
        control.refreshControl?.addTarget(context.coordinator, action:
            #selector(Coordinator.handleRefreshControl),
                                          for: .valueChanged)

        // Simply to give some content to see in the app
        let label = UILabel(frame: CGRect(x: 0, y: 0, width: 200, height: 30))
        label.text = "Scroll View Content"
        control.addSubview(label)

        return control
    }


    func updateUIView(_ uiView: UIScrollView, context: Context) {
        // code to update scroll view from view state, if needed
    }

    class Coordinator: NSObject {
        var control: LegacyScrollView

        init(_ control: LegacyScrollView) {
            self.control = control
        }

        @objc func handleRefreshControl(sender: UIRefreshControl) {
            // handle the refresh event

            sender.endRefreshing()
        }
    }
}

但是,当然,如果不将它们包装在 UIHostingController 中并将它们放入 makeUIView 而不是将它们放在 [=16] 中,则不能在滚动视图中使用任何 SwiftUI 组件=].

你好,看看我制作的这个库:https://github.com/AppPear/SwiftUI-PullToRefresh

一行代码即可实现:

struct CategoryHome: View {
    var categories: [String: [Landmark]] {
        .init(
            grouping: landmarkData,
            by: { [=10=].category.rawValue }
        )
    }

    var body: some View {
        RefreshableNavigationView(title: "Featured", action:{
           // your refresh action
        }){
                ForEach(categories.keys.sorted().identified(by: \.self)) { key in
                    Text(key)
                    Divider() // !!! this is important to add cell separation
                }
            }
        }
    }
}

这是一个内省视图层次结构并将适当的 UIRefreshControl 添加到 SwiftUI 列表的 table 视图的实现:https://github.com/timbersoftware/SwiftUIRefresh

可以在此处找到大部分内省逻辑:https://github.com/timbersoftware/SwiftUIRefresh/blob/15d9deed3fec66e2c0f6fd1fd4fe966142a891db/Sources/PullToRefresh.swift#L39-L73

masOS 尚不支持 swiftui-introspects,因此如果您要构建适用于 iOS 和 macOS 的 UI,请考虑 Samu Andras 库。

我分叉了他的代码,添加了一些增强功能,并添加了在没有 NavigationView 的情况下使用的功能

这里是示例代码。

RefreshableList(showRefreshView: $showRefreshView, action:{
                           // Your refresh action
                            // Remember to set the showRefreshView to false
                            self.showRefreshView = false

                        }){
                            ForEach(self.numbers, id: \.self){ number in
                                VStack(alignment: .leading){
                                    Text("\(number)")
                                    Divider()
                                }
                            }
                        }

更多详情,您可以访问下面的link。 https://github.com/phuhuynh2411/SwiftUI-PullToRefresh

这是我制作的 simplesmallpure SwiftUI 解决方案向 ScrollView 添加下拉刷新功能。

struct PullToRefresh: View {
    
    var coordinateSpaceName: String
    var onRefresh: ()->Void
    
    @State var needRefresh: Bool = false
    
    var body: some View {
        GeometryReader { geo in
            if (geo.frame(in: .named(coordinateSpaceName)).midY > 50) {
                Spacer()
                    .onAppear {
                        needRefresh = true
                    }
            } else if (geo.frame(in: .named(coordinateSpaceName)).maxY < 10) {
                Spacer()
                    .onAppear {
                        if needRefresh {
                            needRefresh = false
                            onRefresh()
                        }
                    }
            }
            HStack {
                Spacer()
                if needRefresh {
                    ProgressView()
                } else {
                    Text("⬇️")
                }
                Spacer()
            }
        }.padding(.top, -50)
    }
}

要使用它很简单,只需将它添加到 ScrollView 的顶部并为其指定 ScrollView 的坐标 space :

ScrollView {
    PullToRefresh(coordinateSpaceName: "pullToRefresh") {
        // do your stuff when pulled
    }
    
    Text("Some view...")
}.coordinateSpace(name: "pullToRefresh")

我尝试了很多不同的解决方案,但没有一个对我的情况有效。 GeometryReader 基于解决方案的复杂布局性能不佳。

这是一个看起来运行良好的纯 SwiftUI 2.0 视图,不会通过持续状态更新降低滚动性能并且不使用任何 UIKit hacks:

import SwiftUI

struct PullToRefreshView: View
{
    private static let minRefreshTimeInterval = TimeInterval(0.2)
    private static let triggerHeight = CGFloat(100)
    private static let indicatorHeight = CGFloat(100)
    private static let fullHeight = triggerHeight + indicatorHeight
    
    let backgroundColor: Color
    let foregroundColor: Color
    let isEnabled: Bool
    let onRefresh: () -> Void
    
    @State private var isRefreshIndicatorVisible = false
    @State private var refreshStartTime: Date? = nil
    
    init(bg: Color = .white, fg: Color = .black, isEnabled: Bool = true, onRefresh: @escaping () -> Void)
    {
        self.backgroundColor = bg
        self.foregroundColor = fg
        self.isEnabled = isEnabled
        self.onRefresh = onRefresh
    }
    
    var body: some View
    {
        VStack(spacing: 0)
        {
            LazyVStack(spacing: 0)
            {
                Color.clear
                    .frame(height: Self.triggerHeight)
                    .onAppear
                    {
                        if isEnabled
                        {
                            withAnimation
                            {
                                isRefreshIndicatorVisible = true
                            }
                            refreshStartTime = Date()
                        }
                    }
                    .onDisappear
                    {
                        if isEnabled, isRefreshIndicatorVisible, let diff = refreshStartTime?.distance(to: Date()), diff > Self.minRefreshTimeInterval
                        {
                            onRefresh()
                        }
                        withAnimation
                        {
                            isRefreshIndicatorVisible = false
                        }
                        refreshStartTime = nil
                    }
            }
            .frame(height: Self.triggerHeight)
            
            indicator
                .frame(height: Self.indicatorHeight)
        }
        .background(backgroundColor)
        .ignoresSafeArea(edges: .all)
        .frame(height: Self.fullHeight)
        .padding(.top, -Self.fullHeight)
    }
    
    private var indicator: some View
    {
        ProgressView()
            .progressViewStyle(CircularProgressViewStyle(tint: foregroundColor))
            .opacity(isRefreshIndicatorVisible ? 1 : 0)
    }
}

当它进入或离开屏幕边界时,它使用带负填充的 LazyVStack 在触发视图 Color.clear 上调用 onAppearonDisappear

如果触发视图出现和消失之间的时间大于 minRefreshTimeInterval 以允许 ScrollView 反弹而不触发刷新,则会触发刷新。

要使用它,请将 PullToRefreshView 添加到 ScrollView 的顶部:

import SwiftUI

struct RefreshableScrollableContent: View
{
    var body: some View
    {
        ScrollView
        {
            VStack(spacing: 0)
            {
                PullToRefreshView { print("refreshing") }
                
                // ScrollView content
            }
        }
    }
}

要点:https://gist.github.com/tkashkin/e5f6b65b255b25269d718350c024f550

老实说,none 个评分最高的答案确实很适合我的场景。该场景在 ScrollView 和自定义 LoadingView 之间切换。每次我从 LoadingView 切换到使用遗留 UIScrollView 使用 UIViewRepresentable 创建的 ScrollView 时, contentSize 就会变得一团糟。

因此,作为一种解决方案,我创建了一个库,以便它对所有试图为这种简单问题寻找解决方案的开发人员都有用。我从互联网上浏览了许多网站,并最终调整了解决方案,最终为我提供了最佳解决方案。

步骤

  1. SPM https://github.com/bibinjacobpulickal/BBRefreshableScrollView 添加到您的项目。
  2. import BBRefreshableScrollView 到所需的文件。
  3. 更新 Viewbody
struct CategoryHome: View {
    ...
    var body: some View {
        NavigationView {
            BBRefreshableScrollView { completion in
                // do refreshing stuff here
            } content: {
                ForEach(categories.keys.sorted().identified(by: \.self)) { key in
                    Text(key)
                }
            }
            .navigationBarTitle(Text("Featured"))
        }
    }
}

更多详情可以关注Readme.

这是一种使用 ScrollView、GeometryReader 和 PreferenceKey 的纯 SwiftUI 方法 我能够读取 ScrollView 中的滚动偏移量,一旦它高于阈值,我就可以执行操作

import SwiftUI

struct RefreshableView<Content:View>: View {
    init(action: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content) {
        self.content = content
        self.refreshAction = action
    }
    
    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                content()
                    .anchorPreference(key: OffsetPreferenceKey.self, value: .top) {
                        geometry[[=10=]].y
                    }
            }
            .onPreferenceChange(OffsetPreferenceKey.self) { offset in
                if offset > threshold {
                    refreshAction()
                }
            }
        }
    }
    
    
    private var content: () -> Content
    private var refreshAction: () -> Void
    private let threshold:CGFloat = 50.0
}

fileprivate struct OffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

这是RefreshableView 的用法示例。 进度指示器不包含在 RefreshableView 中,它只提供一个 GeometryReader 和一个包含您要刷新的内容的 ScrollView。您需要提供 ProgressView 或其他视图来显示加载正在进行中。 它不适用于 List,但您可以改用 ForEach,内容将滚动,这要归功于 ScrollView

RefreshableView(action: {
    viewModel.refreshFeed(forceReload: true)
}) {
    if viewModel.showProgressView {
        VStack {
            ProgressView()
            Text("reloading feed...")
                .font(Font.caption2)
        }
    }
    ForEach(viewModel.feed.entries) { entry in
        viewForEntry(entry)
    }
}

完整示例可在 GitHub

上找到

来自 iOS 15+

NavigationView {
    List(1..<100) { row in
     Text("Row \(row)")
    }
    .refreshable {
         print("write your pull to refresh logic here")
    }
}

更多详情:Apple Doc

这个呢

import SwiftUI

public struct FreshScrollView<Content>: View where Content : View{
    private let content: () -> Content
    private let action: () -> Void
    
    init(@ViewBuilder content:  @escaping () -> Content, action: @escaping ()-> Void){
        self.content = content
        self.action = action
    }
    
    @State var startY: Double = 10000
    
    public var body: some View{
        ScrollView{
            GeometryReader{ geometry in
                HStack {
                    Spacer()
                    if geometry.frame(in: .global) .minY - startY > 30{
                        ProgressView()
                            .padding(.top, -30)
                            .animation(.easeInOut)
                            .transition(.opacity)
                            .onAppear{
                                let noti = UIImpactFeedbackGenerator(style: .light)
                                noti.prepare()
                                noti.impactOccurred()
                                action()
                            }
                    }
                    Spacer()
                }
                .onAppear {
                    startY = geometry.frame(in:.global).minY
                }
            }
            content()
        }
    }
}


#if DEBUG
struct FreshScrollView_Previews: PreviewProvider {
    static var previews: some View {
        FreshScrollView {
            Text("A")
            Text("B")
            Text("C")
            Text("D")
        } action: {
            print("text")
        }

    }
}
#endif

我知道最初的问题是针对列表的,但这里是 ScrollViewLazyVStack 的代码,因为有时列表不合适。

import SwiftUI

struct PullToRefreshSwiftUI: View {
    @Binding private var needRefresh: Bool
    private let coordinateSpaceName: String
    private let onRefresh: () -> Void
    
    init(needRefresh: Binding<Bool>, coordinateSpaceName: String, onRefresh: @escaping () -> Void) {
        self._needRefresh = needRefresh
        self.coordinateSpaceName = coordinateSpaceName
        self.onRefresh = onRefresh
    }
    
    var body: some View {
        HStack(alignment: .center) {
            if needRefresh {
                VStack {
                    Spacer()
                    ProgressView()
                    Spacer()
                }
                .frame(height: 100)
            }
        }
        .background(GeometryReader {
            Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self,
                                   value: [=10=].frame(in: .named(coordinateSpaceName)).origin.y)
        })
        .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in
            guard !needRefresh else { return }
            if abs(offset) > 50 {
                needRefresh = true
                onRefresh()
            }
        }
    }
}


struct ScrollViewOffsetPreferenceKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat.zero
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }

}

这是典型用法:

struct ContentView: View {
    @State private var refresh: Bool = false
    @State private var itemList: [Int] = {
        var array = [Int]()
        (0..<40).forEach { value in
            array.append(value)
        }
        return array
    }()
    
    var body: some View {
        ScrollView {
            PullToRefreshSwiftUI(needRefresh: $refresh,
                                 coordinateSpaceName: "pullToRefresh") {
                DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                    withAnimation { refresh = false }
                }
            }
            LazyVStack {
                ForEach(itemList, id: \.self) { item in
                    HStack {
                        Spacer()
                        Text("\(item)")
                        Spacer()
                    }
                }
            }
        }
        .coordinateSpace(name: "pullToRefresh")
    }
}

我对 SwiftUI 中的下拉刷新选项非常不满意。即使在使用基于内省 UIKit 的解决方案时,在使用导航视图时也会出现奇怪的行为和一些视图跳跃。我还需要一些更可定制的东西,所以我写了一个库。它是下拉刷新控件的(几乎)纯 SwiftUI 实现,它适用于 iOS 14 和 15。

https://github.com/gh123man/SwiftUI-Refresher

它是高度可定制的,并支持一些额外的功能,例如能够覆盖视图内容(在您有静态 header 的情况下)。

由于 swiftUi 最好使用纯 swiftUi,正如@Anatoliy Kashkin 在他的回答中提到的那样,所以我更新了他的回答以支持 enable/disable 从视图外部和内部刷新。

创建PullToRefresh结构视图:

import Foundation
import SwiftUI

struct PullToRefresh: View {
    
    @Binding var isRefreshing:Bool
    var coordinateSpaceName: String
    var onRefresh: ()->Void
    
    @State var needRefresh: Bool = false
    
    var body: some View {
        GeometryReader { geo in
            if (geo.frame(in: .named(coordinateSpaceName)).midY > 50) {
                Spacer()
                    .onAppear {
                        needRefresh = true
                    }
            } else if (geo.frame(in: .named(coordinateSpaceName)).maxY < 10) {
                Spacer()
                    .onAppear {
                        if needRefresh {
                            needRefresh = false
                            onRefresh()
                        }
                    }
            }
            HStack {
                Spacer()
                if needRefresh || isRefreshing {
                    ProgressView()
                } else {
                    
                    Image(systemName: "arrow.clockwise")
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 20, height: 20)
                        .foregroundColor(.gray)
                    
                    Text("Refresh")
                        .foregroundColor(.gray)
                }
                Spacer()
            }
        }.padding(.top, isRefreshing ? 0 : -50)
    }
}

现在你可以像这样使用它了:

@State var isRefreshing:Bool = true

var body: some View {
    
   
      
            ScrollView(){
                
                PullToRefresh(isRefreshing: $isRefreshing, coordinateSpaceName: "pullToRefresh") {
                    fetchOrders()
                }
                
                VStack(alignment : .leading){
                    Text("Some view...")
                    
                }
                
            }.coordinateSpace(name: "pullToRefresh")

通过设置 isRefreshing true ,它将显示进度并在您完成 http 请求或更新数据等时将其设置为 false