5

How do you drag to refresh a grid view in swiftui? I know you can do it with List view with refreshable modifier in iOS 15, but how can you do it with a LazyVGrid? How would you do it in either List or Grid view pre iOS 15? I pretty new at swiftui. I attached a gif showing what Im trying to achieve.

Drag to Refresh

  • Welcome to SO - Please take the [tour](https://stackoverflow.com/tour) and read [How to Ask](https://stackoverflow.com/help/how-to-ask) to improve, edit and format your questions. Without a [Minimal Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example) it is impossible to help you troubleshoot your attempt. – lorem ipsum Dec 07 '21 at 18:46
  • 1
    Does this answer your question? [Pull down to refresh data in SwiftUI](https://stackoverflow.com/questions/56493660/pull-down-to-refresh-data-in-swiftui) – jnpdx Dec 07 '21 at 19:58

1 Answers1

4

Here is the code LazyVStack:

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: $0.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()
    }

}

And here is typical usage:

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

This can be easily adapted for LazyVGrid, just replace LazyVStack.

EDIT: Here is more refined variant:

struct PullToRefresh: View {
    
    private enum Constants {
        static let refreshTriggerOffset = CGFloat(-140)
    }
    
    @Binding private var needsRefresh: Bool
    private let coordinateSpaceName: String
    private let onRefresh: () -> Void
    
    init(needsRefresh: Binding<Bool>, coordinateSpaceName: String, onRefresh: @escaping () -> Void) {
        self._needsRefresh = needsRefresh
        self.coordinateSpaceName = coordinateSpaceName
        self.onRefresh = onRefresh
    }
    
    var body: some View {
        HStack(alignment: .center) {
            if needsRefresh {
                VStack {
                    Spacer()
                    ProgressView()
                    Spacer()
                }
                .frame(height: 60)
            }
        }
        .background(GeometryReader {
            Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self,
                                   value: -$0.frame(in: .named(coordinateSpaceName)).origin.y)
        })
        .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in
            guard !needsRefresh, offset < Constants.refreshTriggerOffset else { return }
            withAnimation { needsRefresh = true }
            onRefresh()
        }
    }
}


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


private enum Constants {
    static let coordinateSpaceName = "PullToRefreshScrollView"
}

struct PullToRefreshScrollView<Content: View>: View {
    @Binding private var needsRefresh: Bool
    private let onRefresh: () -> Void
    private let content: () -> Content
    
    init(needsRefresh: Binding<Bool>,
         onRefresh: @escaping () -> Void,
         @ViewBuilder content: @escaping () -> Content) {
        self._needsRefresh = needsRefresh
        self.onRefresh = onRefresh
        self.content = content
    }
    
    var body: some View {
        ScrollView {
            PullToRefresh(needsRefresh: $needsRefresh,
                          coordinateSpaceName: Constants.coordinateSpaceName,
                          onRefresh: onRefresh)
            content()
        }
        .coordinateSpace(name: Constants.coordinateSpaceName)
    }
}
MegaManX
  • 8,766
  • 12
  • 51
  • 83
  • 1
    This answer helped me a lot! I did find an issue with it though. you should not use `abs(offset)` because this causes an infinite loop when you scroll down as well as up. It should just be offset. Also for me I needed a bigger offset than 50 otherwise my views would default to refresh, but that might just be a weird edge case. – AlexW.H.B. Apr 24 '22 at 18:08
  • 2
    One thing I noticed in the refined variant is that it runs the refresh when I pull down to the trigger offset. But usually refresh should happen after user scroll to the trigger offset and then after releasing the drag. (Like in twitter). I updated the `onPreferenceChange` to achieve that. `if !needsRefresh && offset < Constants.refreshTriggerOffset { withAnimation { needsRefresh = true } } else if needsRefresh && offset == 0 { onRefresh() }` – Lakshith Nishshanke Oct 08 '22 at 03:51