5

When I use a List view I can easily add the refreshable modifier to trigger refresh logic. My question is how to achieve the same when using a LazyVStack instead.

I have the following code:

struct TestListView: View {
    
    var body: some View {
        
        Text("the list view")
        
        // WORKS:
//        VStack {
//            List {
//                ForEach(0..<10) { n in
//                    Text("N = \(n)")
//                }
//            }
//            .refreshable {
//
//            }
//        }
        
        
        // DOES NOT SHOW REFRESH CONTROL:
        ScrollView {
            
            LazyVStack {
                ForEach(0..<10) { n in
                    Text("N = \(n)")
                }
            }

        }
        .refreshable {
            
        }
        
    }
    
}

How can I get the pull to refresh behavior in the LazyVStack case?

zumzum
  • 17,984
  • 26
  • 111
  • 172
  • You would have to create your own with UIKit. `.refreshable` is only for lists https://developer.apple.com/documentation/swiftui/view/refreshable(action:)?language=_2 – lorem ipsum Jan 06 '22 at 17:25
  • Does this answer your question? [SwiftUI Generic Pull to refresh view](https://stackoverflow.com/questions/61371525/swiftui-generic-pull-to-refresh-view) – lorem ipsum Jan 06 '22 at 17:27

4 Answers4

12

Actually it is possible, and I'd say, I understand Apple's idea - they give default built-in behavior for heavy List, but leave lightweight ScrollView just prepared so we could customise it in whatever way we need.

So here is a demo of solution (tested with Xcode 13.4 / iOS 15.5)

demo

Main part:

struct ContentView: View {
    var body: some View {
        ScrollView {
            RefreshableView()
        }
        .refreshable {     // << injects environment value !!
            await fetchSomething()
        }
    }
}


struct RefreshableView: View {
    @Environment(\.refresh) private var refresh   // << refreshable injected !!

    @State private var isRefreshing = false

    var body: some View {
        VStack {
            if isRefreshing {
                MyProgress()
                    .transition(.scale)
            }
        // ...
        .onPreferenceChange(ViewOffsetKey.self) {
            if $0 < -80 && !isRefreshing {   // << any creteria we want !!
                isRefreshing = true
                Task {
                    await refresh?()           // << call refreshable !!
                    await MainActor.run {
                        isRefreshing = false
                    }
                }
            }
        }

Complete test module is here

Asperi
  • 228,894
  • 20
  • 464
  • 690
8

For iOS 16+

Now SwiftUI Added the .refreshable modifier to ScrollView.

Just use it the way you do with List

ScrollView {
    LazyVStack {
        // Loop and add View
    }
}
.refreshable {
    refreshLogic()
}

Here is the documentation reference

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension View {
    /// Marks this view as refreshable.
    public func refreshable(action: @escaping @Sendable () async -> Void) -> some View
}
Wissa
  • 1,444
  • 20
  • 24
6

Based on Asperi answer:

import SwiftUI

struct ContentView: View {
    var body: some View {
        ScrollView {
            RefreshableView {
                RoundedRectangle(cornerRadius: 20)
                    .fill(.red).frame(height: 100).padding()
                    .overlay(Text("Button"))
                    .foregroundColor(.white)
            }
        }
        .refreshable {     // << injects environment value !!
            await fetchSomething()
        }
    }

    func fetchSomething() async {
        // demo, assume we update something long here
        try? await Task.sleep(nanoseconds: 2 * 1_000_000_000)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

struct RefreshableView<Content: View>: View {
    
    var content: () -> Content
    
    @Environment(\.refresh) private var refresh   // << refreshable injected !!
    @State private var isRefreshing = false

    var body: some View {
        VStack {
            if isRefreshing {
                MyProgress()    // ProgressView() ?? - no, it's boring :)
                    .transition(.scale)
            }
            content()
        }
        .animation(.default, value: isRefreshing)
        .background(GeometryReader {
            // detect Pull-to-refresh
            Color.clear.preference(key: ViewOffsetKey.self, value: -$0.frame(in: .global).origin.y)
        })
        .onPreferenceChange(ViewOffsetKey.self) {
            if $0 < -80 && !isRefreshing {   // << any creteria we want !!
                isRefreshing = true
                Task {
                    await refresh?()           // << call refreshable !!
                    await MainActor.run {
                        isRefreshing = false
                    }
                }
            }
        }
    }
}

struct MyProgress: View {
    @State private var isProgress = false
    var body: some View {
        HStack{
             ForEach(0...4, id: \.self){index in
                  Circle()
                        .frame(width:10,height:10)
                        .foregroundColor(.red)
                        .scaleEffect(self.isProgress ? 1:0.01)
                        .animation(self.isProgress ? Animation.linear(duration:0.6).repeatForever().delay(0.2*Double(index)) :
                             .default
                        , value: isProgress)
             }
        }
        .onAppear { isProgress = true }
        .padding()
    }
}

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

schornon
  • 342
  • 3
  • 4
-3

Simply create file RefreshableScrollView in your project

public struct RefreshableScrollView<Content: View>: View {
    var content: Content
    var onRefresh: () -> Void

    public init(content: @escaping () -> Content, onRefresh: @escaping () -> Void) {
        self.content = content()
        self.onRefresh = onRefresh
    }

    public var body: some View {
        List {
            content
                .listRowSeparatorTint(.clear)
                .listRowBackground(Color.clear)
                .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
        }
        .listStyle(.plain)
        .refreshable {
            onRefresh()
        }
    }
}

then use RefreshableScrollView anywhere in your project

example:

 RefreshableScrollView{
      // Your content LaztVStack{}
    } onRefresh: {
      // do something you want
    }
Jishnu Raj T
  • 2,409
  • 1
  • 9
  • 13