aix 0.6.2 → 0.7.1-alpha.1

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,17 @@ 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)
138
137
  }
139
138
  }
140
139
 
@@ -156,7 +155,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
156
155
  self.startEvent = KeyboardStartEvent(
157
156
  scrollY: event.scrollY,
158
157
  isOpening: event.isOpening,
159
- isInteractive: event.isInteractive,
160
158
  interpolateContentOffsetY: nil
161
159
  )
162
160
  }
@@ -165,11 +163,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
165
163
  }
166
164
  }
167
165
 
168
- private var didScrollToEndInitiallyForId: String? = nil
169
-
170
- private var didScrollToEndInitially: Bool {
171
- return didScrollToEndInitiallyForId == (mainScrollViewID ?? "")
172
- }
166
+ private var didScrollToEndInitially: Bool = false
173
167
 
174
168
  // MARK: - Inner View
175
169
 
@@ -217,7 +211,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
217
211
  }
218
212
 
219
213
  // MARK: - Private State
220
-
221
214
  /// Queued scroll operation waiting for blank view to update
222
215
  private var queuedScrollToEnd: QueuedScrollToEnd? = nil
223
216
 
@@ -228,22 +221,15 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
228
221
  /// Cached scroll view reference (weak to avoid retain cycles)
229
222
  private weak var cachedScrollView: UIScrollView?
230
223
 
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
224
  /// Previous "scrolled near end" state for change detection
238
225
  private var prevIsScrolledNearEnd: Bool? = nil
239
-
226
+
240
227
  /// KVO observation tokens for scroll view
241
228
  private var contentOffsetObservation: NSKeyValueObservation?
242
229
  private var contentSizeObservation: NSKeyValueObservation?
243
230
  private var boundsObservation: NSKeyValueObservation?
244
231
 
245
232
  // MARK: - Context References (weak to avoid retain cycles)
