import SwiftUI struct LottieRefreshableScrollView: View { private let action: () async -> Void private let content: () -> Content private let refreshRegionHeight = SodaSpacing.s48 + (SodaSpacing.s8 * 2) @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion @State private var pullOffset: CGFloat = 0 @State private var isRefreshArmed = false @State private var isRefreshing = false init( action: @escaping () async -> Void, @ViewBuilder content: @escaping () -> Content ) { self.action = action self.content = content } var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 0) { pullOffsetReader refreshRegion content() } } .scrollBounceBehavior(.always, axes: .vertical) .background(PullOffsetPositionReader(type: .fixed)) .onPreferenceChange(PullOffsetPreferenceKey.self) { positions in guard let movingMinY = positions.first(where: { $0.type == .moving })?.minY, let fixedMinY = positions.first(where: { $0.type == .fixed })?.minY else { return } let offset = max(0, movingMinY - fixedMinY) pullOffset = offset guard !isRefreshing else { return } if offset >= refreshRegionHeight { isRefreshArmed = true } else if isRefreshArmed { isRefreshArmed = false refresh() } } .accessibilityAction(named: Text(I18n.LiveNow.refreshButton)) { refresh() } } private var pullOffsetReader: some View { PullOffsetPositionReader(type: .moving) .frame(height: 0) } private var refreshRegion: some View { Color.clear .frame(height: isRefreshing ? refreshRegionHeight : 0) .overlay(alignment: .top) { if pullOffset > 0 || isRefreshing { LottieRefreshIndicator(isAnimating: true) .padding(.vertical, SodaSpacing.s8) .frame(maxWidth: .infinity) .frame(height: refreshRegionHeight) .offset(y: isRefreshing ? 0 : -refreshRegionHeight) } } } private func refresh() { isRefreshArmed = false guard !isRefreshing else { return } withAnimation(accessibilityReduceMotion ? nil : .easeOut(duration: 0.2)) { isRefreshing = true } Task { @MainActor in await action() withAnimation(accessibilityReduceMotion ? nil : .easeOut(duration: 0.2)) { pullOffset = 0 isRefreshing = false } } } } private enum PullOffsetPositionType { case fixed case moving } private struct PullOffsetPosition: Equatable { let type: PullOffsetPositionType let minY: CGFloat } private struct PullOffsetPreferenceKey: PreferenceKey { static var defaultValue: [PullOffsetPosition] = [] static func reduce(value: inout [PullOffsetPosition], nextValue: () -> [PullOffsetPosition]) { value.append(contentsOf: nextValue()) } } private struct PullOffsetPositionReader: View { let type: PullOffsetPositionType var body: some View { GeometryReader { proxy in Color.clear.preference( key: PullOffsetPreferenceKey.self, value: [PullOffsetPosition(type: type, minY: proxy.frame(in: .global).minY)] ) } } }