Search code examples
swiftuirefreshdragrefreshable

How can you Drag to refresh a Grid View (LazyVGrid) in Swiftui?


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


Solution

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