246
-
247
233
  weak var blankView: HybridAixCellView? = nil {
248
234
  didSet {
249
235
  // Could add observers or callbacks here when blank view changes
@@ -253,7 +239,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
253
239
  weak var composerView: HybridAixComposer? = nil
254
240
 
255
241
  // MARK: - Computed Properties
256
-
257
242
  /// Find the scroll view within our view hierarchy
258
243
  /// We search from the superview (HybridAixComponent) since the scroll view
259
244
  /// is a sibling of our inner view, not a child
@@ -268,25 +253,18 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
268
253
  var sv: UIScrollView? = nil
269
254
  if let scrollViewID = mainScrollViewID, !scrollViewID.isEmpty {
270
255
  sv = searchRoot.findScrollView(withIdentifier: scrollViewID)
271
- if sv != nil {
272
- print("[Aix] scrollView found by ID '\(scrollViewID)': \(String(describing: sv))")
273
- }
274
256
  }
275
257
 
276
258
  // Fallback to default subview iteration if not found by ID
277
259
  if sv == nil {
278
260
  sv = searchRoot.findScrollView()
279
- print("[Aix] scrollView found by iteration: \(String(describing: sv))")
280
261
  }
281
262
 
282
263
  cachedScrollView = sv
283
264
 
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
265
+ // Set up observers when we find the scroll view
266
+ if let scrollView = sv {
287
267
  scrollView.automaticallyAdjustsScrollIndicatorInsets = false
288
-
289
- setupPanGestureObserver()
290
268
  setupScrollViewObservers(scrollView)
291
269
  applyScrollIndicatorInsets()
292
270
  }
@@ -322,89 +300,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
322
300
  boundsObservation = nil
323
301
  }
324
302
 
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
303
  /// Height of the composer view
409
304
  private var composerHeight: CGFloat {
410
305
  let h = composerView?.view.bounds.height ?? 0
@@ -432,14 +327,29 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
432
327
  private func calculateBlankSize(keyboardHeight: CGFloat, additionalContentInsetBottom: CGFloat) -> CGFloat {
433
328
  guard let scrollView, let blankView else { return 0 }
434
329
 
435
- let cellBeforeBlankView = getCell(index: Int(blankView.index) - 1)
436
- let cellBeforeBlankViewHeight = cellBeforeBlankView?.view.frame.height ?? 0
330
+ let startIndex: Int
331
+ let endIndex = Int(blankView.index) - 1
332
+ if let penultimateCellIndex {
333
+ startIndex = Int(penultimateCellIndex)
334
+ } else {
335
+ startIndex = endIndex
336
+ }
337
+
338
+ var cellsBeforeBlankViewHeight: CGFloat = 0
339
+ if startIndex <= endIndex {
340
+ for i in startIndex...endIndex {
341
+ if let cell = getCell(index: i) {
342
+ cellsBeforeBlankViewHeight += cell.view.frame.height
343
+ }
344
+ }
345
+ }
346
+
437
347
  let blankViewHeight = blankView.view.frame.height
438
348
 
439
349
  // Calculate visible area above all bottom chrome (keyboard, composer, additional insets)
440
350
  // The blank size fills the remaining space so the last message can scroll to the top
441
351
  let visibleAreaHeight = scrollView.bounds.height - keyboardHeight - composerHeight - additionalContentInsetBottom
442
- let inset = visibleAreaHeight - blankViewHeight - cellBeforeBlankViewHeight
352
+ let inset = visibleAreaHeight - blankViewHeight - cellsBeforeBlankViewHeight
443
353
  return max(0, inset)
444
354
  }
445
355
 
@@ -460,12 +370,12 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
460
370
 
461
371
  /// Apply the current content inset to the scroll view
462
372
  func applyContentInset(contentInsetBottom overrideContentInsetBottom: CGFloat? = nil) {
463
- guard let scrollView else { return }
373
+ guard let _ = scrollView else { return }
464
374
 
465
375
  let targetTop = additionalContentInsetTop
466
376
  let targetBottom = overrideContentInsetBottom ?? self.contentInsetBottom
467
377
 
468
- // Create insets struct for callback (fields are optional in the interface)
378
+ // Create insets struct for callback
469
379
  let insets = AixContentInsets(
470
380
  top: Double(targetTop),
471
381
  left: nil,
@@ -473,10 +383,8 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
473
383
  right: nil
474
384
  )
475
385
 
476
- print("[aix] applyContentInset \(targetBottom)")
477
386
  onWillApplyContentInsets?(insets)
478
387
 
479
- // If shouldApplyContentInsets is explicitly false, call callback and return
480
388
  if shouldApplyContentInsets == false {
481
389
  return
482
390
  }
@@ -503,7 +411,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
503
411
  }
504
412
 
505
413
  /// Centralized function to check and fire onScrolledNearEndChange callback
506
- /// Called from KVO observers and after content inset changes
507
414
  private func updateScrolledNearEndState() {
508
415
  guard didScrollToEndInitially, scrollView != nil else { return }
509
416
  let isNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
@@ -545,9 +452,15 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
545
452
  }
546
453
  }
547
454
 
455
+ /// Apply both content insets and scroll indicator insets
456
+ private func applyAllInsets() {
457
+ applyContentInset()
458
+ applyScrollIndicatorInsets()
459
+ }
460
+
548
461
  private func scrollToEndInternal(animated: Bool?) {
549
462
  guard let scrollView else { return }
550
-
463
+
551
464
  // Calculate the offset to show the bottom of content
552
465
  let bottomOffset = CGPoint(
553
466
  x: 0,
@@ -557,8 +470,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
557
470
  }
558
471
 
559
472
 
560
- // MARK: - Keyboard Observer (notification-based)
561
-
473
+ // MARK: - Keyboard Management
562
474
  private lazy var keyboardNotifications: KeyboardNotifications = {
563
475
  return KeyboardNotifications(notifications: [.willShow, .willHide, .didShow, .didHide, .willChangeFrame], delegate: self)
564
476
  }()
@@ -567,7 +479,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
567
479
  private struct KeyboardStartEvent {
568
480
  let scrollY: CGFloat
569
481
  let isOpening: Bool
570
- var isInteractive: Bool
571
482
  let interpolateContentOffsetY: (CGFloat, CGFloat)?
572
483
  }
573
484
 
@@ -581,11 +492,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
581
492
  keyboardHeightWhenOpen = targetHeight
582
493
  }
583
494
 
584
- // If we're already in an interactive dismiss, don't overwrite
585
- if isInInteractiveDismiss {
586
- return
587
- }
588
-
589
495
  let scrollY = scrollView?.contentOffset.y ?? 0
590
496
 
591
497
  var interpolateContentOffsetY: (CGFloat, CGFloat)? = {
@@ -597,102 +503,50 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
597
503
  }()
598
504
 
599
505
  if queuedScrollToEnd != nil {
600
- // don't interpolate the keyboard if we're planning to scroll to end
601
506
  interpolateContentOffsetY = nil
602
507
  }
603
508
 
604
- print("[Aix] handleKeyboardWillMove: isOpening=\(isOpening), interpolate=\(String(describing: interpolateContentOffsetY))")
605
-
606
509
  startEvent = KeyboardStartEvent(
607
510
  scrollY: scrollY,
608
511
  isOpening: isOpening,
609
- isInteractive: false,
610
512
  interpolateContentOffsetY: interpolateContentOffsetY
611
513
  )
612
514
  }
