Proyectos de Subversion Iphone Microlearning - Inconcert

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
//
2
//  RefreshableScrollView.swift
3
//  twogetskills
4
//
5
//  Created by Efrain Yanez Recanatini on 6/21/22.
6
//
7
 
8
import SwiftUI
9
 
10
// There are two type of positioning views - one that scrolls with the content,
11
// and one that stays fixed
12
private enum PositionType {
13
  case fixed, moving
14
}
15
 
16
// This struct is the currency of the Preferences, and has a type
17
// (fixed or moving) and the actual Y-axis value.
18
// It's Equatable because Swift requires it to be.
19
private struct Position: Equatable {
20
  let type: PositionType
21
  let y: CGFloat
22
}
23
 
24
// This might seem weird, but it's necessary due to the funny nature of
25
// how Preferences work. We can't just store the last position and merge
26
// it with the next one - instead we have a queue of all the latest positions.
27
private struct PositionPreferenceKey: PreferenceKey {
28
  typealias Value = [Position]
29
 
30
  static var defaultValue = [Position]()
31
 
32
  static func reduce(value: inout [Position], nextValue: () -> [Position]) {
33
    value.append(contentsOf: nextValue())
34
  }
35
}
36
 
37
private struct PositionIndicator: View {
38
  let type: PositionType
39
 
40
  var body: some View {
41
    GeometryReader { proxy in
42
        // the View itself is an invisible Shape that fills as much as possible
43
        Color.clear
44
          // Compute the top Y position and emit it to the Preferences queue
45
          .preference(key: PositionPreferenceKey.self, value: [Position(type: type, y: proxy.frame(in: .global).minY)])
46
     }
47
  }
48
}
49
 
50
// Callback that'll trigger once refreshing is done
51
public typealias RefreshComplete = () -> Void
52
 
53
// The actual refresh action that's called once refreshing starts. It has the
54
// RefreshComplete callback to let the refresh action let the View know
55
// once it's done refreshing.
56
public typealias OnRefresh = (@escaping RefreshComplete) -> Void
57
 
58
// The offset threshold. 68 is a good number, but you can play
59
// with it to your liking.
60
public let defaultRefreshThreshold: CGFloat = 68
61
 
62
// Tracks the state of the RefreshableScrollView - it's either:
63
// 1. waiting for a scroll to happen
64
// 2. has been primed by pulling down beyond THRESHOLD
65
// 3. is doing the refreshing.
66
public enum RefreshState {
67
  case waiting, primed, loading
68
}
69
 
70
// ViewBuilder for the custom progress View, that may render itself
71
// based on the current RefreshState.
72
public typealias RefreshProgressBuilder<Progress: View> = (RefreshState) -> Progress
73
 
74
// Default color of the rectangle behind the progress spinner
75
public let defaultLoadingViewBackgroundColor =
76
    Color(UIColor(hex: Config.COLOR_APP_WINDOW_BACKGROUND) ?? UIColor.systemBackground)
77
 
78
 
79
//Color(UIColor.systemBackground)
80
 
