@momo-kits/native-kits 0.0.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CODE_OF_CONDUCT.md +133 -0
  2. package/CONTRIBUTING.md +114 -0
  3. package/LICENSE +20 -0
  4. package/README.md +6 -0
  5. package/android/build.gradle +82 -0
  6. package/android/gradle.properties +5 -0
  7. package/android/src/main/AndroidManifest.xml +4 -0
  8. package/android/src/main/java/com/momoplatform/nativekits/NativeKitsPackage.kt +17 -0
  9. package/android/src/main/java/com/momoplatform/nativekits/NativeKitsViewManager.kt +20 -0
  10. package/example/ios/Example/Assets.xcassets/AppIcon.appiconset/Contents.json +63 -0
  11. package/example/ios/Example/Assets.xcassets/Background.colorset/Contents.json +38 -0
  12. package/example/ios/Example/Assets.xcassets/Border.colorset/Contents.json +38 -0
  13. package/example/ios/Example/Assets.xcassets/Card.colorset/Contents.json +38 -0
  14. package/example/ios/Example/Assets.xcassets/Contents.json +6 -0
  15. package/example/ios/Example/Assets.xcassets/Error.colorset/Contents.json +38 -0
  16. package/example/ios/Example/Assets.xcassets/Primary.colorset/Contents.json +38 -0
  17. package/example/ios/Example/Assets.xcassets/PrimaryDark.colorset/Contents.json +38 -0
  18. package/example/ios/Example/Assets.xcassets/PrimaryLight.colorset/Contents.json +38 -0
  19. package/example/ios/Example/Assets.xcassets/Secondary.colorset/Contents.json +38 -0
  20. package/example/ios/Example/Assets.xcassets/Success.colorset/Contents.json +38 -0
  21. package/example/ios/Example/Assets.xcassets/Text.colorset/Contents.json +38 -0
  22. package/example/ios/Example/Assets.xcassets/TextSecondary.colorset/Contents.json +38 -0
  23. package/example/ios/Example/Assets.xcassets/Warning.colorset/Contents.json +38 -0
  24. package/example/ios/Example/ComponentDetail.swift +35 -0
  25. package/example/ios/Example/ComponentItem.swift +24 -0
  26. package/example/ios/Example/ComponentModel.swift +31 -0
  27. package/example/ios/Example/ContentView.swift +53 -0
  28. package/example/ios/Example/Example.entitlements +10 -0
  29. package/example/ios/Example/Foundation/ButtonDemo.swift +80 -0
  30. package/example/ios/Example/Foundation/Popup/ActionSheets.swift +136 -0
  31. package/example/ios/Example/Foundation/Popup/Floats.swift +127 -0
  32. package/example/ios/Example/Foundation/Popup/Popups.swift +101 -0
  33. package/example/ios/Example/Foundation/PopupDemo.swift +366 -0
  34. package/example/ios/Example/Foundation/TypographyDemo.swift +53 -0
  35. package/example/ios/Example/Foundation.swift +26 -0
  36. package/example/ios/Example/Info.plist +18 -0
  37. package/example/ios/Example/Library/SwipeableDemo.swift +105 -0
  38. package/example/ios/Example/Library.swift +26 -0
  39. package/example/ios/Example/Main.swift +11 -0
  40. package/example/ios/Example/Overview.swift +20 -0
  41. package/example/ios/Example/Preview Content/Preview Assets.xcassets/Contents.json +6 -0
  42. package/example/ios/Example/Resource/SFProText-Black.ttf +0 -0
  43. package/example/ios/Example/Resource/SFProText-Bold.ttf +0 -0
  44. package/example/ios/Example/Resource/SFProText-Heavy.ttf +0 -0
  45. package/example/ios/Example/Resource/SFProText-Light.ttf +0 -0
  46. package/example/ios/Example/Resource/SFProText-Medium.ttf +0 -0
  47. package/example/ios/Example/Resource/SFProText-Regular.ttf +0 -0
  48. package/example/ios/Example/Resource/SFProText-Semibold.ttf +0 -0
  49. package/example/ios/Example/Resource/SFProText-Thin.ttf +0 -0
  50. package/example/ios/Example/Resource/SFProText-Ultralight.ttf +0 -0
  51. package/example/ios/Example/Resource/foundation.json +33 -0
  52. package/example/ios/Example/Resource/library.json +8 -0
  53. package/example/ios/Example/Utils.swift +30 -0
  54. package/example/ios/Example/ViewModifier/DarkViewModifier.swift +22 -0
  55. package/example/ios/Example.xcodeproj/project.pbxproj +565 -0
  56. package/example/ios/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  57. package/example/ios/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  58. package/example/ios/Example.xcodeproj/project.xcworkspace/xcuserdata/wem.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
  59. package/example/ios/Example.xcodeproj/xcuserdata/wem.xcuserdatad/xcschemes/xcschememanagement.plist +14 -0
  60. package/example/ios/Example.xcworkspace/contents.xcworkspacedata +10 -0
  61. package/example/ios/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  62. package/example/ios/Example.xcworkspace/xcuserdata/wem.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
  63. package/example/ios/Example.xcworkspace/xcuserdata/wem.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +6 -0
  64. package/example/ios/Podfile +6 -0
  65. package/example/ios/Podfile.lock +16 -0
  66. package/example/ios/Pods/Local Podspecs/MoMoUIKits.podspec.json +29 -0
  67. package/example/ios/Pods/Manifest.lock +16 -0
  68. package/example/ios/Pods/Pods.xcodeproj/project.pbxproj +661 -0
  69. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/wem.xcuserdatad/xcschemes/MoMoUIKits.xcscheme +58 -0
  70. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/wem.xcuserdatad/xcschemes/Pods-Example.xcscheme +58 -0
  71. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/wem.xcuserdatad/xcschemes/xcschememanagement.plist +25 -0
  72. package/example/ios/Pods/Target Support Files/MoMoUIKits/MoMoUIKits-dummy.m +5 -0
  73. package/example/ios/Pods/Target Support Files/MoMoUIKits/MoMoUIKits-prefix.pch +12 -0
  74. package/example/ios/Pods/Target Support Files/MoMoUIKits/MoMoUIKits-umbrella.h +16 -0
  75. package/example/ios/Pods/Target Support Files/MoMoUIKits/MoMoUIKits.debug.xcconfig +12 -0
  76. package/example/ios/Pods/Target Support Files/MoMoUIKits/MoMoUIKits.modulemap +6 -0
  77. package/example/ios/Pods/Target Support Files/MoMoUIKits/MoMoUIKits.release.xcconfig +12 -0
  78. package/example/ios/Pods/Target Support Files/Pods-Example/Pods-Example-acknowledgements.markdown +3 -0
  79. package/example/ios/Pods/Target Support Files/Pods-Example/Pods-Example-acknowledgements.plist +29 -0
  80. package/example/ios/Pods/Target Support Files/Pods-Example/Pods-Example-dummy.m +5 -0
  81. package/example/ios/Pods/Target Support Files/Pods-Example/Pods-Example-umbrella.h +16 -0
  82. package/example/ios/Pods/Target Support Files/Pods-Example/Pods-Example.debug.xcconfig +14 -0
  83. package/example/ios/Pods/Target Support Files/Pods-Example/Pods-Example.modulemap +6 -0
  84. package/example/ios/Pods/Target Support Files/Pods-Example/Pods-Example.release.xcconfig +14 -0
  85. package/ios/Button/Button.swift +131 -0
  86. package/ios/Colors+Spacing/Colors.swift +170 -0
  87. package/ios/Colors+Spacing/Radius.swift +11 -0
  88. package/ios/Colors+Spacing/Spacing.swift +12 -0
  89. package/ios/Extensions/Color++.swift +25 -0
  90. package/ios/OTPKeyboard/KeyboardButton.swift +33 -0
  91. package/ios/OTPKeyboard/OTPKeyboard.swift +145 -0
  92. package/ios/Popup/Modifiers.swift +172 -0
  93. package/ios/Popup/PopupView.swift +397 -0
  94. package/ios/Popup/Utils.swift +186 -0
  95. package/ios/Swipeable/SwipeCell.swift +270 -0
  96. package/ios/Swipeable/SwipeCellModel.swift +86 -0
  97. package/ios/Theme.md +18 -0
  98. package/ios/Typography/Typography.swift +30 -0
  99. package/ios/native-kits.podspec +15 -0
  100. package/package.json +8 -0
  101. package/publish.sh +28 -0