613
515
 
614
- /// Handle keyboard frame updates during animation
615
- private func handleKeyboardMove(height: CGFloat, progress: CGFloat) {
616
- if keyboardHeightWhenOpen > 0 {
617
- keyboardProgress = height / keyboardHeightWhenOpen
618
- }
619
- keyboardHeight = height
620
-
621
- guard let startEvent else { return }
622
-
623
- applyContentInset()
624
- applyScrollIndicatorInsets()
625
-
626
- if let (startY, endY) = startEvent.interpolateContentOffsetY {
627
- // Normalize progress to always go from 0 to 1 (start to end)
628
- // For opening: progress goes 0→1, so use as-is
629
- // For closing: progress goes 1→0, so invert it
630
- let normalizedProgress = startEvent.isOpening ? progress : (1 - progress)
631
- let newScrollY = startY + (endY - startY) * normalizedProgress
632
- scrollView?.setContentOffset(CGPoint(x: 0, y: newScrollY), animated: false)
633
- }
634
- }
635
-
636
516
  /// Handle keyboard animation end
637
517
  private func handleKeyboardDidMove(height: CGFloat, progress: CGFloat) {
638
- // Ensure final height is applied
639
518
  keyboardHeight = height
640
519
  if keyboardHeightWhenOpen > 0 {
641
520
  keyboardProgress = height / keyboardHeightWhenOpen
642
521
  }
643
522
 
644
- applyContentInset()
645
- applyScrollIndicatorInsets()
523
+ applyAllInsets()
646
524
 
647
525
  startEvent = nil
648
- isInInteractiveDismiss = false
649
526
 
650
527
  if queuedScrollToEnd?.waitForKeyboardToEnd == true {
651
528
  flushQueuedScrollToEnd(force: true)
652
529
  }
653
530
  }
654
531
 
655
- /// Handle interactive keyboard dismissal
656
- private func handleKeyboardMoveInteractive(height: CGFloat, progress: CGFloat) {
657
- // Mark that we're in an interactive dismiss if not already
658
- if !isInInteractiveDismiss && startEvent != nil {
659
- isInInteractiveDismiss = true
660
- if var event = startEvent {
661
- event.isInteractive = true
662
- startEvent = event
663
- }
664
- }
665
-
666
- // Update keyboard state
667
- handleKeyboardMove(height: height, progress: progress)
668
-
669
- // Update composer transform
670
- composerView?.applyKeyboardTransform(height: height, heightWhenOpen: keyboardHeightWhenOpen, animated: false)
671
- }
672
-
673
532
  // MARK: - Initialization
674
-
675
533
  override init() {
676
534
  let inner = InnerView()
677
535
  self.view = inner
678
536
  super.init()
679
537
  inner.owner = self
680
- print("[Aix] HybridAix initialized, attaching context to view")
681
538
  // Attach this context to our inner view
682
539
  view.aixContext = self
683
540
  }
684
541
 
685
542
  deinit {
686
- removePanGestureObserver()
687
543
  removeScrollViewObservers()
688
544
  }
689
545
 
690
546
  // MARK: - Lifecycle
691
-
692
547
  /// Called when our view is added to or removed from the HybridAixComponent
