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.
- package/ios/HybridAix.swift +160 -34
- package/ios/HybridAixCellView.swift +14 -7
- package/package.json +1 -1
package/ios/HybridAix.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
|
635
|
+
// Initial mount setup - wait for all cells to be registered
|
|
607
636
|
if !didScrollToEndInitially {
|
|
608
|
-
|
|
609
|
-
|
|
637
|
+
print("[Aix] reportBlankViewSizeChange - calling tryCompleteInitialLayout")
|
|
638
|
+
tryCompleteInitialLayout()
|
|
639
|
+
return
|
|
640
|
+
}
|
|
610
641
|
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
616
|
-
|
|
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
|
-
|
|
620
|
-
|
|
681
|
+
if !missingCells.isEmpty {
|
|
682
|
+
print("[Aix] tryCompleteInitialLayout - waiting for cells")
|
|
621
683
|
return
|
|
622
684
|
}
|
|
623
685
|
|
|
624
|
-
|
|
625
|
-
let wasNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
|
|
686
|
+
print("[Aix] tryCompleteInitialLayout - all cells registered, completing initial layout")
|
|
626
687
|
|
|
627
|
-
|
|
688
|
+
// All cells are registered, complete initial layout
|
|
689
|
+
UIView.performWithoutAnimation {
|
|
690
|
+
applyAllInsets()
|
|
691
|
+
print("[Aix] tryCompleteInitialLayout - insets applied")
|
|
628
692
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
//
|
|
649
|
-
//
|
|
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
|
-
|
|
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
|
|
654
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|