Pull to refresh using scrollview in SwiftUI

Viewed 1524

I am an android developer, developing the iOS app of the company I am working for in Swift UI. The application is almost done, but I have stumbled upon a problem in which I don't know if there is a possible/performant solution. I am new to iOS so maybe I am still lacking the knowledge needed to fix it, so hopefully, someone can help me.

The app needs to support a minimum of ios 14. According to apple's documentation still, 26% of the users use iOS 14. So simply targeting iOS 15 is not an option.

As I found out there is not an official solution from apple to provide a pull to refresh view, especially for ScrollView. Keep in mind that the app hosts screens that need to display large lists to the user. As per Apple's documentation LazyVStack is the correct way to go, so as to lazy load each item displayed in the stack.

Searching the internet I found out that someone can either use Introspect and hook in a UIRefreshControl or try to do it by themselves. So I tried both and every solution seems to have problems.

The view that I use to test the pull to refresh functionality is:

struct TestScreen: View {
    
    @State private var isRefreshing: Bool = false
    
    var body: some View {
        VStack {
            PullToRefreshScrollView(isRefreshing: $isRefreshing) {
                LazyVStack {
                    ForEach(0...100, id: \.self) { item in
                        Text(String(item)).padding(8)
                    }
                }
            }
            
            Button(action: {
                isRefreshing.toggle()
            }, label: {
                Text(isRefreshing ? "Stop refreshing" : "Start refreshing")
            })
        }
        
    }
}

Using introspect:

struct PullToRefreshScrollView<Content>: View where Content: View {
    @Binding var isRefreshing: Bool
    private var showsIndicators: Bool
    let content: () -> Content
    
    let refreshHelper = RefreshHelper()
    
    init(
        isRefreshing: Binding<Bool>,
        showsIndicators: Bool = true,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self._isRefreshing = isRefreshing
        self.showsIndicators = showsIndicators
        self.content = content
    }
    
    var body: some View {
        ScrollView(showsIndicators: showsIndicators) {
            content()
        }.introspectScrollView { scrollView in
            let control = UIRefreshControl()
            control.tintColor = UIColor(Color.cyprus)
            
            refreshHelper.parent = self
            refreshHelper.refreshControl = control
            
            control.addTarget(refreshHelper, action: #selector(RefreshHelper.didRefresh), for: .valueChanged)
            scrollView.refreshControl = control
        }.onChange(of: isRefreshing, perform: { refreshing in
            if refreshing {
                refreshHelper.refreshControl?.beginRefreshing()
            } else {
                refreshHelper.refreshControl?.endRefreshing()
            }
        })
    }
    
    class RefreshHelper {
        var parent: PullToRefreshScrollView<Content>?
        var refreshControl: UIRefreshControl?
        
        @objc func didRefresh() {
            guard let parent = parent else { return }
            if !parent.isRefreshing {
                parent.isRefreshing = true
            }
        }
    }
}


This solution shows the refresh control for the brief moment that the refresh starts, but then hides it. I cannot find out why the refresh control does not keep its height after the refresh has begun.

Custom solution (Best effort so far)

struct PullToRefreshScrollView<Content>: View where Content: View {
    @Binding var isRefreshing: Bool
    private var showsIndicators: Bool
    let content: () -> Content
    
    @State private var contentOffset: CGFloat = 0
    
    init(
        isRefreshing: Binding<Bool>,
        showsIndicators: Bool = true,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self._isRefreshing = isRefreshing
        self.showsIndicators = showsIndicators
        self.content = content
    }
    
    var body: some View {
        ZStack(alignment: .top) {
            ScrollView(showsIndicators: showsIndicators) {
                PullToRefresh(
                    isRefreshing: $isRefreshing,
                    coordinateSpaceName: "pullToRefresh",
                    onScrollChange: {
                        if isRefreshing {
                            contentOffset = 50
                        } else {
                            contentOffset = $0
                        }
                    }
                ).onChange(of: isRefreshing, perform: {
                    if !$0 {
                        withAnimation(.easeOut(duration: 0.3)) {
                            contentOffset = 0
                        }
                        
                    } else {
                        withAnimation(.easeOut(duration: 0.3)) {
                            contentOffset = 50
                        }
                    }
                })
                
                content().offset(y: contentOffset)
            }
            .coordinateSpace(name: "pullToRefresh")
        }
    }
}


struct PullToRefresh: View {
    
    private static let PULL_OFFSET: CGFloat = 70
    private static let REFRESH_OFFSET: CGFloat = 30
    
    @Binding var isRefreshing: Bool
    var coordinateSpaceName: String
    var onScrollChange: (CGFloat) -> Void
    
    @State private var needRefresh: Bool = false
    @State private var scrollOffset: CGFloat = 0
    
    var body: some View {
        GeometryReader { geo in
            HStack(alignment: .center) {
                ZStack {
                    if isRefreshing {
                        ProgressView()
                            .progressViewStyle(CircularProgressViewStyle(tint: Color.primary))
                    } else {
                        HStack {
                            if !needRefresh {
                                Text("Pull to refresh")
                                    .foregroundColor(Color.primary)
                            } else {
                                Text("Release to refresh")
                                    .foregroundColor(Color.primary)
                            }
                        }
                    }
                }
                .frame(height: 50)
                .opacity(isRefreshing ? 1 : fraction(minBound: 0, maxBound: 25, value: max(0, scrollOffset)))
                .offset(y: -scrollOffset)
            }
            .frame(maxWidth: .infinity)
            .onChange(of: scroll(geo).minY, perform: {
                scrollOffset = max($0, 0)
            })
            .onChange(of: scrollOffset, perform: {
                let offset = $0
                onScrollChange(offset)
                if !needRefresh && offset > PullToRefresh.PULL_OFFSET {
                    needRefresh = true
                    return
                }
                
                if needRefresh && offset < PullToRefresh.REFRESH_OFFSET {
                    needRefresh = false
                    isRefreshing = true
                }
            })
        }
    }
    
    func scroll(_ geometryProxy: GeometryProxy) -> CGRect {
        return geometryProxy.frame(in: .named(coordinateSpaceName))
    }
    
    func fraction(minBound: CGFloat, maxBound: CGFloat, value: CGFloat) -> CGFloat {
        return min(max((value - minBound) / (maxBound - minBound), 0), 1)
    }
}

This solution works great on iOS 15, but when testing on iOS 14, the animation of the scroll view, while returning to the idle position, seems to make some vertical jumps. This is the best solution I could develop so far, so I guess this is what I have to keep.

Question

It seems to me a bit weird that there isn't any official solution to this problem, so is this a design pattern that Apple discourages? Should I try to find another solution, maybe tackling the "refreshing" problem with another UI element? Is there something that I am missing? I would really like to stick to the native solution (UIRefreshControl) rather than writing my own.

0 Answers
Related