listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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 }