693
548
  private func handleDidMoveToSuperview() {
694
549
  if let superview = view.superview {
695
- print("[Aix] View added to superview: \(type(of: superview)), attaching context")
696
550
  // Attach context to the superview (HybridAixComponent) so children can find it
697
551
  superview.aixContext = self
698
552
 
@@ -738,7 +592,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
738
592
  }
739
593
 
740
594
  // MARK: - AixContext Protocol
741
-
742
595
  private var lastReportedBlankViewSize = (size: CGSize.zero, index: 0)
743
596
 
744
597
  func reportBlankViewSizeChange(size: CGSize, index: Int) {
@@ -749,22 +602,28 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
749
602
 
750
603
  lastReportedBlankViewSize = (size: size, index: index)
751
604
 
752
- // Check if we have a queued scroll waiting for this index
605
+ // Initial mount setup (tied to mainScrollViewID)
753
606
  if !didScrollToEndInitially {
754
607
  UIView.performWithoutAnimation {
755
- applyContentInset()
756
- applyScrollIndicatorInsets()
757
- scrollToEndInternal(animated: false)
608
+ applyAllInsets()
609
+
610
+ if shouldStartAtEnd {
611
+ scrollToEndInternal(animated: false)
612
+ }
613
+
614
+ // Set initial state based on actual scroll position after layout
758
615
  prevIsScrolledNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
759
616
  }
760
- didScrollToEndInitiallyForId = mainScrollViewID ?? ""
761
- } else {
762
- applyContentInset()
763
- applyScrollIndicatorInsets()
617
+
618
+ // Enable callbacks only after all setup is complete
619
+ didScrollToEndInitially = true
620
+ return
621
+ }
622
+
623
+ applyAllInsets()
764
624
 
765
- if let queued = queuedScrollToEnd, index == queued.index {
766
- flushQueuedScrollToEnd()
767
- }
625
+ if let queued = queuedScrollToEnd, index == queued.index {
626
+ flushQueuedScrollToEnd()
768
627
  }
769
628
  }
770
629
 
@@ -774,6 +633,11 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
774
633
  // If this cell is marked as last, update our blank view reference
775
634
  if cell.isLast {
776
635
  blankView = cell
636
+
637
+ // Trigger initial setup - handleLayoutChange may have already fired before
638
+ // the cell was in window, so we need to report here to ensure setup happens
639
+ let currentSize = cell.view.bounds.size
640
+ reportBlankViewSizeChange(size: currentSize, index: Int(cell.index))
777
641
  }
778
642
  }
779
643
 
@@ -799,72 +663,33 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
799
663
  private var lastReportedComposerHeight: CGFloat = 0
800
664
 
801
665
  func reportComposerHeightChange(height: CGFloat) {
802
- if height == lastReportedComposerHeight {
803
- return
804
- }
805
-
806
- let previousHeight = lastReportedComposerHeight
807
- let isShrinking = height < previousHeight
808
-
666
+ guard height != lastReportedComposerHeight else { return }
809
667
  lastReportedComposerHeight = height
810
-
811
- if !didScrollToEndInitially {
812
- return
813
- }
814
-
668
+
669
+ guard didScrollToEndInitially else { return }
670
+
815
671
  let shouldScroll = shouldScrollOnFooterSizeUpdate()
816
- let animated = scrollOnFooterSizeUpdate?.animated ?? false
817
-
818
- if shouldScroll && animated && isShrinking {
819
- guard let scrollView else {
820
- applyContentInset()
821
- applyScrollIndicatorInsets()
822
- return
823
- }
824
-
825
- let newContentInsetBottom = self.contentInsetBottom
826
- let bottomOffset = CGPoint(
827
- x: 0,
828
- y: max(0, scrollView.contentSize.height - scrollView.bounds.height + newContentInsetBottom)
829
- )
830
-
831
- UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut]) {
832
- scrollView.contentInset.bottom = newContentInsetBottom
833
- scrollView.contentOffset = bottomOffset
834
- }
835
- applyScrollIndicatorInsets()
836
- } else {
837
- applyContentInset()
838
- applyScrollIndicatorInsets()
839
-
840
- if shouldScroll {
841
- scrollToEndInternal(animated: animated)
842
- }
672
+ applyAllInsets()
673
+
674
+ if shouldScroll {
675
+ let animated = scrollOnFooterSizeUpdate?.animated ?? false
676
+ scrollToEndInternal(animated: animated)
843
677
  }
844
678
  }
845
679
 