@@ -0,0 +1,397 @@
1
+
2
+
3
+ import SwiftUI
4
+
5
+ public enum DismissSource {
6
+ case binding // set isPresented to false ot item to nil
7
+ case tapInside
8
+ case tapOutside
9
+ case drag
10
+ case autohide
11
+ }
12
+
13
+ public struct Popup<Item: Equatable, PopupContent: View>: ViewModifier {
14
+
15
+ init(isPresented: Binding<Bool>,
16
+ type: PopupType,
17
+ position: Position,
18
+ animation: Animation,
19
+ autohideIn: Double?,
20
+ dragToDismiss: Bool,
21
+ closeOnTap: Bool,
22
+ closeOnTapOutside: Bool,
23
+ backgroundColor: Color,
24
+ dismissCallback: @escaping (DismissSource) -> (),
25
+ view: @escaping () -> PopupContent) {
26
+ self._isPresented = isPresented
27
+ self._item = .constant(nil)
28
+ self.type = type
29
+ self.position = position
30
+ self.animation = animation
31
+ self.autohideIn = autohideIn
32
+ self.dragToDismiss = dragToDismiss
33
+ self.closeOnTap = closeOnTap
34
+ self.closeOnTapOutside = closeOnTapOutside
35
+ self.backgroundColor = backgroundColor
36
+ self.dismissCallback = dismissCallback
37
+ self.view = view
38
+ self.isPresentedRef = ClassReference(self.$isPresented)
39
+ self.itemRef = ClassReference(self.$item)
40
+ }
41
+
42
+ init(item: Binding<Item?>,
43
+ type: PopupType,
44
+ position: Position,
45
+ animation: Animation,
46
+ autohideIn: Double?,
47
+ dragToDismiss: Bool,
48
+ closeOnTap: Bool,
49
+ closeOnTapOutside: Bool,
50
+ backgroundColor: Color,
51
+ dismissCallback: @escaping (DismissSource) -> (),
52
+ view: @escaping () -> PopupContent) {
53
+ self._isPresented = .constant(false)
54
+ self._item = item
55
+ self.type = type
56
+ self.position = position
57
+ self.animation = animation
58
+ self.autohideIn = autohideIn
59
+ self.dragToDismiss = dragToDismiss
60
+ self.closeOnTap = closeOnTap
61
+ self.closeOnTapOutside = closeOnTapOutside
62
+ self.backgroundColor = backgroundColor
63
+ self.dismissCallback = dismissCallback
64
+ self.view = view
65
+ self.isPresentedRef = ClassReference(self.$isPresented)
66
+ self.itemRef = ClassReference(self.$item)
67
+ }
68
+
69
+ public enum PopupType {
70
+
71
+ case `default`
72
+ case toast
73
+ case floater(verticalPadding: CGFloat = 10, useSafeAreaInset: Bool = true)
74
+
75
+ func shouldBeCentered() -> Bool {
76
+ switch self {
77
+ case .`default`:
78
+ return true
79
+ default:
80
+ return false
81
+ }
82
+ }
83
+ }
84
+
85
+ public enum Position {
86
+ case top
87
+ case bottom
88
+ case center
89
+ }
90
+
91
+ private enum DragState {
92
+ case inactive
93
+ case dragging(translation: CGSize)
94
+
95
+ var translation: CGSize {
96
+ switch self {
97
+ case .inactive:
98
+ return .zero
99
+ case .dragging(let translation):
100
+ return translation
101
+ }
102
+ }
103
+
104
+ var isDragging: Bool {
105
+ switch self {
106
+ case .inactive:
107
+ return false
108
+ case .dragging:
109
+ return true
110
+ }
111
+ }
112
+ }
113
+
114
+ // MARK: - Public Properties
115
+
116
+ /// Tells if the sheet should be presented or not
117
+ @Binding var isPresented: Bool
118
+ @Binding var item: Item?
119
+
120
+ var sheetPresented: Bool {
121
+ item != nil || isPresented
122
+ }
123
+
124
+ var type: PopupType
125
+ var position: Position
126
+
127
+ var animation: Animation
128
+
129
+ /// If nil - never hides on its own
130
+ var autohideIn: Double?
131
+
132
+ /// Should close on tap - default is `true`
133
+ var closeOnTap: Bool
134
+
135
+ /// Should allow dismiss by dragging
136
+ var dragToDismiss: Bool
137
+
138
+ /// Should close on tap outside - default is `true`
139
+ var closeOnTapOutside: Bool
140
+
141
+ /// Background color for outside area - default is `Color.clear`
142
+ var backgroundColor: Color
143
+
144
+ /// is called on any close action
145
+ var dismissCallback: (DismissSource) -> ()
146
+
147
+ var view: () -> PopupContent
148
+
149
+ /// holder for autohiding dispatch work (to be able to cancel it when needed)
150
+ var dispatchWorkHolder = DispatchWorkHolder()
151
+
152
+ // MARK: - Private Properties
153
+
154
+ @Environment(\.safeAreaInsets) private var safeAreaInsets
155
+
156
+ /// Class reference for capturing a weak reference later in dispatch work holder.
157
+ private var isPresentedRef: ClassReference<Binding<Bool>>?
158
+ private var itemRef: ClassReference<Binding<Item?>>?
159
+
160
+ /// The rect and safe area of the hosting controller
161
+ @State private var presenterContentRect: CGRect = .zero
162
+
163
+ /// The rect and safe area of popup content
164
+ @State private var sheetContentRect: CGRect = .zero
165
+
166
+ /// Drag to dismiss gesture state
167
+ @GestureState private var dragState = DragState.inactive
168
+
169
+ /// Last position for drag gesture
170
+ @State private var lastDragPosition: CGFloat = 0
171
+
172
+ /// Trigger popup showing/hiding animations and...
173
+ @State private var shouldShowContent: Bool = false
174
+
175
+ /// ... once hiding animation is finished remove popup from the memory using this flag
176
+ @State private var showContent: Bool = false
177
+
178
+ /// Set dismiss souce to pass to dismiss callback
179
+ @State private var dismissSource: DismissSource?
180
+
181
+ /// The offset when the popup is displayed
182
+ private var displayedOffset: CGFloat {
183
+ switch type {
184
+ case .`default`:
185
+ return -presenterContentRect.midY + screenHeight/2
186
+ case .toast:
187
+ if position == .bottom {
188
+ return presenterContentRect.minY + safeAreaInsets.bottom + presenterContentRect.height - presenterContentRect.midY - sheetContentRect.height/2
189
+ } else {
190
+ return presenterContentRect.minY - safeAreaInsets.top - presenterContentRect.midY + sheetContentRect.height/2
191
+ }
192
+ case .floater(let verticalPadding, let useSafeAreaInset):
193
+ if position == .bottom {
194
+ return presenterContentRect.minY + safeAreaInsets.bottom + presenterContentRect.height - presenterContentRect.midY - sheetContentRect.height/2 - verticalPadding + (useSafeAreaInset ? -safeAreaInsets.bottom : 0)
195
+ } else {
196
+ return presenterContentRect.minY - safeAreaInsets.top - presenterContentRect.midY + sheetContentRect.height/2 + verticalPadding + (useSafeAreaInset ? safeAreaInsets.top : 0)
197
+ }
198
+ }
199
+ }
200
+
201
+ /// The offset when the popup is hidden
202
+ private var hiddenOffset: CGFloat {
203
+ if position == .center {
204
+ return displayedOffset
205
+ } else if position == .top {
206
+ if presenterContentRect.isEmpty {
207
+ return -1000
208
+ }
209
+ return -presenterContentRect.midY - sheetContentRect.height/2 - 5
210
+ } else {
211
+ if presenterContentRect.isEmpty {
212
+ return 1000
213
+ }
214
+ return screenHeight - presenterContentRect.midY + sheetContentRect.height/2 + 5
215
+ }
216
+ }
217
+
218
+ /// The current offset, based on the **presented** property
219
+ private var currentOffset: CGFloat {
220
+ return shouldShowContent ? displayedOffset : hiddenOffset
221
+ }
222
+
223
+ /// The current background opacity, based on the **presented** property
224
+ private var currentBackgroundOpacity: Double {
225
+ return shouldShowContent ? 1.0 : 0.0
226
+ }
227
+
228
+ private var screenSize: CGSize {
229
+ #if os(iOS) || os(tvOS)
230
+ return UIScreen.main.bounds.size
231
+ #elseif os(watchOS)
232
+ return WKInterfaceDevice.current().screenBounds.size
233
+ #else
234
+ return NSScreen.main?.frame.size ?? .zero
235
+ #endif
236
+ }
237
+
238
+ private var screenHeight: CGFloat {
239
+ screenSize.height
240
+ }
241
+
242
+ // MARK: - Content Builders
243
+
244
+ public func body(content: Content) -> some View {
245
+ main(content: content)
246
+ .onAppear {
247
+ appearAction(sheetPresented: sheetPresented)
248
+ }
249
+ .valueChanged(value: isPresented) { isPresented in
250
+ appearAction(sheetPresented: isPresented)
251
+ if isPresented {
252
+ dismissSource = .binding
253
+ }
254
+ if !isPresented {
255
+ dismissCallback(dismissSource ?? .autohide)
256
+ }
257
+ }
258
+ .valueChanged(value: item) { item in
259
+ appearAction(sheetPresented: item != nil)
260
+ if item != nil {
261
+ dismissSource = .binding
262
+ }
263
+ if item == nil {
264
+ dismissCallback(dismissSource ?? .autohide)
265
+ }
266
+ }
267
+ }
268
+
269
+ private func main(content: Content) -> some View {
270
+ ZStack {
271
+ content
272
+ .frameGetter($presenterContentRect)
273
+
274
+ if showContent {
275
+ popupBackground()
276
+ }
277
+ }
278
+ .overlay(
279
+ Group {
280
+ if showContent {
281
+ sheet()
282
+ }
283
+ }
284
+ )
285
+ }
286
+
287
+ private func popupBackground() -> some View {
288
+ backgroundColor
289
+ .applyIf(closeOnTapOutside) { view in
290
+ view.contentShape(Rectangle())
291
+ }
292
+ .addTapIfNotTV(if: closeOnTapOutside) {
293
+ dismissSource = .tapOutside
294
+ dismiss()
295
+ }
296
+ .edgesIgnoringSafeArea(.all)
297
+ .opacity(currentBackgroundOpacity)
298
+ .animation(.linear)
299
+ }
300
+
301
+ /// This is the builder for the sheet content
302
+ func sheet() -> some View {
303
+
304
+ // if needed, dispatch autohide and cancel previous one
305
+ if let autohideIn = autohideIn {
306
+ dispatchWorkHolder.work?.cancel()
307
+
308
+ // Weak reference to avoid the work item capturing the struct,
309
+ // which would create a retain cycle with the work holder itself.
310
+
311
+ dispatchWorkHolder.work = DispatchWorkItem(block: { [weak isPresentedRef, weak itemRef] in
312
+ isPresentedRef?.value.wrappedValue = false
313
+ itemRef?.value.wrappedValue = nil
314
+ dismissSource = .autohide
315
+ })
316
+ if sheetPresented, let work = dispatchWorkHolder.work {
317
+ DispatchQueue.main.asyncAfter(deadline: .now() + autohideIn, execute: work)
318
+ }
319
+ }
320
+
321
+ let sheet = ZStack {
322
+ self.view()
323
+ .addTapIfNotTV(if: closeOnTap) {
324
+ dismissSource = .tapInside
325
+ dismiss()
326
+ }
327
+ .frameGetter($sheetContentRect)
328
+ .offset(y: currentOffset)
329
+ .onAnimationCompleted(for: currentOffset) {
330
+ showContent = shouldShowContent
331
+ }
332
+ .animation(animation)
333
+ }
334
+
335
+ #if !os(tvOS)
336
+ let drag = DragGesture()
337
+ .updating($dragState) { drag, state, _ in
338
+ state = .dragging(translation: drag.translation)
339
+ }
340
+ .onEnded(onDragEnded)
341
+
342
+ return sheet
343
+ .applyIf(dragToDismiss) {
344
+ $0.offset(y: dragOffset())
345
+ .simultaneousGesture(drag)
346
+ }
347
+ #else
348
+ return sheet
349
+ #endif
350
+ }
351
+
352
+ #if !os(tvOS)
353
+ func dragOffset() -> CGFloat {
354
+ if (position == .bottom && dragState.translation.height > 0) ||
355
+ (position == .top && dragState.translation.height < 0) {
356
+ return dragState.translation.height
357
+ }
358
+ return lastDragPosition
359
+ }
360
+
361
+ private func onDragEnded(drag: DragGesture.Value) {
362
+ let reference = sheetContentRect.height / 3
363
+ if (position == .bottom && drag.translation.height > reference) ||
364
+ (position == .top && drag.translation.height < -reference) {
365
+ lastDragPosition = drag.translation.height
366
+ withAnimation {
367
+ lastDragPosition = 0
368
+ }
369
+ dismissSource = .drag
370
+ dismiss()
371
+ }
372
+ }
373
+ #endif
374
+
375
+ private func appearAction(sheetPresented: Bool) {
376
+ if sheetPresented {
377
+ showContent = true // immediately load popup body
378
+ DispatchQueue.main.async {
379
+ shouldShowContent = true // this will cause currentOffset change thus triggering the showing animation
380
+ }
381
+ } else {
382
+ shouldShowContent = false // this will cause currentOffset change thus triggering the hiding animation
383
+ // unload popup body after hiding animation is done (see sheet())
384
+ }
385
+ }
386
+
387
+ private func dismiss() {
388
+ dispatchWorkHolder.work?.cancel()
389
+ isPresented = false
390
+ item = nil
391
+ if position == .center {
392
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
393
+ showContent = false
394
+ }
395
+ }
396
+ }
397
+ }
@@ -0,0 +1,186 @@
1
+
2
+
3
+ import SwiftUI
4
+ import Combine
5
+
6
+ final class DispatchWorkHolder {
7
+ var work: DispatchWorkItem?
8
+ }
9
+
10
+ final class ClassReference<T> {
11
+ var value: T
12
+
13
+ init(_ value: T) {
14
+ self.value = value
15
+ }
16
+ }
17
+
18
+ extension View {
19
+
20
+ @ViewBuilder
21
+ func valueChanged<T: Equatable>(value: T, onChange: @escaping (T) -> Void) -> some View {
22
+ if #available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) {
23
+ self.onChange(of: value, perform: onChange)
24
+ } else {
25
+ self.onReceive(Just(value)) { value in
26
+ onChange(value)
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ public extension View {
33
+ @ViewBuilder
34
+ func applyIf<T: View>(_ condition: Bool, apply: (Self) -> T) -> some View {
35
+ if condition {
36
+ apply(self)
37
+ } else {
38
+ self
39
+ }
40
+ }
41
+
42
+ @ViewBuilder
43
+ func addTapIfNotTV(if condition: Bool, onTap: @escaping ()->()) -> some View {
44
+ #if os(tvOS)
45
+ self
46
+ #else
47
+ if condition {
48
+ self.simultaneousGesture(
49
+ TapGesture().onEnded {
50
+ onTap()
51
+ }
52
+ )
53
+ } else {
54
+ self
55
+ }
56
+ #endif
57
+ }
58
+ }
59
+
60
+ struct FrameGetter: ViewModifier {
61
+
62
+ @Binding var frame: CGRect
63
+
64
+ func body(content: Content) -> some View {
65
+ content
66
+ .background(
67
+ GeometryReader { proxy -> AnyView in
68
+ DispatchQueue.main.async {
69
+ let rect = proxy.frame(in: .global)
70
+ // This avoids an infinite layout loop
71
+ if rect.integral != self.frame.integral {
72
+ self.frame = rect
73
+ }
74
+ }
75
+ return AnyView(EmptyView())
76
+ }
77
+ )
78
+ }
79
+ }
80
+
81
+ extension View {
82
+ public func frameGetter(_ frame: Binding<CGRect>) -> some View {
83
+ modifier(FrameGetter(frame: frame))
84
+ }
85
+ }
86
+
87
+ struct AnimationCompletionObserverModifier<Value>: AnimatableModifier where Value: VectorArithmetic {
88
+
89
+ /// While animating, SwiftUI changes the old input value to the new target value using this property. This value is set to the old value until the animation completes.
90
+ var animatableData: Value {
91
+ didSet {
92
+ notifyCompletionIfFinished()
93
+ }
94
+ }
95
+
96
+ /// The target value for which we're observing. This value is directly set once the animation starts. During animation, `animatableData` will hold the oldValue and is only updated to the target value once the animation completes.
97
+ private var targetValue: Value
98
+
99
+ /// The completion callback which is called once the animation completes.
100
+ private var completion: () -> Void
101
+
102
+ init(observedValue: Value, completion: @escaping () -> Void) {
103
+ self.completion = completion
104
+ self.animatableData = observedValue
105
+ targetValue = observedValue
106
+ }
107
+
108
+ /// Verifies whether the current animation is finished and calls the completion callback if true.
109
+ private func notifyCompletionIfFinished() {
110
+ guard animatableData == targetValue else { return }
111
+
112
+ /// Dispatching is needed to take the next runloop for the completion callback.
113
+ /// This prevents errors like "Modifying state during view update, this will cause undefined behavior."
114
+ DispatchQueue.main.async {
115
+ self.completion()
116
+ }
117
+ }
118
+
119
+ func body(content: Content) -> some View {
120
+ /// We're not really modifying the view so we can directly return the original input value.
121
+ return content
122
+ }
123
+ }
124
+
125
+ extension View {
126
+
127
+ /// Calls the completion handler whenever an animation on the given value completes.
128
+ /// - Parameters:
129
+ /// - value: The value to observe for animations.
130
+ /// - completion: The completion callback to call once the animation completes.
131
+ /// - Returns: A modified `View` instance with the observer attached.
132
+ func onAnimationCompleted<Value: VectorArithmetic>(for value: Value, completion: @escaping () -> Void) -> ModifiedContent<Self, AnimationCompletionObserverModifier<Value>> {
133
+ return modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion))
134
+ }
135
+ }
136
+
137
+ #if os(iOS)
138
+
139
+ extension UIApplication {
140
+ var keyWindow: UIWindow? {
141
+ connectedScenes
142
+ .compactMap {
143
+ $0 as? UIWindowScene
144
+ }
145
+ .flatMap {
146
+ $0.windows
147
+ }
148
+ .first {
149
+ $0.isKeyWindow
150
+ }
151
+ }
152
+ }
153
+
154
+ private struct SafeAreaInsetsKey: EnvironmentKey {
155
+ static var defaultValue: EdgeInsets {
156
+ UIApplication.shared.keyWindow?.safeAreaInsets.swiftUiInsets ?? EdgeInsets()
157
+ }
158
+ }
159
+
160
+ extension EnvironmentValues {
161
+ var safeAreaInsets: EdgeInsets {
162
+ self[SafeAreaInsetsKey.self]
163
+ }
164
+ }
165
+
166
+ private extension UIEdgeInsets {
167
+ var swiftUiInsets: EdgeInsets {
168
+ EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right)
169
+ }
170
+ }
171
+
172
+ #else
173
+
174
+ private struct SafeAreaInsetsKey: EnvironmentKey {
175
+ static var defaultValue: EdgeInsets {
176
+ EdgeInsets()
177
+ }
178
+ }
179
+
180
+ extension EnvironmentValues {
181
+ var safeAreaInsets: EdgeInsets {
182
+ self[SafeAreaInsetsKey.self]
183
+ }
184
+ }
185
+
186
+ #endif