SwiftUI Pull to Refresh Support

Hi,

Are there plans to add Pull to Refresh support to List or ScrollView in iOS14?

This is a large gap and the workarounds create other issues.
  1. Wrapping all my swiftui view content in a UIScrollView ViewRepresentable wrapper (leveraging the refreshControl property of ScrollView) causes other issues. Specifically NavigationLinks driven by Binding<Idenfiable> don’t present (presumably the nesting within a UIHostingController to sit within the UIScrollView breaks this somehow).

  2. Refresh controls usually render above the Large Navigation bar titles which aren’t exposed as Views. This means with approaches that use Preferences and geometry readers can’t get an accurate scroll offset (because large titles shrink and grow as the user scrolls)

I can probably work around this by doing all my navigation in UIKit, calling back from Views to some parent object (eg Navigation Coordinator) who can then manage pushing View controllers that wrap SwiftUI views in hosting controllers. But that is not ideal.

Has anyone had any luck with a Pull to refresh solution in SwiftUI with navigation? (other than introspecting the View tree for secret UITableViews etc)

I implemented a custom RefreshableScrollView using PreferenceKey

import SwiftUI
/// RefreshableScrollView
public struct RefreshableScrollView<Content:View>: View {
  @Binding private var isRefreshing: Bool
  @State private var offset: CGFloat = 0
  @State private var canRefresh: Bool = true
  private var content: () -> Content
  private let threshold: CGFloat = 50.0
   
  public init(
    isRefreshing: Binding<Bool>,
    @ViewBuilder content: @escaping () -> Content
  ) {
    self._isRefreshing = isRefreshing
    self.content = content
  }
   
  public var body: some View {
    GeometryReader { geometry in
      ScrollView {
        VStack(spacing: 0) {
          if isRefreshing || !(canRefresh) {
            ProgressView()
              .progressViewStyle(
                CircularProgressViewStyle(tint: Color.purple)
              )
          } else {
            Image("arrowDown")
              .resizable()
              .frame(width: 22, height: 22, alignment: .center)
              .foregroundColor(Color.purple)
              .padding(.top, -21)
              .rotationEffect(.degrees((offset < (threshold - 2)) ? 0 : 180))
              .animation(.easeInOut(duration: 0.5))
          }
          content()
            .anchorPreference(key: OffsetPreferenceKey.self, value: .top) {
              geometry[$0].y
            }
        }
      }
      .onPreferenceChange(OffsetPreferenceKey.self) { offset in
        DispatchQueue.main.async {
          if canRefresh {
            self.offset = offset
            if offset > threshold && canRefresh {
              withAnimation {
                /// In case u need haptic feel
                hapticSuccess()
                isRefreshing = true
              }
              canRefresh = false
            }
          } else {
            if offset < 21 {
              canRefresh = true
            }
          }
        }
      }
      .animation(.easeInOut, value: isRefreshing)
    }
  }
}

fileprivate struct OffsetPreferenceKey: PreferenceKey {
  static var defaultValue: CGFloat = 0
   
  static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
    value = nextValue()
  }
}

public extension View {
  func hapticSuccess() {
    let generator = UINotificationFeedbackGenerator()
    generator.notificationOccurred(.success)
  }

  /// In case u need a modifier
  func refreshable(isRefreshing: Binding<Bool>) -> some View {
    modifier(RefreshableScrollViewModifier(isRefreshing: isRefreshing))
  }
}

As of iOS 16 beta 4 this now works with ScrollView.

ScrollView {
    ...
}
.refreshable {
    // refresh action
}
SwiftUI Pull to Refresh Support
 
 
Q