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.
@@ -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
- removePanGestureObserver()
129
+ // Reset all scroll view and cell state when ID changes
135
130
  removeScrollViewObservers()
136
131
  cachedScrollView = nil
137
- didSetupPanGestureObserver = false
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 didScrollToEndInitiallyForId: String? = nil
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 pan gesture observer when we find the scroll view
285
- if let scrollView = sv, !didSetupPanGestureObserver {
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 0 }
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
- let endIndex = Int(blankView.index) - 1
333
+
437
334
  if let penultimateCellIndex {
438
- startIndex = Int(penultimateCellIndex)
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
- if startIndex <= endIndex {
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
- let blankViewHeight = blankView.view.frame.height
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
- // Calculate visible area above all bottom chrome (keyboard, composer, additional insets)
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
- return max(0, inset)
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
- return blankSize + keyboardHeight + composerHeight + additionalContentInsetBottom
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 (fields are optional in the interface)
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 Observer (notification-based)
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
- applyContentInset()
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
- // Check if we have a queued scroll waiting for this index
627
+ // Initial mount setup (tied to mainScrollViewID)
768
628
  if !didScrollToEndInitially {
769
629
  UIView.performWithoutAnimation {
770
- applyContentInset()
771
- applyScrollIndicatorInsets()
772
- scrollToEndInternal(animated: false)
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
- didScrollToEndInitiallyForId = mainScrollViewID ?? ""
776
- } else {
777
- applyContentInset()
778
- applyScrollIndicatorInsets()
639
+
640
+ // Enable callbacks only after all setup is complete
641
+ didScrollToEndInitially = true
642
+ return
643
+ }
644
+
645
+ applyAllInsets()
779
646
 
780
- if let queued = queuedScrollToEnd, index == queued.index {
781
- flushQueuedScrollToEnd()
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
- if height == lastReportedComposerHeight {
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
- if !didScrollToEndInitially {
827
- return
828
- }
829
-
692
+
693
+ guard didScrollToEndInitially else { return }
694
+
830
695
  let shouldScroll = shouldScrollOnFooterSizeUpdate()
831
- let animated = scrollOnFooterSizeUpdate?.animated ?? false
832
-
833
- if shouldScroll && animated && isShrinking {
834
- guard let scrollView else {
835
- applyContentInset()
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 let scrollView else {
708
+ guard scrollView != nil else {
866
709
  return false
867
710
  }
868
711
 
869
- let contentHeight = scrollView.contentSize.height
870
- let scrollViewHeight = scrollView.bounds.height
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
- applyContentInset()
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 = self else { return }
938
- self.keyboardHeight = targetHeight
939
- if self.keyboardHeightWhenOpen > 0 {
940
- self.keyboardProgress = targetHeight / self.keyboardHeightWhenOpen
767
+ guard let self else { return }
768
+ keyboardHeight = targetHeight
769
+ if keyboardHeightWhenOpen > 0 {
770
+ keyboardProgress = targetHeight / keyboardHeightWhenOpen
941
771
  }
942
- self.applyContentInset()
943
- self.applyScrollIndicatorInsets()
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 (startY, endY) = self.startEvent?.interpolateContentOffsetY {
947
- self.scrollView?.setContentOffset(CGPoint(x: 0, y: endY), animated: false)
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 = self else { return }
967
-
968
- self.keyboardHeight = 0
969
- self.keyboardProgress = 0
970
- self.applyContentInset()
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 = scrollView else { return 0 }
1028
- let maxScrollY = scrollView.contentSize.height - scrollView.bounds.height + contentInsetBottom
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 else { return nil }
1038
- let isScrolledNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
1039
- let shouldShiftContentUp = blankSize == 0 && isScrolledNearEnd
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
- // Calculate the max scroll position when keyboard is open
1045
- // This is where we want to scroll to: contentSize - bounds + contentInset
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
- if shouldShiftContentUp {
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 { return nil }
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 additionalContentInsetBottomWithKeyboard = CGFloat(self.additionalContentInsets?.bottom?.whenKeyboardOpen ?? 0)
1071
- let additionalContentInsetBottomWithoutKeyboard = CGFloat(self.additionalContentInsets?.bottom?.whenKeyboardClosed ?? 0)
843
+ let additionalInsetOpen = CGFloat(additionalContentInsets?.bottom?.whenKeyboardOpen ?? 0)
844
+ let additionalInsetClosed = CGFloat(additionalContentInsets?.bottom?.whenKeyboardClosed ?? 0)
1072
845
 
1073
- // Calculate how much content inset will decrease when keyboard closes
1074
- let blankSizeWithKeyboard = calculateBlankSize(keyboardHeight: keyboardHeightWhenOpen, additionalContentInsetBottom: additionalContentInsetBottomWithKeyboard)
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
- // Calculate actual content insets (including composer)
1078
- let insetWithKeyboard = calculateContentInsetBottom(keyboardHeight: keyboardHeightWhenOpen, blankSize: blankSizeWithKeyboard, additionalContentInsetBottom: additionalContentInsetBottomWithKeyboard)
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
- // To keep the visual content position stable, we need to decrease scrollY
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
- }