aix 0.7.0 → 0.7.1-alpha.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.
- package/Aix.podspec +3 -0
- package/ios/AixKeyboardManager.swift +118 -0
- package/ios/HybridAix.swift +131 -478
- package/ios/HybridAixComposer.swift +1 -1
- package/nitrogen/generated/android/c++/JAixScrollOnFooterSizeUpdate.hpp +5 -5
- package/nitrogen/generated/android/c++/JAixStickToKeyboard.hpp +5 -5
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixScrollOnFooterSizeUpdate.kt +2 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixStickToKeyboard.kt +2 -2
- package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.hpp +14 -14
- package/nitrogen/generated/ios/swift/AixScrollOnFooterSizeUpdate.swift +24 -5
- package/nitrogen/generated/ios/swift/AixStickToKeyboard.swift +24 -5
- package/nitrogen/generated/shared/c++/AixScrollOnFooterSizeUpdate.hpp +5 -5
- package/nitrogen/generated/shared/c++/AixStickToKeyboard.hpp +6 -6
- package/package.json +1 -1
- package/src/dropzone.tsx +2 -0
- package/src/views/aix.nitro.ts +2 -2
package/ios/HybridAix.swift
CHANGED
|
@@ -38,7 +38,6 @@ protocol AixContext: AnyObject {
|
|
|
38
38
|
func reportComposerHeightChange(height: CGFloat)
|
|
39
39
|
|
|
40
40
|
// MARK: - Keyboard State (for composer sticky behavior)
|
|
41
|
-
|
|
42
41
|
/// Current keyboard height
|
|
43
42
|
var keyboardHeight: CGFloat { get }
|
|
44
43
|
|
|
@@ -107,11 +106,9 @@ extension UIView {
|
|
|
107
106
|
}
|
|
108
107
|
|
|
109
108
|
// MARK: - HybridAix (Root Context)
|
|
110
|
-
|
|
111
109
|
class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
112
110
|
|
|
113
111
|
var penultimateCellIndex: Double?
|
|
114
|
-
|
|
115
112
|
var shouldApplyContentInsets: Bool? = nil
|
|
116
113
|
var applyContentInsetDelay: Double? = nil
|
|
117
114
|
var onWillApplyContentInsets: ((_ insets: AixContentInsets) -> Void)? = nil
|
|
@@ -126,15 +123,18 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
126
123
|
}
|
|
127
124
|
}
|
|
128
125
|
|
|
129
|
-
var scrollOnComposerSizeUpdate: AixScrollOnFooterSizeUpdate?
|
|
130
|
-
|
|
131
126
|
var mainScrollViewID: String? {
|
|
132
127
|
didSet {
|
|
133
128
|
guard mainScrollViewID != oldValue else { return }
|
|
134
|
-
|
|
129
|
+
// Reset all scroll view and cell state when ID changes
|
|
135
130
|
removeScrollViewObservers()
|
|
136
131
|
cachedScrollView = nil
|
|
137
|
-
|
|
132
|
+
didScrollToEndInitially = false
|
|
133
|
+
prevIsScrolledNearEnd = nil
|
|
134
|
+
blankView = nil
|
|
135
|
+
cells.removeAllObjects()
|
|
136
|
+
lastReportedBlankViewSize = (size: .zero, index: 0)
|
|
137
|
+
lastCalculatedBlankSize = 0
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
@@ -156,7 +156,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
156
156
|
self.startEvent = KeyboardStartEvent(
|
|
157
157
|
scrollY: event.scrollY,
|
|
158
158
|
isOpening: event.isOpening,
|
|
159
|
-
isInteractive: event.isInteractive,
|
|
160
159
|
interpolateContentOffsetY: nil
|
|
161
160
|
)
|
|
162
161
|
}
|
|
@@ -165,11 +164,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
165
164
|
}
|
|
166
165
|
}
|
|
167
166
|
|
|
168
|
-
private var
|
|
169
|
-
|
|
170
|
-
private var didScrollToEndInitially: Bool {
|
|
171
|
-
return didScrollToEndInitiallyForId == (mainScrollViewID ?? "")
|
|
172
|
-
}
|
|
167
|
+
private var didScrollToEndInitially: Bool = false
|
|
173
168
|
|
|
174
169
|
// MARK: - Inner View
|
|
175
170
|
|
|
@@ -217,7 +212,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
217
212
|
}
|
|
218
213
|
|
|
219
214
|
// MARK: - Private State
|
|
220
|
-
|
|
221
215
|
/// Queued scroll operation waiting for blank view to update
|
|
222
216
|
private var queuedScrollToEnd: QueuedScrollToEnd? = nil
|
|
223
217
|
|
|
@@ -228,32 +222,20 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
228
222
|
/// Cached scroll view reference (weak to avoid retain cycles)
|
|
229
223
|
private weak var cachedScrollView: UIScrollView?
|
|
230
224
|
|
|
231
|
-
/// Flag to track if we've set up the pan gesture observer
|
|
232
|
-
private var didSetupPanGestureObserver = false
|
|
233
|
-
|
|
234
|
-
/// Flag to track if we're currently in an interactive keyboard dismiss
|
|
235
|
-
private var isInInteractiveDismiss = false
|
|
236
|
-
|
|
237
225
|
/// Previous "scrolled near end" state for change detection
|
|
238
226
|
private var prevIsScrolledNearEnd: Bool? = nil
|
|
239
|
-
|
|
227
|
+
|
|
240
228
|
/// KVO observation tokens for scroll view
|
|
241
229
|
private var contentOffsetObservation: NSKeyValueObservation?
|
|
242
230
|
private var contentSizeObservation: NSKeyValueObservation?
|
|
243
231
|
private var boundsObservation: NSKeyValueObservation?
|
|
244
232
|
|
|
245
233
|
// MARK: - Context References (weak to avoid retain cycles)
|
|
246
|
-
|
|
247
|
-
weak var blankView: HybridAixCellView? = nil {
|
|
248
|
-
didSet {
|
|
249
|
-
// Could add observers or callbacks here when blank view changes
|
|
250
|
-
}
|
|
251
|
-
}
|
|
234
|
+
weak var blankView: HybridAixCellView? = nil
|
|
252
235
|
|
|
253
236
|
weak var composerView: HybridAixComposer? = nil
|
|
254
237
|
|
|
255
238
|
// MARK: - Computed Properties
|
|
256
|
-
|
|
257
239
|
/// Find the scroll view within our view hierarchy
|
|
258
240
|
/// We search from the superview (HybridAixComponent) since the scroll view
|
|
259
241
|
/// is a sibling of our inner view, not a child
|
|
@@ -268,25 +250,18 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
268
250
|
var sv: UIScrollView? = nil
|
|
269
251
|
if let scrollViewID = mainScrollViewID, !scrollViewID.isEmpty {
|
|
270
252
|
sv = searchRoot.findScrollView(withIdentifier: scrollViewID)
|
|
271
|
-
if sv != nil {
|
|
272
|
-
print("[Aix] scrollView found by ID '\(scrollViewID)': \(String(describing: sv))")
|
|
273
|
-
}
|
|
274
253
|
}
|
|
275
254
|
|
|
276
255
|
// Fallback to default subview iteration if not found by ID
|
|
277
256
|
if sv == nil {
|
|
278
257
|
sv = searchRoot.findScrollView()
|
|
279
|
-
print("[Aix] scrollView found by iteration: \(String(describing: sv))")
|
|
280
258
|
}
|
|
281
259
|
|
|
282
260
|
cachedScrollView = sv
|
|
283
261
|
|
|
284
|
-
// Set up
|
|
285
|
-
if let scrollView = sv
|
|
286
|
-
// Disable automatic scroll indicator inset adjustment so we can control it manually
|
|
262
|
+
// Set up observers when we find the scroll view
|
|
263
|
+
if let scrollView = sv {
|
|
287
264
|
scrollView.automaticallyAdjustsScrollIndicatorInsets = false
|
|
288
|
-
|
|
289
|
-
setupPanGestureObserver()
|
|
290
265
|
setupScrollViewObservers(scrollView)
|
|
291
266
|
applyScrollIndicatorInsets()
|
|
292
267
|
}
|
|
@@ -322,89 +297,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
322
297
|
boundsObservation = nil
|
|
323
298
|
}
|
|
324
299
|
|
|
325
|
-
/// Set up observer on scroll view's pan gesture to detect interactive keyboard dismiss
|
|
326
|
-
private func setupPanGestureObserver() {
|
|
327
|
-
guard let scrollView = cachedScrollView else { return }
|
|
328
|
-
didSetupPanGestureObserver = true
|
|
329
|
-
|
|
330
|
-
scrollView.panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture(_:)))
|
|
331
|
-
print("[Aix] Pan gesture observer set up")
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/// Clean up pan gesture observer to avoid retain cycles
|
|
335
|
-
private func removePanGestureObserver() {
|
|
336
|
-
guard didSetupPanGestureObserver, let scrollView = cachedScrollView else { return }
|
|
337
|
-
scrollView.panGestureRecognizer.removeTarget(self, action: #selector(handlePanGesture(_:)))
|
|
338
|
-
didSetupPanGestureObserver = false
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/// Handle pan gesture state changes to detect interactive keyboard dismiss
|
|
342
|
-
@objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
|
|
343
|
-
guard let scrollView = cachedScrollView,
|
|
344
|
-
scrollView.keyboardDismissMode == .interactive,
|
|
345
|
-
keyboardHeight > 0 else { return }
|
|
346
|
-
|
|
347
|
-
switch gesture.state {
|
|
348
|
-
case .began, .changed:
|
|
349
|
-
// Check if finger has reached the top of composer (or keyboard if no composer)
|
|
350
|
-
if !isInInteractiveDismiss && isFingerAtComposerTop(gesture: gesture) {
|
|
351
|
-
startInteractiveKeyboardDismiss()
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
case .ended, .cancelled, .failed:
|
|
355
|
-
if isInInteractiveDismiss {
|
|
356
|
-
// The keyboard manager will handle the end via notifications
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
default:
|
|
360
|
-
break
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/// Check if the finger position has reached the top of the composer view
|
|
365
|
-
private func isFingerAtComposerTop(gesture: UIPanGestureRecognizer) -> Bool {
|
|
366
|
-
guard let window = view.window else { return false }
|
|
367
|
-
|
|
368
|
-
// Get finger location in window coordinates
|
|
369
|
-
let fingerLocationInWindow = gesture.location(in: window)
|
|
370
|
-
|
|
371
|
-
// Get the threshold Y position (top of composer, or top of keyboard if no composer)
|
|
372
|
-
let thresholdY: CGFloat
|
|
373
|
-
if let composerView = composerView?.view, let composerWindow = composerView.window {
|
|
374
|
-
// Convert composer's frame to window coordinates
|
|
375
|
-
let composerFrameInWindow = composerView.convert(composerView.bounds, to: composerWindow)
|
|
376
|
-
thresholdY = composerFrameInWindow.minY
|
|
377
|
-
} else {
|
|
378
|
-
// Fallback: use keyboard top position
|
|
379
|
-
let screenHeight = UIScreen.main.bounds.height
|
|
380
|
-
thresholdY = screenHeight - keyboardHeight
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Finger has reached the composer top when its Y >= threshold
|
|
384
|
-
return fingerLocationInWindow.y >= thresholdY
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/// Start tracking an interactive keyboard dismiss
|
|
388
|
-
private func startInteractiveKeyboardDismiss() {
|
|
389
|
-
return // this is totally broken rn, full of false positives
|
|
390
|
-
guard !isInInteractiveDismiss else { return }
|
|
391
|
-
isInInteractiveDismiss = true
|
|
392
|
-
|
|
393
|
-
let scrollY = scrollView?.contentOffset.y ?? 0
|
|
394
|
-
|
|
395
|
-
print("[Aix] Starting interactive keyboard dismiss from height=\(keyboardHeight), scrollY=\(scrollY)")
|
|
396
|
-
|
|
397
|
-
// Calculate proper interpolation values (same as non-interactive close)
|
|
398
|
-
let interpolation = getContentOffsetYWhenClosing(scrollY: scrollY)
|
|
399
|
-
|
|
400
|
-
startEvent = KeyboardStartEvent(
|
|
401
|
-
scrollY: scrollY,
|
|
402
|
-
isOpening: false,
|
|
403
|
-
isInteractive: true,
|
|
404
|
-
interpolateContentOffsetY: interpolation,
|
|
405
|
-
)
|
|
406
|
-
}
|
|
407
|
-
|
|
408
300
|
/// Height of the composer view
|
|
409
301
|
private var composerHeight: CGFloat {
|
|
410
302
|
let h = composerView?.view.bounds.height ?? 0
|
|
@@ -429,33 +321,58 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
429
321
|
return 0
|
|
430
322
|
}
|
|
431
323
|
|
|
324
|
+
/// Cache the last successfully calculated blank size to avoid jumps when cells are temporarily missing
|
|
325
|
+
private var lastCalculatedBlankSize: CGFloat = 0
|
|
326
|
+
|
|
432
327
|
private func calculateBlankSize(keyboardHeight: CGFloat, additionalContentInsetBottom: CGFloat) -> CGFloat {
|
|
433
|
-
guard let scrollView, let blankView else { return
|
|
328
|
+
guard let scrollView, let blankView else { return lastCalculatedBlankSize }
|
|
434
329
|
|
|
330
|
+
let blankViewIndex = Int(blankView.index)
|
|
331
|
+
let endIndex = blankViewIndex - 1
|
|
435
332
|
let startIndex: Int
|
|
436
|
-
|
|
333
|
+
|
|
437
334
|
if let penultimateCellIndex {
|
|
438
|
-
|
|
335
|
+
let penultimate = Int(penultimateCellIndex)
|
|
336
|
+
// Ensure penultimateCellIndex is valid (not beyond current cells)
|
|
337
|
+
// If penultimateCellIndex >= blankViewIndex, it means props are out of sync with cells
|
|
338
|
+
if penultimate >= blankViewIndex {
|
|
339
|
+
// Props are ahead of cells - use previous behavior (last cell before blank)
|
|
340
|
+
startIndex = endIndex
|
|
341
|
+
} else {
|
|
342
|
+
startIndex = penultimate
|
|
343
|
+
}
|
|
439
344
|
} else {
|
|
440
345
|
startIndex = endIndex
|
|
441
346
|
}
|
|
442
347
|
|
|
443
348
|
var cellsBeforeBlankViewHeight: CGFloat = 0
|
|
444
|
-
|
|
349
|
+
var hasMissingCells = false
|
|
350
|
+
if startIndex >= 0 && startIndex <= endIndex {
|
|
445
351
|
for i in startIndex...endIndex {
|
|
446
352
|
if let cell = getCell(index: i) {
|
|
447
353
|
cellsBeforeBlankViewHeight += cell.view.frame.height
|
|
354
|
+
} else {
|
|
355
|
+
hasMissingCells = true
|
|
448
356
|
}
|
|
449
357
|
}
|
|
450
358
|
}
|
|
451
359
|
|
|
452
|
-
|
|
360
|
+
// If any required cells are missing, return the last known good value to avoid jumps
|
|
361
|
+
// When all cells register, we'll calculate correctly
|
|
362
|
+
if hasMissingCells {
|
|
363
|
+
return lastCalculatedBlankSize
|
|
364
|
+
}
|
|
453
365
|
|
|
454
|
-
|
|
455
|
-
// The blank size fills the remaining space so the last message can scroll to the top
|
|
366
|
+
let blankViewHeight = blankView.view.frame.height
|
|
456
367
|
let visibleAreaHeight = scrollView.bounds.height - keyboardHeight - composerHeight - additionalContentInsetBottom
|
|
457
368
|
let inset = visibleAreaHeight - blankViewHeight - cellsBeforeBlankViewHeight
|
|
458
|
-
|
|
369
|
+
|
|
370
|
+
// Cache this value for when cells are temporarily missing
|
|
371
|
+
lastCalculatedBlankSize = inset
|
|
372
|
+
|
|
373
|
+
// Return raw inset value (can be negative when cells are taller than visible area)
|
|
374
|
+
// The clamping happens in contentInsetBottom to ensure total inset is never negative
|
|
375
|
+
return inset
|
|
459
376
|
}
|
|
460
377
|
|
|
461
378
|
/// Calculate the blank size - the space needed to push content up
|
|
@@ -467,7 +384,9 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
467
384
|
/// The content inset for the bottom of the scroll view
|
|
468
385
|
|
|
469
386
|
private func calculateContentInsetBottom(keyboardHeight: CGFloat, blankSize: CGFloat, additionalContentInsetBottom: CGFloat) -> CGFloat {
|
|
470
|
-
|
|
387
|
+
// Clamp to 0 here instead of in calculateBlankSize to preserve correct math
|
|
388
|
+
// when keyboard is open and blankSize goes negative
|
|
389
|
+
return max(0, blankSize + keyboardHeight + composerHeight + additionalContentInsetBottom)
|
|
471
390
|
}
|
|
472
391
|
var contentInsetBottom: CGFloat {
|
|
473
392
|
return calculateContentInsetBottom(keyboardHeight: self.keyboardHeight, blankSize: self.blankSize, additionalContentInsetBottom: self.additionalContentInsetBottom)
|
|
@@ -475,12 +394,12 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
475
394
|
|
|
476
395
|
/// Apply the current content inset to the scroll view
|
|
477
396
|
func applyContentInset(contentInsetBottom overrideContentInsetBottom: CGFloat? = nil) {
|
|
478
|
-
guard let scrollView else { return }
|
|
397
|
+
guard let _ = scrollView else { return }
|
|
479
398
|
|
|
480
399
|
let targetTop = additionalContentInsetTop
|
|
481
400
|
let targetBottom = overrideContentInsetBottom ?? self.contentInsetBottom
|
|
482
401
|
|
|
483
|
-
// Create insets struct for callback
|
|
402
|
+
// Create insets struct for callback
|
|
484
403
|
let insets = AixContentInsets(
|
|
485
404
|
top: Double(targetTop),
|
|
486
405
|
left: nil,
|
|
@@ -488,10 +407,8 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
488
407
|
right: nil
|
|
489
408
|
)
|
|
490
409
|
|
|
491
|
-
print("[aix] applyContentInset \(targetBottom)")
|
|
492
410
|
onWillApplyContentInsets?(insets)
|
|
493
411
|
|
|
494
|
-
// If shouldApplyContentInsets is explicitly false, call callback and return
|
|
495
412
|
if shouldApplyContentInsets == false {
|
|
496
413
|
return
|
|
497
414
|
}
|
|
@@ -518,7 +435,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
518
435
|
}
|
|
519
436
|
|
|
520
437
|
/// Centralized function to check and fire onScrolledNearEndChange callback
|
|
521
|
-
/// Called from KVO observers and after content inset changes
|
|
522
438
|
private func updateScrolledNearEndState() {
|
|
523
439
|
guard didScrollToEndInitially, scrollView != nil else { return }
|
|
524
440
|
let isNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
|
|
@@ -560,9 +476,15 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
560
476
|
}
|
|
561
477
|
}
|
|
562
478
|
|
|
479
|
+
/// Apply both content insets and scroll indicator insets
|
|
480
|
+
private func applyAllInsets() {
|
|
481
|
+
applyContentInset()
|
|
482
|
+
applyScrollIndicatorInsets()
|
|
483
|
+
}
|
|
484
|
+
|
|
563
485
|
private func scrollToEndInternal(animated: Bool?) {
|
|
564
486
|
guard let scrollView else { return }
|
|
565
|
-
|
|
487
|
+
|
|
566
488
|
// Calculate the offset to show the bottom of content
|
|
567
489
|
let bottomOffset = CGPoint(
|
|
568
490
|
x: 0,
|
|
@@ -572,8 +494,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
572
494
|
}
|
|
573
495
|
|
|
574
496
|
|
|
575
|
-
// MARK: - Keyboard
|
|
576
|
-
|
|
497
|
+
// MARK: - Keyboard Management
|
|
577
498
|
private lazy var keyboardNotifications: KeyboardNotifications = {
|
|
578
499
|
return KeyboardNotifications(notifications: [.willShow, .willHide, .didShow, .didHide, .willChangeFrame], delegate: self)
|
|
579
500
|
}()
|
|
@@ -582,7 +503,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
582
503
|
private struct KeyboardStartEvent {
|
|
583
504
|
let scrollY: CGFloat
|
|
584
505
|
let isOpening: Bool
|
|
585
|
-
var isInteractive: Bool
|
|
586
506
|
let interpolateContentOffsetY: (CGFloat, CGFloat)?
|
|
587
507
|
}
|
|
588
508
|
|
|
@@ -596,11 +516,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
596
516
|
keyboardHeightWhenOpen = targetHeight
|
|
597
517
|
}
|
|
598
518
|
|
|
599
|
-
// If we're already in an interactive dismiss, don't overwrite
|
|
600
|
-
if isInInteractiveDismiss {
|
|
601
|
-
return
|
|
602
|
-
}
|
|
603
|
-
|
|
604
519
|
let scrollY = scrollView?.contentOffset.y ?? 0
|
|
605
520
|
|
|
606
521
|
var interpolateContentOffsetY: (CGFloat, CGFloat)? = {
|
|
@@ -612,102 +527,50 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
612
527
|
}()
|
|
613
528
|
|
|
614
529
|
if queuedScrollToEnd != nil {
|
|
615
|
-
// don't interpolate the keyboard if we're planning to scroll to end
|
|
616
530
|
interpolateContentOffsetY = nil
|
|
617
531
|
}
|
|
618
532
|
|
|
619
|
-
print("[Aix] handleKeyboardWillMove: isOpening=\(isOpening), interpolate=\(String(describing: interpolateContentOffsetY))")
|
|
620
|
-
|
|
621
533
|
startEvent = KeyboardStartEvent(
|
|
622
534
|
scrollY: scrollY,
|
|
623
535
|
isOpening: isOpening,
|
|
624
|
-
isInteractive: false,
|
|
625
536
|
interpolateContentOffsetY: interpolateContentOffsetY
|
|
626
537
|
)
|
|
627
538
|
}
|
|
628
539
|
|
|
629
|
-
/// Handle keyboard frame updates during animation
|
|
630
|
-
private func handleKeyboardMove(height: CGFloat, progress: CGFloat) {
|
|
631
|
-
if keyboardHeightWhenOpen > 0 {
|
|
632
|
-
keyboardProgress = height / keyboardHeightWhenOpen
|
|
633
|
-
}
|
|
634
|
-
keyboardHeight = height
|
|
635
|
-
|
|
636
|
-
guard let startEvent else { return }
|
|
637
|
-
|
|
638
|
-
applyContentInset()
|
|
639
|
-
applyScrollIndicatorInsets()
|
|
640
|
-
|
|
641
|
-
if let (startY, endY) = startEvent.interpolateContentOffsetY {
|
|
642
|
-
// Normalize progress to always go from 0 to 1 (start to end)
|
|
643
|
-
// For opening: progress goes 0→1, so use as-is
|
|
644
|
-
// For closing: progress goes 1→0, so invert it
|
|
645
|
-
let normalizedProgress = startEvent.isOpening ? progress : (1 - progress)
|
|
646
|
-
let newScrollY = startY + (endY - startY) * normalizedProgress
|
|
647
|
-
scrollView?.setContentOffset(CGPoint(x: 0, y: newScrollY), animated: false)
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
540
|
/// Handle keyboard animation end
|
|
652
541
|
private func handleKeyboardDidMove(height: CGFloat, progress: CGFloat) {
|
|
653
|
-
// Ensure final height is applied
|
|
654
542
|
keyboardHeight = height
|
|
655
543
|
if keyboardHeightWhenOpen > 0 {
|
|
656
544
|
keyboardProgress = height / keyboardHeightWhenOpen
|
|
657
545
|
}
|
|
658
546
|
|
|
659
|
-
|
|
660
|
-
applyScrollIndicatorInsets()
|
|
547
|
+
applyAllInsets()
|
|
661
548
|
|
|
662
549
|
startEvent = nil
|
|
663
|
-
isInInteractiveDismiss = false
|
|
664
550
|
|
|
665
551
|
if queuedScrollToEnd?.waitForKeyboardToEnd == true {
|
|
666
552
|
flushQueuedScrollToEnd(force: true)
|
|
667
553
|
}
|
|
668
554
|
}
|
|
669
555
|
|
|
670
|
-
/// Handle interactive keyboard dismissal
|
|
671
|
-
private func handleKeyboardMoveInteractive(height: CGFloat, progress: CGFloat) {
|
|
672
|
-
// Mark that we're in an interactive dismiss if not already
|
|
673
|
-
if !isInInteractiveDismiss && startEvent != nil {
|
|
674
|
-
isInInteractiveDismiss = true
|
|
675
|
-
if var event = startEvent {
|
|
676
|
-
event.isInteractive = true
|
|
677
|
-
startEvent = event
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// Update keyboard state
|
|
682
|
-
handleKeyboardMove(height: height, progress: progress)
|
|
683
|
-
|
|
684
|
-
// Update composer transform
|
|
685
|
-
composerView?.applyKeyboardTransform(height: height, heightWhenOpen: keyboardHeightWhenOpen, animated: false)
|
|
686
|
-
}
|
|
687
|
-
|
|
688
556
|
// MARK: - Initialization
|
|
689
|
-
|
|
690
557
|
override init() {
|
|
691
558
|
let inner = InnerView()
|
|
692
559
|
self.view = inner
|
|
693
560
|
super.init()
|
|
694
561
|
inner.owner = self
|
|
695
|
-
print("[Aix] HybridAix initialized, attaching context to view")
|
|
696
562
|
// Attach this context to our inner view
|
|
697
563
|
view.aixContext = self
|
|
698
564
|
}
|
|
699
565
|
|
|
700
566
|
deinit {
|
|
701
|
-
removePanGestureObserver()
|
|
702
567
|
removeScrollViewObservers()
|
|
703
568
|
}
|
|
704
569
|
|
|
705
570
|
// MARK: - Lifecycle
|
|
706
|
-
|
|
707
571
|
/// Called when our view is added to or removed from the HybridAixComponent
|
|
708
572
|
private func handleDidMoveToSuperview() {
|
|
709
573
|
if let superview = view.superview {
|
|
710
|
-
print("[Aix] View added to superview: \(type(of: superview)), attaching context")
|
|
711
574
|
// Attach context to the superview (HybridAixComponent) so children can find it
|
|
712
575
|
superview.aixContext = self
|
|
713
576
|
|
|
@@ -753,42 +616,52 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
753
616
|
}
|
|
754
617
|
|
|
755
618
|
// MARK: - AixContext Protocol
|
|
756
|
-
|
|
757
619
|
private var lastReportedBlankViewSize = (size: CGSize.zero, index: 0)
|
|
758
620
|
|
|
759
621
|
func reportBlankViewSizeChange(size: CGSize, index: Int) {
|
|
760
622
|
let didAlreadyUpdate = size.height == lastReportedBlankViewSize.size.height && size.width == lastReportedBlankViewSize.size.width && index == lastReportedBlankViewSize.index
|
|
761
|
-
if didAlreadyUpdate {
|
|
762
|
-
return
|
|
763
|
-
}
|
|
623
|
+
if didAlreadyUpdate { return }
|
|
764
624
|
|
|
765
625
|
lastReportedBlankViewSize = (size: size, index: index)
|
|
766
626
|
|
|
767
|
-
//
|
|
627
|
+
// Initial mount setup (tied to mainScrollViewID)
|
|
768
628
|
if !didScrollToEndInitially {
|
|
769
629
|
UIView.performWithoutAnimation {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
630
|
+
applyAllInsets()
|
|
631
|
+
|
|
632
|
+
if shouldStartAtEnd {
|
|
633
|
+
scrollToEndInternal(animated: false)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Set initial state based on actual scroll position after layout
|
|
773
637
|
prevIsScrolledNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
|
|
774
638
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
639
|
+
|
|
640
|
+
// Enable callbacks only after all setup is complete
|
|
641
|
+
didScrollToEndInitially = true
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
applyAllInsets()
|
|
779
646
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
}
|
|
647
|
+
if let queued = queuedScrollToEnd, index == queued.index {
|
|
648
|
+
flushQueuedScrollToEnd()
|
|
783
649
|
}
|
|
784
650
|
}
|
|
785
651
|
|
|
786
652
|
func registerCell(_ cell: HybridAixCellView) {
|
|
787
653
|
cells.setObject(cell, forKey: NSNumber(value: cell.index))
|
|
788
654
|
|
|
789
|
-
// If this cell is marked as last, update our blank view reference
|
|
790
655
|
if cell.isLast {
|
|
791
656
|
blankView = cell
|
|
657
|
+
|
|
658
|
+
// Trigger initial setup
|
|
659
|
+
let currentSize = cell.view.bounds.size
|
|
660
|
+
reportBlankViewSizeChange(size: currentSize, index: Int(cell.index))
|
|
661
|
+
} else if didScrollToEndInitially {
|
|
662
|
+
// A content cell registered after initial setup - recalculate insets
|
|
663
|
+
// since this cell's height affects the blank size calculation
|
|
664
|
+
applyAllInsets()
|
|
792
665
|
}
|
|
793
666
|
}
|
|
794
667
|
|
|
@@ -798,7 +671,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
798
671
|
// If this was our blank view, clear it
|
|
799
672
|
if blankView === cell {
|
|
800
673
|
blankView = nil
|
|
801
|
-
|
|
674
|
+
}
|
|
802
675
|
}
|
|
803
676
|
|
|
804
677
|
func registerComposerView(_ composerView: HybridAixComposer) {
|
|
@@ -814,72 +687,33 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
814
687
|
private var lastReportedComposerHeight: CGFloat = 0
|
|
815
688
|
|
|
816
689
|
func reportComposerHeightChange(height: CGFloat) {
|
|
817
|
-
|
|
818
|
-
return
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
let previousHeight = lastReportedComposerHeight
|
|
822
|
-
let isShrinking = height < previousHeight
|
|
823
|
-
|
|
690
|
+
guard height != lastReportedComposerHeight else { return }
|
|
824
691
|
lastReportedComposerHeight = height
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
}
|
|
829
|
-
|
|
692
|
+
|
|
693
|
+
guard didScrollToEndInitially else { return }
|
|
694
|
+
|
|
830
695
|
let shouldScroll = shouldScrollOnFooterSizeUpdate()
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
if shouldScroll
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
applyScrollIndicatorInsets()
|
|
837
|
-
return
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
let newContentInsetBottom = self.contentInsetBottom
|
|
841
|
-
let bottomOffset = CGPoint(
|
|
842
|
-
x: 0,
|
|
843
|
-
y: max(0, scrollView.contentSize.height - scrollView.bounds.height + newContentInsetBottom)
|
|
844
|
-
)
|
|
845
|
-
|
|
846
|
-
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut]) {
|
|
847
|
-
scrollView.contentInset.bottom = newContentInsetBottom
|
|
848
|
-
scrollView.contentOffset = bottomOffset
|
|
849
|
-
}
|
|
850
|
-
applyScrollIndicatorInsets()
|
|
851
|
-
} else {
|
|
852
|
-
applyContentInset()
|
|
853
|
-
applyScrollIndicatorInsets()
|
|
854
|
-
|
|
855
|
-
if shouldScroll {
|
|
856
|
-
scrollToEndInternal(animated: animated)
|
|
857
|
-
}
|
|
696
|
+
applyAllInsets()
|
|
697
|
+
|
|
698
|
+
if shouldScroll {
|
|
699
|
+
let animated = scrollOnFooterSizeUpdate?.animated ?? false
|
|
700
|
+
scrollToEndInternal(animated: animated)
|
|
858
701
|
}
|
|
859
702
|
}
|
|
860
703
|
|
|
861
704
|
private func shouldScrollOnFooterSizeUpdate() -> Bool {
|
|
862
|
-
guard let settings = scrollOnFooterSizeUpdate, settings.enabled else {
|
|
705
|
+
guard let settings = scrollOnFooterSizeUpdate, settings.enabled ?? true else {
|
|
863
706
|
return false
|
|
864
707
|
}
|
|
865
|
-
guard
|
|
708
|
+
guard scrollView != nil else {
|
|
866
709
|
return false
|
|
867
710
|
}
|
|
868
711
|
|
|
869
|
-
let
|
|
870
|
-
|
|
871
|
-
let currentOffsetY = scrollView.contentOffset.y
|
|
872
|
-
let bottomInset = scrollView.contentInset.bottom
|
|
873
|
-
|
|
874
|
-
let maxOffsetY = max(0, contentHeight - scrollViewHeight + bottomInset)
|
|
875
|
-
let distanceFromEnd = maxOffsetY - currentOffsetY
|
|
876
|
-
|
|
877
|
-
let threshold = settings.scrolledToEndThreshold ?? 0
|
|
878
|
-
return distanceFromEnd <= CGFloat(threshold)
|
|
712
|
+
let threshold = settings.scrolledToEndThreshold ?? 100
|
|
713
|
+
return distFromEnd <= CGFloat(threshold)
|
|
879
714
|
}
|
|
880
715
|
|
|
881
716
|
// MARK: - Cell Access
|
|
882
|
-
|
|
883
717
|
/// Get a cell by its index
|
|
884
718
|
func getCell(index: Int) -> HybridAixCellView? {
|
|
885
719
|
return cells.object(forKey: NSNumber(value: index))
|
|
@@ -887,7 +721,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
887
721
|
|
|
888
722
|
|
|
889
723
|
// MARK: - Scrolling
|
|
890
|
-
|
|
891
724
|
func getIsQueuedScrollToEndReady(queuedScrollToEnd: QueuedScrollToEnd) -> Bool {
|
|
892
725
|
guard let blankView else { return false }
|
|
893
726
|
if queuedScrollToEnd.waitForKeyboardToEnd == true && startEvent != nil {
|
|
@@ -905,7 +738,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
905
738
|
}
|
|
906
739
|
|
|
907
740
|
// MARK: - Keyboard Notification Handlers
|
|
908
|
-
|
|
909
741
|
func keyboardWillShow(notification: NSNotification) {
|
|
910
742
|
guard let userInfo = notification.userInfo,
|
|
911
743
|
let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
|
|
@@ -913,7 +745,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
913
745
|
let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { return }
|
|
914
746
|
|
|
915
747
|
let targetHeight = keyboardFrame.height
|
|
916
|
-
print("[Aix] keyboardWillShow: targetHeight=\(targetHeight), duration=\(duration)")
|
|
917
748
|
|
|
918
749
|
guard duration > 0 else { return }
|
|
919
750
|
|
|
@@ -924,8 +755,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
924
755
|
if !didScrollToEndInitially {
|
|
925
756
|
keyboardHeight = targetHeight
|
|
926
757
|
keyboardProgress = 1.0
|
|
927
|
-
|
|
928
|
-
applyScrollIndicatorInsets()
|
|
758
|
+
applyAllInsets()
|
|
929
759
|
composerView?.applyKeyboardTransform(height: targetHeight, heightWhenOpen: keyboardHeightWhenOpen, animated: false)
|
|
930
760
|
return
|
|
931
761
|
}
|
|
@@ -934,17 +764,16 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
934
764
|
|
|
935
765
|
let options = UIView.AnimationOptions(rawValue: curveValue << 16)
|
|
936
766
|
UIView.animate(withDuration: duration, delay: 0, options: options, animations: { [weak self] in
|
|
937
|
-
guard let self
|
|
938
|
-
|
|
939
|
-
if
|
|
940
|
-
|
|
767
|
+
guard let self else { return }
|
|
768
|
+
keyboardHeight = targetHeight
|
|
769
|
+
if keyboardHeightWhenOpen > 0 {
|
|
770
|
+
keyboardProgress = targetHeight / keyboardHeightWhenOpen
|
|
941
771
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
self.composerView?.applyKeyboardTransform(height: targetHeight, heightWhenOpen: self.keyboardHeightWhenOpen, animated: false)
|
|
772
|
+
applyAllInsets()
|
|
773
|
+
composerView?.applyKeyboardTransform(height: targetHeight, heightWhenOpen: keyboardHeightWhenOpen, animated: false)
|
|
945
774
|
|
|
946
|
-
if let (
|
|
947
|
-
|
|
775
|
+
if let (_, endY) = startEvent?.interpolateContentOffsetY {
|
|
776
|
+
scrollView?.setContentOffset(CGPoint(x: 0, y: endY), animated: false)
|
|
948
777
|
}
|
|
949
778
|
}, completion: { [weak self] _ in
|
|
950
779
|
self?.handleKeyboardDidMove(height: targetHeight, progress: 1.0)
|
|
@@ -956,249 +785,73 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
956
785
|
let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double,
|
|
957
786
|
let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { return }
|
|
958
787
|
|
|
959
|
-
print("[Aix] keyboardWillHide: duration=\(duration)")
|
|
960
|
-
|
|
961
|
-
// Don't interpolate scroll position when closing, the inset change will handle the visual transition
|
|
962
788
|
startEvent = nil
|
|
963
789
|
|
|
964
790
|
let options = UIView.AnimationOptions(rawValue: curveValue << 16)
|
|
965
791
|
UIView.animate(withDuration: duration, delay: 0, options: options, animations: { [weak self] in
|
|
966
|
-
guard let self
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
self.applyScrollIndicatorInsets()
|
|
972
|
-
self.composerView?.applyKeyboardTransform(height: 0, heightWhenOpen: self.keyboardHeightWhenOpen, animated: false)
|
|
792
|
+
guard let self else { return }
|
|
793
|
+
keyboardHeight = 0
|
|
794
|
+
keyboardProgress = 0
|
|
795
|
+
applyAllInsets()
|
|
796
|
+
composerView?.applyKeyboardTransform(height: 0, heightWhenOpen: keyboardHeightWhenOpen, animated: false)
|
|
973
797
|
}, completion: { [weak self] _ in
|
|
974
798
|
self?.handleKeyboardDidMove(height: 0, progress: 0)
|
|
975
799
|
})
|
|
976
800
|
}
|
|
977
801
|
|
|
978
802
|
func keyboardDidShow(notification: NSNotification) {
|
|
979
|
-
print("[Aix] keyboardDidShow")
|
|
980
803
|
}
|
|
981
804
|
|
|
982
805
|
func keyboardDidHide(notification: NSNotification) {
|
|
983
|
-
print("[Aix] keyboardDidHide")
|
|
984
806
|
keyboardHeightWhenOpen = 0
|
|
985
807
|
composerView?.applyKeyboardTransform(height: 0, heightWhenOpen: 0, animated: false)
|
|
986
808
|
}
|
|
987
809
|
|
|
988
810
|
func keyboardWillChangeFrame(notification: NSNotification) {
|
|
989
|
-
guard let userInfo = notification.userInfo,
|
|
990
|
-
let keyboardFrameEnd = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
|
991
|
-
|
|
992
|
-
let screenHeight = UIScreen.main.bounds.height
|
|
993
|
-
let keyboardTop = keyboardFrameEnd.origin.y
|
|
994
|
-
let newHeight = max(0, screenHeight - keyboardTop)
|
|
995
|
-
|
|
996
|
-
if startEvent != nil && !isInInteractiveDismiss {
|
|
997
|
-
return
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
if isInInteractiveDismiss && newHeight != keyboardHeight {
|
|
1001
|
-
let progress = keyboardHeightWhenOpen > 0 ? newHeight / keyboardHeightWhenOpen : 0
|
|
1002
|
-
handleKeyboardMoveInteractive(height: newHeight, progress: progress)
|
|
1003
|
-
}
|
|
1004
811
|
}
|
|
1005
812
|
}
|
|
1006
813
|
|
|
1007
814
|
// MARK: - Scroll Position Helpers
|
|
1008
|
-
|
|
1009
815
|
extension HybridAix {
|
|
1010
|
-
/// Check if an interactive keyboard dismiss is in progress by examining scroll view state
|
|
1011
|
-
private func isInteractiveDismissInProgress() -> Bool {
|
|
1012
|
-
guard let scrollView = scrollView else { return false }
|
|
1013
|
-
|
|
1014
|
-
// Check if scroll view has interactive keyboard dismiss mode
|
|
1015
|
-
guard scrollView.keyboardDismissMode == .interactive else { return false }
|
|
1016
|
-
|
|
1017
|
-
// Check if pan gesture is active (user is scrolling)
|
|
1018
|
-
let panGesture = scrollView.panGestureRecognizer
|
|
1019
|
-
let gestureState = panGesture.state
|
|
1020
|
-
|
|
1021
|
-
// Pan gesture states: .began = 1, .changed = 2
|
|
1022
|
-
return gestureState == .began || gestureState == .changed
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
816
|
/// Distance from current scroll position to the maximum scroll position (end)
|
|
1026
817
|
var distFromEnd: CGFloat {
|
|
1027
|
-
guard let scrollView
|
|
1028
|
-
|
|
1029
|
-
return maxScrollY - scrollView.contentOffset.y
|
|
818
|
+
guard let scrollView else { return 0 }
|
|
819
|
+
return scrollView.contentSize.height - scrollView.bounds.height + contentInsetBottom - scrollView.contentOffset.y
|
|
1030
820
|
}
|
|
1031
821
|
|
|
1032
822
|
func getIsScrolledNearEnd(distFromEnd: CGFloat) -> Bool {
|
|
823
|
+
guard scrollView != nil else { return false }
|
|
1033
824
|
return distFromEnd <= (scrollEndReachedThreshold ?? max(200, blankSize))
|
|
1034
825
|
}
|
|
1035
|
-
|
|
826
|
+
|
|
1036
827
|
func getContentOffsetYWhenOpening(scrollY: CGFloat) -> (CGFloat, CGFloat)? {
|
|
1037
|
-
guard let scrollView
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
// Use the target additionalContentInsetBottom when keyboard is fully open
|
|
1042
|
-
let targetAdditionalInset = CGFloat(self.additionalContentInsets?.bottom?.whenKeyboardOpen ?? 0)
|
|
828
|
+
guard let scrollView, getIsScrolledNearEnd(distFromEnd: distFromEnd), blankSize <= keyboardHeightWhenOpen else {
|
|
829
|
+
return nil
|
|
830
|
+
}
|
|
1043
831
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
// When blankSize is 0: contentInset = keyboard + composer + additionalInset
|
|
1047
|
-
let shiftContentUpToY = scrollView.contentSize.height - scrollView.bounds.height + keyboardHeightWhenOpen + composerHeight + targetAdditionalInset
|
|
832
|
+
let targetAdditionalInset = CGFloat(additionalContentInsets?.bottom?.whenKeyboardOpen ?? 0)
|
|
833
|
+
let targetY = scrollView.contentSize.height - scrollView.bounds.height + keyboardHeightWhenOpen + composerHeight + targetAdditionalInset
|
|
1048
834
|
|
|
1049
|
-
|
|
1050
|
-
return (scrollY, shiftContentUpToY)
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
let hasBlankSizeLessThanOpenKeyboardHeight = blankSize > 0 && blankSize <= keyboardHeightWhenOpen
|
|
1054
|
-
|
|
1055
|
-
if hasBlankSizeLessThanOpenKeyboardHeight && isScrolledNearEnd {
|
|
1056
|
-
return (scrollY, shiftContentUpToY)
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
return nil
|
|
835
|
+
return (scrollY, targetY)
|
|
1060
836
|
}
|
|
1061
837
|
|
|
1062
838
|
func getContentOffsetYWhenClosing(scrollY: CGFloat) -> (CGFloat, CGFloat)? {
|
|
1063
|
-
guard keyboardHeightWhenOpen > 0 else {
|
|
1064
|
-
let isScrolledNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
|
|
1065
|
-
|
|
1066
|
-
if !isScrolledNearEnd {
|
|
839
|
+
guard scrollView != nil, keyboardHeightWhenOpen > 0, getIsScrolledNearEnd(distFromEnd: distFromEnd) else {
|
|
1067
840
|
return nil
|
|
1068
841
|
}
|
|
1069
842
|
|
|
1070
|
-
let
|
|
1071
|
-
let
|
|
843
|
+
let additionalInsetOpen = CGFloat(additionalContentInsets?.bottom?.whenKeyboardOpen ?? 0)
|
|
844
|
+
let additionalInsetClosed = CGFloat(additionalContentInsets?.bottom?.whenKeyboardClosed ?? 0)
|
|
1072
845
|
|
|
1073
|
-
|
|
1074
|
-
let
|
|
1075
|
-
let blankSizeWithoutKeyboard = calculateBlankSize(keyboardHeight: 0, additionalContentInsetBottom: additionalContentInsetBottomWithoutKeyboard)
|
|
846
|
+
let blankSizeOpen = calculateBlankSize(keyboardHeight: keyboardHeightWhenOpen, additionalContentInsetBottom: additionalInsetOpen)
|
|
847
|
+
let blankSizeClosed = calculateBlankSize(keyboardHeight: 0, additionalContentInsetBottom: additionalInsetClosed)
|
|
1076
848
|
|
|
1077
|
-
|
|
1078
|
-
let
|
|
1079
|
-
let insetWithoutKeyboard = calculateContentInsetBottom(keyboardHeight: 0, blankSize: blankSizeWithoutKeyboard,
|
|
1080
|
-
additionalContentInsetBottom: additionalContentInsetBottomWithoutKeyboard
|
|
1081
|
-
)
|
|
1082
|
-
let insetDecrease = insetWithKeyboard - insetWithoutKeyboard
|
|
849
|
+
let insetOpen = calculateContentInsetBottom(keyboardHeight: keyboardHeightWhenOpen, blankSize: blankSizeOpen, additionalContentInsetBottom: additionalInsetOpen)
|
|
850
|
+
let insetClosed = calculateContentInsetBottom(keyboardHeight: 0, blankSize: blankSizeClosed, additionalContentInsetBottom: additionalInsetClosed)
|
|
1083
851
|
|
|
1084
|
-
|
|
1085
|
-
// by the same amount the inset decreases
|
|
1086
|
-
let targetScrollY = max(0, scrollY - insetDecrease)
|
|
852
|
+
let targetScrollY = max(0, scrollY - (insetOpen - insetClosed))
|
|
1087
853
|
|
|
1088
|
-
// Only interpolate if there's actually movement needed
|
|
1089
854
|
guard abs(scrollY - targetScrollY) > 1 else { return nil }
|
|
1090
|
-
|
|
1091
855
|
return (scrollY, targetScrollY)
|
|
1092
856
|
}
|
|
1093
857
|
}
|
|
1094
|
-
|
|
1095
|
-
// Source - https://stackoverflow.com/a
|
|
1096
|
-
// Posted by Vasily Bodnarchuk, modified by community. See post 'Timeline' for change history
|
|
1097
|
-
// Retrieved 2026-01-07, License - CC BY-SA 4.0
|
|
1098
|
-
|
|
1099
|
-
protocol KeyboardNotificationsDelegate: AnyObject {
|
|
1100
|
-
func keyboardWillShow(notification: NSNotification)
|
|
1101
|
-
func keyboardWillHide(notification: NSNotification)
|
|
1102
|
-
func keyboardDidShow(notification: NSNotification)
|
|
1103
|
-
func keyboardDidHide(notification: NSNotification)
|
|
1104
|
-
func keyboardWillChangeFrame(notification: NSNotification)
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
extension KeyboardNotificationsDelegate {
|
|
1108
|
-
func keyboardWillShow(notification: NSNotification) {}
|
|
1109
|
-
func keyboardWillHide(notification: NSNotification) {}
|
|
1110
|
-
func keyboardDidShow(notification: NSNotification) {}
|
|
1111
|
-
func keyboardDidHide(notification: NSNotification) {}
|
|
1112
|
-
func keyboardWillChangeFrame(notification: NSNotification) {}
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
class KeyboardNotifications {
|
|
1116
|
-
fileprivate var _isEnabled: Bool
|
|
1117
|
-
fileprivate var notifications: [KeyboardNotificationsType]
|
|
1118
|
-
fileprivate weak var delegate: KeyboardNotificationsDelegate?
|
|
1119
|
-
|
|
1120
|
-
init(notifications: [KeyboardNotificationsType], delegate: KeyboardNotificationsDelegate) {
|
|
1121
|
-
_isEnabled = false
|
|
1122
|
-
self.notifications = notifications
|
|
1123
|
-
self.delegate = delegate
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
deinit { if isEnabled { isEnabled = false } }
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
// MARK: - enums
|
|
1130
|
-
|
|
1131
|
-
extension KeyboardNotifications {
|
|
1132
|
-
|
|
1133
|
-
enum KeyboardNotificationsType {
|
|
1134
|
-
case willShow, willHide, didShow, didHide, willChangeFrame
|
|
1135
|
-
|
|
1136
|
-
var selector: Selector {
|
|
1137
|
-
switch self {
|
|
1138
|
-
case .willShow: return #selector(keyboardWillShow(notification:))
|
|
1139
|
-
case .willHide: return #selector(keyboardWillHide(notification:))
|
|
1140
|
-
case .didShow: return #selector(keyboardDidShow(notification:))
|
|
1141
|
-
case .didHide: return #selector(keyboardDidHide(notification:))
|
|
1142
|
-
case .willChangeFrame: return #selector(keyboardWillChangeFrame(notification:))
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
var notificationName: NSNotification.Name {
|
|
1147
|
-
switch self {
|
|
1148
|
-
case .willShow: return UIResponder.keyboardWillShowNotification
|
|
1149
|
-
case .willHide: return UIResponder.keyboardWillHideNotification
|
|
1150
|
-
case .didShow: return UIResponder.keyboardDidShowNotification
|
|
1151
|
-
case .didHide: return UIResponder.keyboardDidHideNotification
|
|
1152
|
-
case .willChangeFrame: return UIResponder.keyboardWillChangeFrameNotification
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
// MARK: - isEnabled
|
|
1159
|
-
|
|
1160
|
-
extension KeyboardNotifications {
|
|
1161
|
-
|
|
1162
|
-
private func addObserver(type: KeyboardNotificationsType) {
|
|
1163
|
-
NotificationCenter.default.addObserver(self, selector: type.selector, name: type.notificationName, object: nil)
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
var isEnabled: Bool {
|
|
1167
|
-
set {
|
|
1168
|
-
if newValue {
|
|
1169
|
-
for notificaton in notifications { addObserver(type: notificaton) }
|
|
1170
|
-
} else {
|
|
1171
|
-
NotificationCenter.default.removeObserver(self)
|
|
1172
|
-
}
|
|
1173
|
-
_isEnabled = newValue
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
get { return _isEnabled }
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
// MARK: - Notification functions
|
|
1182
|
-
|
|
1183
|
-
extension KeyboardNotifications {
|
|
1184
|
-
|
|
1185
|
-
@objc func keyboardWillShow(notification: NSNotification) {
|
|
1186
|
-
delegate?.keyboardWillShow(notification: notification)
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
@objc func keyboardWillHide(notification: NSNotification) {
|
|
1190
|
-
delegate?.keyboardWillHide(notification: notification)
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
@objc func keyboardDidShow(notification: NSNotification) {
|
|
1194
|
-
delegate?.keyboardDidShow(notification: notification)
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
@objc func keyboardDidHide(notification: NSNotification) {
|
|
1198
|
-
delegate?.keyboardDidHide(notification: notification)
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
@objc func keyboardWillChangeFrame(notification: NSNotification) {
|
|
1202
|
-
delegate?.keyboardWillChangeFrame(notification: notification)
|
|
1203
|
-
}
|
|
1204
|
-
}
|