81
public struct RefreshableScrollView<Progress, Content>: View where Progress: View, Content: View {
82
  let showsIndicators: Bool // if the ScrollView should show indicators
83
  let loadingViewBackgroundColor: Color
84
  let threshold: CGFloat // what height do you have to pull down to trigger the refresh
85
  let onRefresh: OnRefresh // the refreshing action
86
  let progress: RefreshProgressBuilder<Progress> // custom progress view
87
  let content: () -> Content // the ScrollView content
88
  @State private var offset: CGFloat = 0
89
  @State private var state = RefreshState.waiting // the current state
90
 
91
  let feedbackGenerator = UINotificationFeedbackGenerator() // haptic feedback
92
 
93
  // We use a custom constructor to allow for usage of a @ViewBuilder for the content
94
  public init(showsIndicators: Bool = true,
95
              loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,
96
              threshold: CGFloat = defaultRefreshThreshold,
97
              onRefresh: @escaping OnRefresh,
98
              @ViewBuilder progress: @escaping RefreshProgressBuilder<Progress>,
99
              @ViewBuilder content: @escaping () -> Content) {
100
    self.showsIndicators = showsIndicators
101
    self.loadingViewBackgroundColor = loadingViewBackgroundColor
102
    self.threshold = threshold
103
    self.onRefresh = onRefresh
104
    self.progress = progress
105
    self.content = content
106
  }
107
 
108
  public var body: some View {
109
    // The root view is a regular ScrollView
110
    ScrollView(showsIndicators: showsIndicators) {
111
      // The ZStack allows us to position the PositionIndicator,
112
      // the content and the loading view, all on top of each other.
113
      ZStack(alignment: .top) {
114
        // The moving positioning indicator, that sits at the top
115
        // of the ScrollView and scrolls down with the content
116
        PositionIndicator(type: .moving)
117
          .frame(height: 0)
118
 
119
         // Your ScrollView content. If we're loading, we want
120
         // to keep it below the loading view, hence the alignmentGuide.
121
         content()
122
           .alignmentGuide(.top, computeValue: { _ in
123
             (state == .loading) ? -threshold + offset : 0
124
            })
125
 
126
          // The loading view. It's offset to the top of the content unless we're loading.
127
          ZStack {
128
            Rectangle()
129
              .foregroundColor(loadingViewBackgroundColor)
130
              .frame(height: threshold)
131
            progress(state)
132
          }.offset(y: (state == .loading) ? -offset : -threshold)
133
        }
134
      }
135
      // Put a fixed PositionIndicator in the background so that we have
136
      // a reference point to compute the scroll offset.
137
      .background(PositionIndicator(type: .fixed))
138
      // Once the scrolling offset changes, we want to see if there should
139
      // be a state change.
140
      .onPreferenceChange(PositionPreferenceKey.self) { values in
141
        // Compute the offset between the moving and fixed PositionIndicators
142
        let movingY = values.first { $0.type == .moving }?.y ?? 0
143
        let fixedY = values.first { $0.type == .fixed }?.y ?? 0
144
        offset = movingY - fixedY
145
        if state != .loading { // If we're already loading, ignore everything
146
          // Map the preference change action to the UI thread
147
          DispatchQueue.main.async {
148
 
149
 
150
            // If the user pulled down below the threshold, prime the view
151
            if offset > threshold && state == .waiting {
152
              state = .primed
153
              self.feedbackGenerator.notificationOccurred(.success)
154
 
155
            // If the view is primed and we've crossed the threshold again on the
156
            // way back, trigger the refresh
157
            } else if offset < threshold && state == .primed {
158
              state = .loading
159
              onRefresh { // trigger the refreshing callback
160
                // once refreshing is done, smoothly move the loading view
161
                // back to the offset position
162
                withAnimation {
163
                  self.state = .waiting
164
                }
165
              }
166
            }
167
          }
168
        }
169
      }
170
  }
171
}
172
 
173
// Extension that uses default RefreshActivityIndicator so that you don't have to
174
// specify it every time.
175
public extension RefreshableScrollView where Progress == RefreshActivityIndicator {
176
    init(showsIndicators: Bool = true,
177
         loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,
178
         threshold: CGFloat = defaultRefreshThreshold,
179
         onRefresh: @escaping OnRefresh,
180
         @ViewBuilder content: @escaping () -> Content) {
181
        self.init(showsIndicators: showsIndicators,
182
                  loadingViewBackgroundColor: loadingViewBackgroundColor,
183
                  threshold: threshold,
184
                  onRefresh: onRefresh,
185
                  progress: { state in
186
                    RefreshActivityIndicator(isAnimating: state == .loading) {
187
                        $0.hidesWhenStopped = false
188
                    }
189
                 },
190
                 content: content)
191
    }
192
}
193
 
194
// Wraps a UIActivityIndicatorView as a loading spinner that works on all SwiftUI versions.
195
public struct RefreshActivityIndicator: UIViewRepresentable {
196
  public typealias UIView = UIActivityIndicatorView
197
  public var isAnimating: Bool = true
198
  public var configuration = { (indicator: UIView) in }
199
 
200
  public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) {
201
    self.isAnimating = isAnimating
202
    if let configuration = configuration {
203
      self.configuration = configuration
204
    }
205
  }
