aix 0.7.1-alpha.5 → 0.7.1-alpha.7

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.
@@ -22,6 +22,9 @@ protocol AixContext: AnyObject {
22
22
  /// Called when the blank view's size changes
23
23
  func reportBlankViewSizeChange(size: CGSize, index: Int)
24
24
 
25
+ /// Called when any cell's height changes (for scroll position compensation)
26
+ func reportCellHeightChange(index: Int, height: CGFloat)
27
+
25
28
  /// Register a cell with the context
26
29
  func registerCell(_ cell: HybridAixCellView)
27
30
 
@@ -147,6 +150,8 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
147
150
  cells.removeAllObjects()
148
151
  lastReportedBlankViewSize = (size: .zero, index: 0)
149
152
  lastCalculatedBlankSize = 0
153
+ anchoredScrollOffset = nil
154
+ pendingAnimatedScroll = false
150
155
  }
151
156
  }
152
157
 
@@ -308,6 +313,14 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
308
313
  /// Cache the last successfully calculated blank size to avoid jumps when cells are temporarily missing
309
314
  private var lastCalculatedBlankSize: CGFloat = 0
310
315
 
316
+ /// The anchored scroll offset after an animated scrollToIndex completes.
317
+ /// When set, all cell height changes will restore to this offset to maintain position stability.
318
+ private var anchoredScrollOffset: CGPoint? = nil
319
+
320
+ /// When true, an animated scroll is pending and will be executed after keyboard animation completes.
321
+ /// This prevents conflicts between keyboard closing animation and our scroll animation.
322
+ private var pendingAnimatedScroll: Bool = false
323
+
311
324
  /// Get the penultimate cell index (the one that should stay at top when scrolled to end)