846
680
  private func shouldScrollOnFooterSizeUpdate() -> Bool {
847
- guard let settings = scrollOnFooterSizeUpdate, settings.enabled else {
681
+ guard let settings = scrollOnFooterSizeUpdate, settings.enabled ?? true else {
848
682
  return false
849
683
  }
850
- guard let scrollView else {
684
+ guard scrollView != nil else {
851
685
  return false
852
686
  }
853
687
 
854
- let contentHeight = scrollView.contentSize.height
855
- let scrollViewHeight = scrollView.bounds.height
856
- let currentOffsetY = scrollView.contentOffset.y
857
- let bottomInset = scrollView.contentInset.bottom
858
-
859
- let maxOffsetY = max(0, contentHeight - scrollViewHeight + bottomInset)
860
- let distanceFromEnd = maxOffsetY - currentOffsetY
861
-
862
- let threshold = settings.scrolledToEndThreshold ?? 0
863
- return distanceFromEnd <= CGFloat(threshold)
688
+ let threshold = settings.scrolledToEndThreshold ?? 100
689
+ return distFromEnd <= CGFloat(threshold)
864
690
  }
865
691
 
866
692
  // MARK: - Cell Access
867
-
868
693
  /// Get a cell by its index
869
694
  func getCell(index: Int) -> HybridAixCellView? {
870
695
  return cells.object(forKey: NSNumber(value: index))
@@ -872,7 +697,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
872
697
 
873
698
 
874
699
  // MARK: - Scrolling
875
-
876
700
  func getIsQueuedScrollToEndReady(queuedScrollToEnd: QueuedScrollToEnd) -> Bool {
877
701
  guard let blankView else { return false }
878
702
  if queuedScrollToEnd.waitForKeyboardToEnd == true && startEvent != nil {
@@ -890,7 +714,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
890
714
  }
891
715
 
892
716
  // MARK: - Keyboard Notification Handlers
893
-
894
717
  func keyboardWillShow(notification: NSNotification) {
895
718
  guard let userInfo = notification.userInfo,
896
719
  let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
@@ -898,7 +721,6 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
898
721
  let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { return }
899
722
 
900
723
  let targetHeight = keyboardFrame.height
901
- print("[Aix] keyboardWillShow: targetHeight=\(targetHeight), duration=\(duration)")
902
724
 
903
725
  guard duration > 0 else { return }
904
726
 
@@ -909,8 +731,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
909
731
  if !didScrollToEndInitially {
910
732
  keyboardHeight = targetHeight
911
733
  keyboardProgress = 1.0
912
- applyContentInset()
913
- applyScrollIndicatorInsets()
734
+ applyAllInsets()
914
735
  composerView?.applyKeyboardTransform(height: targetHeight, heightWhenOpen: keyboardHeightWhenOpen, animated: false)
915
736
  return
916
737
  }
@@ -919,17 +740,16 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
919
740
 
920
741
  let options = UIView.AnimationOptions(rawValue: curveValue << 16)
921
742
  UIView.animate(withDuration: duration, delay: 0, options: options, animations: { [weak self] in
922
- guard let self = self else { return }
923
- self.keyboardHeight = targetHeight
924
- if self.keyboardHeightWhenOpen > 0 {
925
- self.keyboardProgress = targetHeight / self.keyboardHeightWhenOpen
743
+ guard let self else { return }
744
+ keyboardHeight = targetHeight
745
+ if keyboardHeightWhenOpen > 0 {
746
+ keyboardProgress = targetHeight / keyboardHeightWhenOpen
926
747
  }
927
- self.applyContentInset()
928
- self.applyScrollIndicatorInsets()
929
- self.composerView?.applyKeyboardTransform(height: targetHeight, heightWhenOpen: self.keyboardHeightWhenOpen, animated: false)
748
+ applyAllInsets()
749
+ composerView?.applyKeyboardTransform(height: targetHeight, heightWhenOpen: keyboardHeightWhenOpen, animated: false)
930
750
 
931
- if let (startY, endY) = self.startEvent?.interpolateContentOffsetY {
932
- self.scrollView?.setContentOffset(CGPoint(x: 0, y: endY), animated: false)
751
+ if let (_, endY) = startEvent?.interpolateContentOffsetY {
752
+ scrollView?.setContentOffset(CGPoint(x: 0, y: endY), animated: false)
933
753
  }
934
754
  }, completion: { [weak self] _ in
935
755
  self?.handleKeyboardDidMove(height: targetHeight, progress: 1.0)
@@ -941,249 +761,73 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
941
761
  let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double,
942
762
  let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { return }
943
763
 
944
- print("[Aix] keyboardWillHide: duration=\(duration)")
945
-
946
- // Don't interpolate scroll position when closing, the inset change will handle the visual transition
947
764
  startEvent = nil
948
765
 
949
766
  let options = UIView.AnimationOptions(rawValue: curveValue << 16)
950
767
  UIView.animate(withDuration: duration, delay: 0, options: options, animations: { [weak self] in
951
- guard let self = self else { return }
952
-
953
- self.keyboardHeight = 0
954
- self.keyboardProgress = 0
955
- self.applyContentInset()
956
- self.applyScrollIndicatorInsets()
957
- self.composerView?.applyKeyboardTransform(height: 0, heightWhenOpen: self.keyboardHeightWhenOpen, animated: false)
768
+ guard let self else { return }
769
+ keyboardHeight = 0
770
+ keyboardProgress = 0
771
+ applyAllInsets()
772
+ composerView?.applyKeyboardTransform(height: 0, heightWhenOpen: keyboardHeightWhenOpen, animated: false)
958
773
  }, completion: { [weak self] _ in
959
774
  self?.handleKeyboardDidMove(height: 0, progress: 0)
960
775
  })
961
776
  }
962
777
 
963
778
  func keyboardDidShow(notification: NSNotification) {
964
- print("[Aix] keyboardDidShow")
965
779
  }
966
780
 
967
781
  func keyboardDidHide(notification: NSNotification) {
968
- print("[Aix] keyboardDidHide")
969
782
  keyboardHeightWhenOpen = 0
970
783
  composerView?.applyKeyboardTransform(height: 0, heightWhenOpen: 0, animated: false)
971
784
  }
972
785
 
973
786
  func keyboardWillChangeFrame(notification: NSNotification) {
974
- guard let userInfo = notification.userInfo,
975
- let keyboardFrameEnd = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
976
-
977
- let screenHeight = UIScreen.main.bounds.height
978
- let keyboardTop = keyboardFrameEnd.origin.y
979
- let newHeight = max(0, screenHeight - keyboardTop)
980
-
981
- if startEvent != nil && !isInInteractiveDismiss {
982
- return
983
- }
984
-
985
- if isInInteractiveDismiss && newHeight != keyboardHeight {
986
- let progress = keyboardHeightWhenOpen > 0 ? newHeight / keyboardHeightWhenOpen : 0
987
- handleKeyboardMoveInteractive(height: newHeight, progress: progress)
988
- }
989
787
  }
990
788
  }
991
789
 
992
790
  // MARK: - Scroll Position Helpers
993
-
994
791
  extension HybridAix {
995
- /// Check if an interactive keyboard dismiss is in progress by examining scroll view state
996
- private func isInteractiveDismissInProgress() -> Bool {
997
- guard let scrollView = scrollView else { return false }
998
-
999
- // Check if scroll view has interactive keyboard dismiss mode
1000
- guard scrollView.keyboardDismissMode == .interactive else { return false }
1001
-
1002
- // Check if pan gesture is active (user is scrolling)
1003
- let panGesture = scrollView.panGestureRecognizer
1004
- let gestureState = panGesture.state
1005
-
1006
- // Pan gesture states: .began = 1, .changed = 2
1007
- return gestureState == .began || gestureState == .changed
1008
- }
1009
-
1010
792
  /// Distance from current scroll position to the maximum scroll position (end)
1011
793
  var distFromEnd: CGFloat {
1012
- guard let scrollView = scrollView else { return 0 }
1013
- let maxScrollY = scrollView.contentSize.height - scrollView.bounds.height + contentInsetBottom
1014
- return maxScrollY - scrollView.contentOffset.y
794
+ guard let scrollView else { return 0 }
795
+ return scrollView.contentSize.height - scrollView.bounds.height + contentInsetBottom - scrollView.contentOffset.y
1015
796
  }
1016
797
 
1017
798
  func getIsScrolledNearEnd(distFromEnd: CGFloat) -> Bool {
799
+ guard scrollView != nil else { return false }
1018
800
  return distFromEnd <= (scrollEndReachedThreshold ?? max(200, blankSize))
1019
801
  }
1020
-
802
+
1021
803
  func getContentOffsetYWhenOpening(scrollY: CGFloat) -> (CGFloat, CGFloat)? {
1022
- guard let scrollView else { return nil }
1023
- let isScrolledNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
1024
- let shouldShiftContentUp = blankSize == 0 && isScrolledNearEnd
1025
-
1026
- // Use the target additionalContentInsetBottom when keyboard is fully open
1027
- let targetAdditionalInset = CGFloat(self.additionalContentInsets?.bottom?.whenKeyboardOpen ?? 0)
804
+ guard let scrollView, getIsScrolledNearEnd(distFromEnd: distFromEnd), blankSize <= keyboardHeightWhenOpen else {
805
+ return nil
806
+ }
1028
807
 
1029
- // Calculate the max scroll position when keyboard is open
1030
- // This is where we want to scroll to: contentSize - bounds + contentInset
1031
- // When blankSize is 0: contentInset = keyboard + composer + additionalInset
1032
- let shiftContentUpToY = scrollView.contentSize.height - scrollView.bounds.height + keyboardHeightWhenOpen + composerHeight + targetAdditionalInset
808
+ let targetAdditionalInset = CGFloat(additionalContentInsets?.bottom?.whenKeyboardOpen ?? 0)
809
+ let targetY = scrollView.contentSize.height - scrollView.bounds.height + keyboardHeightWhenOpen + composerHeight + targetAdditionalInset
1033
810
 
1034
- if shouldShiftContentUp {
1035
- return (scrollY, shiftContentUpToY)
1036
- }
1037
-
1038
- let hasBlankSizeLessThanOpenKeyboardHeight = blankSize > 0 && blankSize <= keyboardHeightWhenOpen
1039
-
1040
- if hasBlankSizeLessThanOpenKeyboardHeight && isScrolledNearEnd {
1041
- return (scrollY, shiftContentUpToY)
1042
- }
1043
-
1044
- return nil
811
+ return (scrollY, targetY)
1045
812
  }
1046
813
 
1047
814
  func getContentOffsetYWhenClosing(scrollY: CGFloat) -> (CGFloat, CGFloat)? {
1048
- guard keyboardHeightWhenOpen > 0 else { return nil }
1049
- let isScrolledNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
1050
-
1051
- if !isScrolledNearEnd {
815
+ guard scrollView != nil, keyboardHeightWhenOpen > 0, getIsScrolledNearEnd(distFromEnd: distFromEnd) else {
1052
816
  return nil
1053
817
  }
1054
818
 
1055
- let additionalContentInsetBottomWithKeyboard = CGFloat(self.additionalContentInsets?.bottom?.whenKeyboardOpen ?? 0)
1056
- let additionalContentInsetBottomWithoutKeyboard = CGFloat(self.additionalContentInsets?.bottom?.whenKeyboardClosed ?? 0)
819
+ let additionalInsetOpen = CGFloat(additionalContentInsets?.bottom?.whenKeyboardOpen ?? 0)
820
+ let additionalInsetClosed = CGFloat(additionalContentInsets?.bottom?.whenKeyboardClosed ?? 0)
1057
821
 
1058
- // Calculate how much content inset will decrease when keyboard closes
1059
- let blankSizeWithKeyboard = calculateBlankSize(keyboardHeight: keyboardHeightWhenOpen, additionalContentInsetBottom: additionalContentInsetBottomWithKeyboard)
1060
- let blankSizeWithoutKeyboard = calculateBlankSize(keyboardHeight: 0, additionalContentInsetBottom: additionalContentInsetBottomWithoutKeyboard)
822
+ let blankSizeOpen = calculateBlankSize(keyboardHeight: keyboardHeightWhenOpen, additionalContentInsetBottom: additionalInsetOpen)
823
+ let blankSizeClosed = calculateBlankSize(keyboardHeight: 0, additionalContentInsetBottom: additionalInsetClosed)
1061
824
 
1062
- // Calculate actual content insets (including composer)
1063
- let insetWithKeyboard = calculateContentInsetBottom(keyboardHeight: keyboardHeightWhenOpen, blankSize: blankSizeWithKeyboard, additionalContentInsetBottom: additionalContentInsetBottomWithKeyboard)
1064
- let insetWithoutKeyboard = calculateContentInsetBottom(keyboardHeight: 0, blankSize: blankSizeWithoutKeyboard,
1065
- additionalContentInsetBottom: additionalContentInsetBottomWithoutKeyboard
1066
- )
1067
- let insetDecrease = insetWithKeyboard - insetWithoutKeyboard
825
+ let insetOpen = calculateContentInsetBottom(keyboardHeight: keyboardHeightWhenOpen, blankSize: blankSizeOpen, additionalContentInsetBottom: additionalInsetOpen)
826
+ let insetClosed = calculateContentInsetBottom(keyboardHeight: 0, blankSize: blankSizeClosed, additionalContentInsetBottom: additionalInsetClosed)
1068
827
 
1069
- // To keep the visual content position stable, we need to decrease scrollY
1070
- // by the same amount the inset decreases
1071
- let targetScrollY = max(0, scrollY - insetDecrease)
828
+ let targetScrollY = max(0, scrollY - (insetOpen - insetClosed))
1072
829
 
1073
- // Only interpolate if there's actually movement needed
1074
830
  guard abs(scrollY - targetScrollY) > 1 else { return nil }
1075
-
1076
831
  return (scrollY, targetScrollY)
1077
832
  }
1078
833
  }
1079
-
1080
- // Source - https://stackoverflow.com/a
1081
- // Posted by Vasily Bodnarchuk, modified by community. See post 'Timeline' for change history
1082
- // Retrieved 2026-01-07, License - CC BY-SA 4.0
1083
-
1084
- protocol KeyboardNotificationsDelegate: AnyObject {
1085
- func keyboardWillShow(notification: NSNotification)
1086
- func keyboardWillHide(notification: NSNotification)
1087
- func keyboardDidShow(notification: NSNotification)
1088
- func keyboardDidHide(notification: NSNotification)
1089
- func keyboardWillChangeFrame(notification: NSNotification)
1090
- }
1091
-
1092
- extension KeyboardNotificationsDelegate {
1093
- func keyboardWillShow(notification: NSNotification) {}
1094
- func keyboardWillHide(notification: NSNotification) {}
1095
- func keyboardDidShow(notification: NSNotification) {}
1096
- func keyboardDidHide(notification: NSNotification) {}
1097
- func keyboardWillChangeFrame(notification: NSNotification) {}
1098
- }
1099
-
1100
- class KeyboardNotifications {
1101
- fileprivate var _isEnabled: Bool
1102
- fileprivate var notifications: [KeyboardNotificationsType]
1103
- fileprivate weak var delegate: KeyboardNotificationsDelegate?
1104
-
1105
- init(notifications: [KeyboardNotificationsType], delegate: KeyboardNotificationsDelegate) {
1106
- _isEnabled = false
1107
- self.notifications = notifications
1108
- self.delegate = delegate
1109
- }
1110
-
1111
- deinit { if isEnabled { isEnabled = false } }
1112
- }
1113
-
1114
- // MARK: - enums
1115
-
1116
- extension KeyboardNotifications {
1117
-
1118
- enum KeyboardNotificationsType {
1119
- case willShow, willHide, didShow, didHide, willChangeFrame
1120
-
1121
- var selector: Selector {
1122
- switch self {
1123
- case .willShow: return #selector(keyboardWillShow(notification:))
1124
- case .willHide: return #selector(keyboardWillHide(notification:))
1125
- case .didShow: return #selector(keyboardDidShow(notification:))
1126
- case .didHide: return #selector(keyboardDidHide(notification:))
1127
- case .willChangeFrame: return #selector(keyboardWillChangeFrame(notification:))
1128
- }
1129
- }
1130
-
1131
- var notificationName: NSNotification.Name {
1132
- switch self {
1133
- case .willShow: return UIResponder.keyboardWillShowNotification
1134
- case .willHide: return UIResponder.keyboardWillHideNotification
1135
- case .didShow: return UIResponder.keyboardDidShowNotification
1136
- case .didHide: return UIResponder.keyboardDidHideNotification
1137
- case .willChangeFrame: return UIResponder.keyboardWillChangeFrameNotification
1138
- }
1139
- }
1140
- }
1141
- }
1142
-
1143
- // MARK: - isEnabled
1144
-
1145
- extension KeyboardNotifications {
1146
-
1147
- private func addObserver(type: KeyboardNotificationsType) {
1148
- NotificationCenter.default.addObserver(self, selector: type.selector, name: type.notificationName, object: nil)
1149
- }
1150
-
1151
- var isEnabled: Bool {
1152
- set {
1153
- if newValue {
1154
- for notificaton in notifications { addObserver(type: notificaton) }
1155
- } else {
1156
- NotificationCenter.default.removeObserver(self)
1157
- }
1158
- _isEnabled = newValue
1159
- }
1160
-
1161
- get { return _isEnabled }
1162
- }
1163
-
1164
- }
1165
-
1166
- // MARK: - Notification functions
1167
-
1168
- extension KeyboardNotifications {
1169
-
1170
- @objc func keyboardWillShow(notification: NSNotification) {
1171
- delegate?.keyboardWillShow(notification: notification)
1172
- }
1173
-
1174
- @objc func keyboardWillHide(notification: NSNotification) {
1175
- delegate?.keyboardWillHide(notification: notification)
1176
- }
1177
-
1178
- @objc func keyboardDidShow(notification: NSNotification) {
1179
- delegate?.keyboardDidShow(notification: notification)
1180
- }
1181
-
1182
- @objc func keyboardDidHide(notification: NSNotification) {
1183
- delegate?.keyboardDidHide(notification: notification)
1184
- }
1185
-
1186
- @objc func keyboardWillChangeFrame(notification: NSNotification) {
1187
- delegate?.keyboardWillChangeFrame(notification: notification)
1188
- }
1189
- }