206
 
207
  public func makeUIView(context: UIViewRepresentableContext<Self>) -> UIView {
208
    UIView()
209
  }
210
 
211
  public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<Self>) {
212
    isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
213
    configuration(uiView)
214
  }
215
}
216
 
217
#if compiler(>=5.5)
218
// Allows using RefreshableScrollView with an async block.
219
@available(iOS 15.0, *)
220
public extension RefreshableScrollView {
221
    init(showsIndicators: Bool = true,
222
         loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,
223
         threshold: CGFloat = defaultRefreshThreshold,
224
         action: @escaping @Sendable () async -> Void,
225
         @ViewBuilder progress: @escaping RefreshProgressBuilder<Progress>,
226
         @ViewBuilder content: @escaping () -> Content) {
227
        self.init(showsIndicators: showsIndicators,
228
                  loadingViewBackgroundColor: loadingViewBackgroundColor,
229
                  threshold: threshold,
230
                  onRefresh: { refreshComplete in
231
                    Task {
232
                        await action()
233
                        refreshComplete()
234
                    }
235
                },
236
                  progress: progress,
237
                  content: content)
238
    }
239
}
240
#endif
241
 
242
public struct RefreshableCompat<Progress>: ViewModifier where Progress: View {
243
    private let showsIndicators: Bool
244
    private let loadingViewBackgroundColor: Color
245
    private let threshold: CGFloat
246
    private let onRefresh: OnRefresh
247
    private let progress: RefreshProgressBuilder<Progress>
248
 
249
    public init(showsIndicators: Bool = true,
250
                loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,
251
                threshold: CGFloat = defaultRefreshThreshold,
252
                onRefresh: @escaping OnRefresh,
253
                @ViewBuilder progress: @escaping RefreshProgressBuilder<Progress>) {
254
        self.showsIndicators = showsIndicators
255
        self.loadingViewBackgroundColor = loadingViewBackgroundColor
256
        self.threshold = threshold
257
        self.onRefresh = onRefresh
258
        self.progress = progress
259
    }
260
 
261
    public func body(content: Content) -> some View {
262
        RefreshableScrollView(showsIndicators: showsIndicators,
263
                              loadingViewBackgroundColor: loadingViewBackgroundColor,
264
                              threshold: threshold,
265
                              onRefresh: onRefresh,
266
                              progress: progress) {
267
            content
268
        }
269
    }
270
}
271
 
272
#if compiler(>=5.5)
273
@available(iOS 15.0, *)
274
public extension List {
275
    @ViewBuilder func refreshableCompat<Progress: View>(showsIndicators: Bool = true,
276
                                                        loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,
277
                                                        threshold: CGFloat = defaultRefreshThreshold,
278
                                                        onRefresh: @escaping OnRefresh,
279
                                                        @ViewBuilder progress: @escaping RefreshProgressBuilder<Progress>) -> some View {
280
        if #available(iOS 15.0, macOS 12.0, *) {
281
            self.refreshable {
282
                await withCheckedContinuation { cont in
283
                    onRefresh {
284
                        cont.resume()
285
                    }
286
                }
287
            }
288
        } else {
289
            self.modifier(RefreshableCompat(showsIndicators: showsIndicators,
290
                                            loadingViewBackgroundColor: loadingViewBackgroundColor,
291
                                            threshold: threshold,
292
                                            onRefresh: onRefresh,
293
                                            progress: progress))
294
        }
295
    }
296
}
297
#endif
298
 
299
public extension View {
300
    @ViewBuilder func refreshableCompat<Progress: View>(showsIndicators: Bool = true,
301
                                                        loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,
302
                                                        threshold: CGFloat = defaultRefreshThreshold,
303
                                                        onRefresh: @escaping OnRefresh,
304
                                                        @ViewBuilder progress: @escaping RefreshProgressBuilder<Progress>) -> some View {
305
        self.modifier(RefreshableCompat(showsIndicators: showsIndicators,
306
                                        loadingViewBackgroundColor: loadingViewBackgroundColor,
307
                                        threshold: threshold,
308
                                        onRefresh: onRefresh,
309
                                        progress: progress))
310
    }
311
}
312
 
