← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


ListlessiOS/Extensions/ItemListView+PullGestures.swift (6656B)

1import SwiftUI
2
3extension ItemListView {
4    struct PullToCreateState {
5        enum Action {
6            case none
7            case createItem
8            case collapseIndicator
9        }
10
11        var pullOffset: CGFloat = 0
12        var indicatorOffset: CGFloat = 0
13        var isInsertionPending: Bool = false
14        var isScrollInteracting: Bool = false
15
16        private var pullStartTime: CFTimeInterval = 0
17
18        var shouldShowIndicator: Bool {
19            indicatorOffset > 0 || isInsertionPending
20        }
21
22        func indicatorDisplayOffset(threshold: CGFloat) -> CGFloat {
23            isInsertionPending ? threshold : indicatorOffset
24        }
25
26        mutating func updatePullDistance(_ distance: CGFloat) {
27            // Skip duplicate writes to break onScrollGeometryChange re-layout cycles.
28            guard pullOffset != distance else { return }
29            pullOffset = distance
30            if isScrollInteracting {
31                indicatorOffset = distance
32            }
33        }
34
35        mutating func handlePhaseChange(
36            from oldPhase: ScrollPhase,
37            to newPhase: ScrollPhase,
38            pullThreshold: CGFloat,
39            flickThreshold: CGFloat
40        ) -> Action {
41            if newPhase == .interacting, oldPhase != .interacting {
42                pullStartTime = CACurrentMediaTime()
43                // Sync in case onScrollGeometryChange fired before this
44                // phase change, leaving indicatorOffset behind pullOffset.
45                indicatorOffset = pullOffset
46            }
47            isScrollInteracting = (newPhase == .interacting)
48            guard oldPhase == .interacting, newPhase != .interacting else { return .none }
49
50            let elapsed = CACurrentMediaTime() - pullStartTime
51            let isFlick = pullOffset > 0 && elapsed > 0
52                && (pullOffset / elapsed) >= flickThreshold
53
54            if pullOffset >= pullThreshold || isFlick {
55                isInsertionPending = true
56                return .createItem
57            }
58
59            isInsertionPending = false
60            return .collapseIndicator
61        }
62    }
63}
64
65private struct PullGesturesModifier: ViewModifier {
66    @Binding var pullToCreate: ItemListView.PullToCreateState
67    @Binding var pullUpOffset: CGFloat
68
69    @AppStorage("hapticsEnabled") private var hapticsEnabled = true
70    @State private var isScrollInteracting = false
71
72    let isEditing: Bool
73    let hasCompletedItems: Bool
74    let pullCreateThreshold: CGFloat
75    let flickThreshold: CGFloat
76    let pullClearThreshold: CGFloat
77    let onCreateItemAtTop: () -> UUID
78    let onClearCompleted: () -> Void
79
80    func body(content: Content) -> some View {
81        content
82            .onScrollGeometryChange(for: CGFloat.self) { geo in
83                max(0, -(geo.contentOffset.y + geo.contentInsets.top))
84            } action: { _, pullDistance in
85                if isEditing {
86                    pullToCreate.updatePullDistance(0)
87                } else {
88                    pullToCreate.updatePullDistance(pullDistance)
89                }
90            }
91            .onScrollGeometryChange(for: CGFloat.self) { geo in
92                let adjustedBottomInset = geo.contentInsets.bottom - 20
93                let maxOffset = max(
94                    -geo.contentInsets.top,
95                    geo.contentSize.height - geo.bounds.size.height + adjustedBottomInset
96                )
97                return max(0, geo.contentOffset.y - maxOffset)
98            } action: { _, bottomOverscroll in
99                guard hasCompletedItems, isScrollInteracting else { return }
100                pullUpOffset = bottomOverscroll
101            }
102            .onScrollPhaseChange { oldPhase, newPhase in
103                if newPhase == .interacting {
104                    isScrollInteracting = true
105                }
106
107                handlePullToCreateScrollPhaseChange(from: oldPhase, to: newPhase)
108
109                if oldPhase == .interacting, newPhase != .interacting {
110                    handlePullToClearRelease()
111                    isScrollInteracting = false
112                }
113            }
114            .sensoryFeedback(
115                .impact(weight: .light),
116                trigger: hapticsEnabled && !isEditing && pullToCreate.pullOffset >= pullCreateThreshold
117            ) { old, new in
118                !old && new
119            }
120            .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled && pullUpOffset >= pullClearThreshold) { old, new in
121                !old && new
122            }
123    }
124
125    private func handlePullToCreateScrollPhaseChange(from oldPhase: ScrollPhase, to newPhase: ScrollPhase) {
126        guard !isEditing else { return }
127        let action = pullToCreate.handlePhaseChange(
128            from: oldPhase,
129            to: newPhase,
130            pullThreshold: pullCreateThreshold,
131            flickThreshold: flickThreshold
132        )
133
134        guard oldPhase == .interacting, newPhase != .interacting else { return }
135
136        switch action {
137        case .createItem:
138            var transaction = Transaction(animation: nil)
139            transaction.disablesAnimations = true
140            withTransaction(transaction) {
141                _ = onCreateItemAtTop()
142            }
143        case .collapseIndicator:
144            withAnimation(.spring(response: 0.22, dampingFraction: 1.0)) {
145                pullToCreate.indicatorOffset = 0
146            }
147        case .none:
148            break
149        }
150    }
151
152    private func handlePullToClearRelease() {
153        guard hasCompletedItems, pullUpOffset >= pullClearThreshold else {
154            pullUpOffset = 0
155            return
156        }
157        pullUpOffset = 0
158        onClearCompleted()
159    }
160}
161
162extension View {
163    func pullGestures(
164        pullToCreate: Binding<ItemListView.PullToCreateState>,
165        pullUpOffset: Binding<CGFloat>,
166        isEditing: Bool,
167        hasCompletedItems: Bool,
168        pullCreateThreshold: CGFloat,
169        flickThreshold: CGFloat,
170        pullClearThreshold: CGFloat,
171        onCreateItemAtTop: @escaping () -> UUID,
172        onClearCompleted: @escaping () -> Void
173    ) -> some View {
174        modifier(
175            PullGesturesModifier(
176                pullToCreate: pullToCreate,
177                pullUpOffset: pullUpOffset,
178                isEditing: isEditing,
179                hasCompletedItems: hasCompletedItems,
180                pullCreateThreshold: pullCreateThreshold,
181                flickThreshold: flickThreshold,
182                pullClearThreshold: pullClearThreshold,
183                onCreateItemAtTop: onCreateItemAtTop,
184                onClearCompleted: onClearCompleted
185            )
186        )
187    }
188}