8

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?

2

4 Answers 4

13

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
}
Sign up to request clarification or add additional context in comments.

4 Comments

what's the iOS requirement on it? is it iOS 16+ only?
iOS 15. will update the answer
refreshable itself is available since iOS 15, but speaking of the availability on ScrollView, it seems since iOS 16. JFYI.
Also this is not possible on watchOS currently, even if you use List or ScrollView. Seems to be an intentional decision from Apple
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

1 Comment

it doesn't work in case of fast scroll to top, as it will refresh the view.
7

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

Comments

-4

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
    }

1 Comment

This is a List not a ScrollView as @zumum was asking for

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.