312
325
  private func getPenultimateCellIndex() -> Int {
313
326
  if let penultimateCellIndex {
@@ -318,7 +331,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
318
331
  }
319
332
 
320
333
  private func calculateBlankSize(keyboardHeight: CGFloat, additionalContentInsetBottom: CGFloat) -> CGFloat {
321
- guard let scrollView, let blankView else {
334
+ guard let scrollView, let blankView else {
322
335
  return lastCalculatedBlankSize
323
336
  }
324
337
 
@@ -330,10 +343,13 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
330
343
  // This includes the penultimate cell AND any cells after it (e.g., AI responses)
331
344
  var cellsHeight: CGFloat = 0
332
345
  var hasMissingCells = false
346
+ var cellHeights: [(Int, CGFloat)] = []
333
347
  if startIndex >= 0 && startIndex <= endIndex {
334
348
  for i in startIndex...endIndex {
335
349
  if let cell = getCell(index: i) {
336
- cellsHeight += cell.view.frame.height
350
+ let h = cell.view.frame.height
351
+ cellsHeight += h
352
+ cellHeights.append((i, h))
337
353
  } else {
338
354
  hasMissingCells = true
339
355
  }
@@ -381,7 +397,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
381
397
 
382
398
  let targetTop = additionalContentInsetTop
383
399
  let targetBottom = overrideContentInsetBottom ?? self.contentInsetBottom
384
-
400
+
385
401
  // Create insets struct for callback
386
402
  let insets = AixContentInsets(
387
403
  top: Double(targetTop),
@@ -391,11 +407,11 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
391
407
  )
392
408
 
393
409
  onWillApplyContentInsets?(insets)
394
-
410
+
395
411
  if shouldApplyContentInsets == false {
396
412
  return
397
413
  }
398
-
414
+
399
415
  // Helper to actually apply the insets
400
416
  let applyInsets = { [weak self] in
401
417
  guard let self, let scrollView = self.scrollView else { return }
@@ -465,8 +481,9 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
465
481
  applyScrollIndicatorInsets()
466
482
  }
467
483
 
468
- private func scrollToEndInternal(animated: Bool?) {
469
- guard let scrollView else { return }
484
+ @discardableResult
485
+ private func scrollToEndInternal(animated: Bool?) -> CGPoint? {
486
+ guard let scrollView else { return nil }
470
487
 
471
488
  // Ensure layout is complete before calculating scroll position
472
489
  scrollView.layoutIfNeeded()
@@ -474,10 +491,12 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
474
491
  let contentHeight = scrollView.contentSize.height
475
492
  let boundsHeight = scrollView.bounds.height
476
493
  let appliedInset = scrollView.contentInset.bottom
494
+
477
495
  let targetY = max(0, contentHeight - boundsHeight + appliedInset)
478
496
 
479
497
  let bottomOffset = CGPoint(x: 0, y: targetY)
480
498
  scrollView.setContentOffset(bottomOffset, animated: animated ?? true)
499
+ return bottomOffset
481
500
  }
482
501
 
483
502
 
@@ -530,6 +549,16 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
530
549
  applyAllInsets()
531
550
 
532
551
  startEvent = nil
552
+
553
+ // Execute pending animated scroll now that keyboard animation is complete
554
+ if pendingAnimatedScroll {
555
+ pendingAnimatedScroll = false
556
+ print("[Aix] handleKeyboardDidMove - executing pending animated scroll")
557
+
558
+ if let targetOffset = scrollToEndInternal(animated: true) {
559
+ anchoredScrollOffset = targetOffset
560
+ }
561
+ }
533
562
  }
534
563
 
535
564
  // MARK: - Initialization
@@ -603,59 +632,156 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
603
632
 
604
633
  lastReportedBlankViewSize = (size: size, index: index)
605
634
 
606
- // Initial mount setup (tied to mainScrollViewID)
635
+ // Initial mount setup - wait for all cells to be registered
607
636
  if !didScrollToEndInitially {
608
- UIView.performWithoutAnimation {
609
- applyAllInsets()
637
+ print("[Aix] reportBlankViewSizeChange - calling tryCompleteInitialLayout")
638
+ tryCompleteInitialLayout()
639
+ return
640
+ }
610
641
 
611
- if shouldStartAtEnd {
612
- scrollToEndInternal(animated: false)
613
- }
642
+ // After initial layout, only apply insets without scrolling.
643
+ // The blankSize adjustment will compensate for any cell height changes,
644
+ // keeping the content offset stable.
645
+ print("[Aix] reportBlankViewSizeChange - applying insets (post-initial)")
646
+ applyAllInsetsWithAnchoredOffset()
647
+ }
648
+
649
+ /// Check if all cells are registered and complete initial layout if so
650
+ private func tryCompleteInitialLayout() {
651
+ guard !didScrollToEndInitially else {
652
+ print("[Aix] tryCompleteInitialLayout - already completed")
653
+ return
654
+ }
655
+ guard let blankView else {
656
+ print("[Aix] tryCompleteInitialLayout - no blankView yet")
657
+ return
658
+ }
659
+
660
+ let blankViewIndex = Int(blankView.index)
614
661
 
615
- // Set initial state based on actual scroll position after layout
616
- prevIsScrolledNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
662
+ // Make sure the blank view itself is registered (not just set via updateBlankViewStatus)
663
+ if getCell(index: blankViewIndex) == nil {
664
+ print("[Aix] tryCompleteInitialLayout - blankView is not registered yet")
665
+ return
666
+ }
667
+
668
+ // Check if all cells from 0 to blankView.index-1 are registered
669
+ let expectedCount = blankViewIndex
670
+ var registeredCells: [Int] = []
671
+ var missingCells: [Int] = []
672
+
673
+ for i in 0..<expectedCount {
674
+ if getCell(index: i) != nil {
675
+ registeredCells.append(i)
676
+ } else {
677
+ missingCells.append(i)
617
678
  }
679
+ }
618
680
 
619
- // Enable callbacks only after all setup is complete
620
- didScrollToEndInitially = true
681
+ if !missingCells.isEmpty {
682
+ print("[Aix] tryCompleteInitialLayout - waiting for cells")
621
683
  return
622
684
  }
623
685
 
624
- // Check if we were near end BEFORE applying new insets
625
- let wasNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
686
+ print("[Aix] tryCompleteInitialLayout - all cells registered, completing initial layout")
626
687
 
627
- applyAllInsets()
688
+ // All cells are registered, complete initial layout
689
+ UIView.performWithoutAnimation {
690
+ applyAllInsets()
691
+ print("[Aix] tryCompleteInitialLayout - insets applied")
628
692
 
629
- // If scrollToIndexTarget matches, defer — the content cell may not have
630
- // registered yet, so blankSize would be stale. registerCell will flush it.
631
- if wasNearEnd && scrollToIndexTarget != index {
632
- scrollToEndInternal(animated: false)
693
+ if shouldStartAtEnd {
694
+ print("[Aix] tryCompleteInitialLayout - scrolling to end")
695
+ scrollToEndInternal(animated: false)
696
+ }
697
+
698
+ prevIsScrolledNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
633
699
  }
700
+
701
+ didScrollToEndInitially = true
702
+ print("[Aix] tryCompleteInitialLayout - COMPLETED, didScrollToEndInitially = true")
634
703
  }
635
-
704
+
705
+ func reportCellHeightChange(index: Int, height: CGFloat) {
706
+ print("[Aix] reportCellHeightChange")
707
+
708
+ // Only process after initial layout is complete
709
+ guard didScrollToEndInitially else {
710
+ print("[Aix] reportCellHeightChange - skipping, initial layout not complete")
711
+ return
712
+ }
713
+
714
+ // Preserve scroll position while applying insets.
715
+ // When cells grow, blankSize shrinks and contentInset.bottom decreases,
716
+ // but we want to keep the visible content stable.
717
+ applyAllInsetsWithAnchoredOffset()
718
+ }
719
+
636
720
  func registerCell(_ cell: HybridAixCellView) {
637
721
  cells.setObject(cell, forKey: NSNumber(value: cell.index))
638
722
 
639
723
  if cell.isLast {
724
+ print("[Aix] registerCell - this is blankView, setting and reporting size")
640
725
  blankView = cell
641
726
  let currentSize = cell.view.bounds.size
642
727
  reportBlankViewSizeChange(size: currentSize, index: Int(cell.index))
643
- } else if didScrollToEndInitially {
644
- let wasNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
645
-
646
- applyAllInsets()
728
+ } else if !didScrollToEndInitially {
729
+ // During initial mount, check if all cells are now registered
730
+ print("[Aix] registerCell - during initial mount, checking completion")
731
+ tryCompleteInitialLayout()
732
+ } else {
733
+ // After initial layout, apply insets for blankSize compensation
734
+ print("[Aix] registerCell - post-initial, applying insets")
647
735
 
648
- // Flush deferred animated scroll the content cell is now registered
649
- // so blankSize is accurate
736
+ // Only perform animated scroll when scrollToIndex is explicitly set.
737
+ // This happens when a new user message is added (becomes penultimate cell).
650
738
  if let target = scrollToIndexTarget, let blankView, target == Int(blankView.index) {
651
- scrollToEndInternal(animated: true)
739
+ let isKeyboardTransitioning = startEvent != nil
740
+ print("[Aix] registerCell - scrollToIndex target matches")
741
+
742
+ applyAllInsets()
743
+
744
+ if isKeyboardTransitioning {
745
+ // Keyboard is animating - defer our scroll to avoid conflicts
746
+ // handleKeyboardDidMove will execute the scroll when keyboard animation ends
747
+ print("[Aix] registerCell - deferring animated scroll until keyboard animation completes")
748
+ pendingAnimatedScroll = true
749
+ } else {
750
+ // No keyboard animation - scroll immediately
751
+ if let targetOffset = scrollToEndInternal(animated: true) {
752
+ anchoredScrollOffset = targetOffset
753
+ }
754
+ }
755
+
652
756
  onDidScrollToIndex?()
653
- } else if wasNearEnd && scrollToIndexTarget == nil {
654
- scrollToEndInternal(animated: false)
757
+ } else {
758
+ applyAllInsetsWithAnchoredOffset()
655
759
  }
656
760
  }
657
761
  }
658
762
 
763
+ /// Apply insets while maintaining the anchored scroll offset
764
+ private func applyAllInsetsWithAnchoredOffset() {
765
+ guard let scrollView else { return }
766
+
767
+ // When scrollToIndex is active, the animated scroll handles positioning.
768
+ // Don't restore offset - let the animation complete naturally.
769
+ if scrollToIndexTarget != nil {
770
+ applyAllInsets()
771
+ return
772
+ }
773
+
774
+ // Use anchored offset if available, otherwise save current
775
+ let targetOffset = anchoredScrollOffset ?? scrollView.contentOffset
776
+
777
+ applyAllInsets()
778
+
779
+ // Restore to anchored/saved position
780
+ if scrollView.contentOffset != targetOffset {
781
+ scrollView.contentOffset = targetOffset
782
+ }
783
+ }
784
+
659
785
  func unregisterCell(_ cell: HybridAixCellView) {
660
786
  cells.removeObject(forKey: NSNumber(value: cell.index))
661
787
 
@@ -137,14 +137,21 @@ class HybridAixCellView: HybridAixCellViewSpec {
137
137
 
138
138
  /// Called when layoutSubviews fires (size may have changed)
139
139
  private func handleLayoutChange() {
140
- // Only report size changes for the last cell (blank view)
141
- // and only if the size actually changed
142
140
  let currentSize = view.bounds.size
143
- if isLast && currentSize != lastReportedSize {
144
- lastReportedSize = currentSize
145
- if let ctx = getAixContext() {
146
- ctx.reportBlankViewSizeChange(size: currentSize, index: Int(index))
147
- }
141
+ guard currentSize != lastReportedSize else { return }
142
+
143
+ let oldHeight = lastReportedSize.height
144
+ lastReportedSize = currentSize
145
+
146
+ guard let ctx = getAixContext() else { return }
147
+
148
+ if isLast {
149
+ // Report blank view size change (handles initial layout and blank size)
150
+ ctx.reportBlankViewSizeChange(size: currentSize, index: Int(index))
151
+ } else if oldHeight > 0 {
152
+ // Report height change for non-blank cells (for scroll position compensation)
153
+ // Only report after initial layout (oldHeight > 0) to avoid spurious updates
154
+ ctx.reportCellHeightChange(index: Int(index), height: currentSize.height)
148
155
  }
149
156
  }
150
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aix",
3
- "version": "0.7.1-alpha.5",
3
+ "version": "0.7.1-alpha.7",
4
4
  "author": "Fernando Rojo",
5
5
  "repository": {
6
6
  "type": "git",