title: Pull to refresh using scrollview in SwiftUI
body: <p>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.</p>
  <p>The app needs to support a minimum of ios 14. According to <a href="https://developer.apple.com/support/app-store/" rel="nofollow noreferrer">apple's documentation</a> still, 26% of the users use iOS 14. So simply targeting iOS 15 is not an option.</p>
  <p>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 <code>LazyVStack</code> is the correct way to go, so as to lazy load each item displayed in the stack.</p>
  <p>Searching the internet I found out that someone can either use <a href="https://github.com/siteline/SwiftUI-Introspect" rel="nofollow noreferrer">Introspect</a> and hook in a <code>UIRefreshControl</code> or try to do it by themselves. So I tried both and every solution seems to have problems.</p>
  <p>The view that I use to test the pull to refresh functionality is:</p>
  <pre><code>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 ? &quot;Stop refreshing&quot; : &quot;Start refreshing&quot;)
              })
          }
          
      }
  }
  </code></pre>
  <h3>Using introspect:</h3>
  <pre><code>struct PullToRefreshScrollView&lt;Content&gt;: View where Content: View {
      @Binding var isRefreshing: Bool
      private var showsIndicators: Bool
      let content: () -&gt; Content
      
      let refreshHelper = RefreshHelper()
      
      init(
          isRefreshing: Binding&lt;Bool&gt;,
          showsIndicators: Bool = true,
          @ViewBuilder content: @escaping () -&gt; 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&lt;Content&gt;?
          var refreshControl: UIRefreshControl?
          
          @objc func didRefresh() {
              guard let parent = parent else { return }
              if !parent.isRefreshing {
                  parent.isRefreshing = true
              }
          }
      }
  }
  
  
  </code></pre>
  <p>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.</p>
  <h3>Custom solution (Best effort so far)</h3>
  <pre><code>struct PullToRefreshScrollView&lt;Content&gt;: View where Content: View {
      @Binding var isRefreshing: Bool
      private var showsIndicators: Bool
      let content: () -&gt; Content
      
      @State private var contentOffset: CGFloat = 0
      
      init(
          isRefreshing: Binding&lt;Bool&gt;,
          showsIndicators: Bool = true,
          @ViewBuilder content: @escaping () -&gt; Content
      ) {
          self._isRefreshing = isRefreshing
          self.showsIndicators = showsIndicators
          self.content = content
      }
      
      var body: some View {
          ZStack(alignment: .top) {
              ScrollView(showsIndicators: showsIndicators) {
                  PullToRefresh(
                      isRefreshing: $isRefreshing,
                      coordinateSpaceName: &quot;pullToRefresh&quot;,
                      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: &quot;pullToRefresh&quot;)
          }
      }
  }
  
  
  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) -&gt; 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(&quot;Pull to refresh&quot;)
                                      .foregroundColor(Color.primary)
                              } else {
                                  Text(&quot;Release to refresh&quot;)
                                      .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 &amp;&amp; offset &gt; PullToRefresh.PULL_OFFSET {
                      needRefresh = true
                      return
                  }
                  
                  if needRefresh &amp;&amp; offset &lt; PullToRefresh.REFRESH_OFFSET {
                      needRefresh = false
                      isRefreshing = true
                  }
              })
          }
      }
      
      func scroll(_ geometryProxy: GeometryProxy) -&gt; CGRect {
          return geometryProxy.frame(in: .named(coordinateSpaceName))
      }
      
      func fraction(minBound: CGFloat, maxBound: CGFloat, value: CGFloat) -&gt; CGFloat {
          return min(max((value - minBound) / (maxBound - minBound), 0), 1)
      }
  }
  </code></pre>
  <p>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.</p>
  <h3>Question</h3>
  <p>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 &quot;refreshing&quot; problem with another UI element? Is there something that I am missing? I would really like to stick to the native solution (<code>UIRefreshControl</code>) rather than writing my own.</p>