ItemListView+PullGestures.swift (6656B)
1 import SwiftUI 2 3 extension 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 65 private 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 162 extension 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 }