Sources/ComposeUI/Scroll View/ComposeScrollView.swift

Summary

Maintainability
A
0 mins
Test Coverage
#if os(iOS)

import Foundation
import SwiftUI

// Heavily influenced and adopted from
// https://github.com/globulus/swiftui-pull-to-refresh/blob/main/Sources/SwiftUIPullToRefresh/SwiftUIPullToRefresh.swift
public struct ComposeScrollView<Content : View> : View {
   
    private enum Status {
        case idle
        case dragging
        case primed
        case loading
    }
    
    @Environment(\.composeScrollViewStyle) var style
        
    private let axes : Axis.Set
    private let showsIndicators : Bool
    private let onDrag : DragHandler?
    private let onRefresh : RefreshHandler?

    private let content : Content
    
    @State private var status : Status = .idle
    @State private var progress : Double = 0
    @State private var startDraggingOffset : CGPoint = .zero
    
    public init(_ axes : Axis.Set = .vertical,
                showsIndicators: Bool = false,
                onRefresh : RefreshHandler? = nil,
                onDrag : DragHandler? = nil,
                @ViewBuilder content: () -> Content) {
        self.axes = axes
        self.showsIndicators = showsIndicators
        self.onRefresh = onRefresh
        self.onDrag = onDrag
        self.content = content()
    }
    
    var progressBody: some View {
        ZStack {
            if status == .loading {
                ActivityIndicator()
                    .offset(y: -style.progressOffset)
            }
            else if status != .idle {
                PullIndicator()
                    .rotationEffect(.degrees(180 * progress))
                    .opacity(progress)
                    .offset(y: -style.progressOffset)
            }
        }
    }
    
    public var body: some View {
        ScrollView(axes, showsIndicators: showsIndicators) {
            
            ComposeScrollViewPositionIndicator(type: .moving)
                .frame(height: 0)
                .overlay(
                    Reader(startDraggingOffset: $startDraggingOffset,
                           onDrag: onDrag)
                )
            
            if status != .idle {
                Color.clear
                    .frame(height: status == .loading ? style.threshold : style.threshold * progress)
            }
            
            content
                .overlay(onRefresh != nil ? progressBody : nil, alignment: .top)
        }
        .background(ComposeScrollViewPositionIndicator(type: .fixed))
        .onPreferenceChange(ComposeScrollViewPositionIndicator.PositionPreferenceKey.self) { values in
            guard status != .loading, onRefresh != nil else {
                return
            }
           
            guard startDraggingOffset == .zero else {
                status = .idle
                return
            }
            
            if status == .idle {
                status = .dragging
            }

            DispatchQueue.main.async {
                let movingY = values.first { $0.type == .moving }?.y ?? 0
                let fixedY = values.first { $0.type == .fixed }?.y ?? 0
                let offset : CGFloat = movingY - fixedY
                
                guard offset > 0 else {
                    return
                }

                progress = Double(min(max(abs(offset) / style.threshold, 0.0), 1.0))
                
                if offset > style.threshold && status == .dragging {
                    status = .primed
                }
                else if offset < style.threshold && status == .primed {
                    withAnimation(.linear(duration: 0.2)) {
                        status = .loading
                    }
                    
                    onRefresh? {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
                            withAnimation {
                                self.status = .idle
                                self.progress = 0
                            }
                        }
                      
                    }
                }
            }
        }
        .onTapGesture {
            
        }
    }
    
}

/* Indicators */
    
private struct PullIndicator : View {
    
    var body: some View {
        Image(systemName: "arrow.down")
            .resizable()
            .frame(width: 12, height: 12)
    }
    
}

private struct ActivityIndicator: UIViewRepresentable {
    
    func makeUIView(context: UIViewRepresentableContext<Self>) -> UIActivityIndicatorView {
        UIActivityIndicatorView()
    }
    
    func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<Self>) {
        uiView.startAnimating()
    }
}

#endif