313
struct TestView: View {
314
  @State private var now = Date()
315
 
316
  var body: some View {
317
    RefreshableScrollView(
318
      onRefresh: { done in
319
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
320
          self.now = Date()
321
          done()
322
        }
323
      }) {
324
        VStack {
325
          ForEach(1..<20) {
326
            Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)")
327
               .padding(.bottom, 10)
328
           }
329
         }.padding()
330
       }
331
     }
332
}
333
 
334
struct TestViewWithLargerThreshold: View {
335
  @State private var now = Date()
336
 
337
  var body: some View {
338
    RefreshableScrollView(
339
      threshold: defaultRefreshThreshold * 3,
340
      onRefresh: { done in
341
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
342
          self.now = Date()
343
          done()
344
        }
345
      }) {
346
        VStack {
347
          ForEach(1..<20) {
348
            Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)")
349
               .padding(.bottom, 10)
350
           }
351
         }.padding()
352
       }
353
     }
354
}
355
 
356
struct TestViewWithCustomProgress: View {
357
    @State private var now = Date()
358
 
359
    var body: some View {
360
       RefreshableScrollView(onRefresh: { done in
361
          DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
362
            self.now = Date()
363
            done()
364
          }
365
        },
366
                             progress: { state in
367
           if state == .waiting {
368
               Text("Pull me down...")
369
           } else if state == .primed {
370
               Text("Now release!")
371
           } else {
372
               Text("Working...")
373
           }
374
       }
375
       ) {
376
          VStack {
377
            ForEach(1..<20) {
378
              Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)")
379
                 .padding(.bottom, 10)
380
             }
381
           }.padding()
382
         }
383
       }
384
  }
385
 
386
#if compiler(>=5.5)
387
@available(iOS 15, *)
388
struct TestViewWithAsync: View {
389
  @State private var now = Date()
390
 
391
  var body: some View {
392
     RefreshableScrollView(action: {
393
         await Task.sleep(3_000_000_000)
394
         now = Date()
395
     }, progress: { state in
396
         RefreshActivityIndicator(isAnimating: state == .loading) {
397
             $0.hidesWhenStopped = false
398
         }
399
     }) {
400
        VStack {
401
          ForEach(1..<20) {
402
            Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)")
403
               .padding(.bottom, 10)
404
           }
405
         }.padding()
406
       }
407
     }
408
}
409
#endif
410
 
411
struct TestViewCompat: View {
412
    @State private var now = Date()
413
 
414
  var body: some View {
415
      VStack {
416
          ForEach(1..<20) {
417
          Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)")
418
            .padding(.bottom, 10)
419
        }
420
      }
421
      .refreshableCompat(showsIndicators: false,
422
                         onRefresh: { done in
423
          DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
424
            self.now = Date()
425
            done()
426
          }
427
      }, progress: { state in
428
          RefreshActivityIndicator(isAnimating: state == .loading) {
429
              $0.hidesWhenStopped = false
430
          }
431
      })
432
 
433
   }
434
}
435
 
436
struct TestView_Previews: PreviewProvider {
437
    static var previews: some View {
438
        TestView()
439
    }
440
}
441
 
442
struct TestViewWithLargerThreshold_Previews: PreviewProvider {
443
    static var previews: some View {
444
        TestViewWithLargerThreshold()
445
    }
446
}
447
 
448
struct TestViewWithCustomProgress_Previews: PreviewProvider {
449
    static var previews: some View {
450
        TestViewWithCustomProgress()
451
    }
452
}
453
 
454
#if compiler(>=5.5)
455
@available(iOS 15, *)
456
struct TestViewWithAsync_Previews: PreviewProvider {
457
    static var previews: some View {
458
        TestViewWithAsync()
459
    }
460
}
461
#endif
462
 
463
struct TestViewCompat_Previews: PreviewProvider {
464
    static var previews: some View {
465
        TestViewCompat()
466
    }
467
}