aix 0.7.1-alpha.8 → 0.8.0
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/README.md +26 -43
- package/ios/HybridAix.swift +34 -26
- package/ios/HybridAixComposer.swift +18 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ alt="aix" width="1600" height="900" />
|
|
|
5
5
|
|
|
6
6
|
UI Primitives for building AI apps in React Native.
|
|
7
7
|
|
|
8
|
+
> [!WARNING]
|
|
8
9
|
> aix is currently in alpha preview. The API is likely to change.
|
|
9
10
|
|
|
10
11
|
## Features
|
|
@@ -121,27 +122,35 @@ export function ChatScreen({ messages }) {
|
|
|
121
122
|
### Send a message
|
|
122
123
|
|
|
123
124
|
When sending a message, you will likely want to scroll to it after it gets added
|
|
124
|
-
to the list.
|
|
125
|
-
|
|
126
|
-
Simply call `aix.current?.scrollToIndexWhenBlankSizeReady(index)` in your submit
|
|
127
|
-
handler.
|
|
125
|
+
to the list. Use the `scrollToIndex` prop to declaratively trigger an animated
|
|
126
|
+
scroll, and `onDidScrollToIndex` to reset the state when the scroll completes.
|
|
128
127
|
|
|
129
128
|
The `index` you pass should correspond to the newest message in the list. For AI
|
|
130
|
-
chats, this is typically the next assistant message index.
|
|
129
|
+
chats, this is typically the next assistant message index. Use a nullish value (`null | undefined`) to indicate
|
|
130
|
+
no scroll target.
|
|
131
131
|
|
|
132
132
|
```tsx
|
|
133
133
|
import { Keyboard } from 'react-native'
|
|
134
|
-
import {
|
|
134
|
+
import { useState } from 'react'
|
|
135
135
|
|
|
136
136
|
function Chat() {
|
|
137
|
-
const
|
|
137
|
+
const [scrollToIndex, setScrollToIndex] = useState<number | null>(null)
|
|
138
138
|
|
|
139
139
|
const onSubmit = () => {
|
|
140
|
-
|
|
140
|
+
const newMessageIndex = messages.length + 1
|
|
141
|
+
setScrollToIndex(newMessageIndex)
|
|
142
|
+
sendMessage(message)
|
|
141
143
|
requestAnimationFrame(Keyboard.dismiss)
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
return
|
|
146
|
+
return (
|
|
147
|
+
<Aix
|
|
148
|
+
scrollToIndex={scrollToIndex}
|
|
149
|
+
onDidScrollToIndex={() => setScrollToIndex(null)}
|
|
150
|
+
>
|
|
151
|
+
{/* ... */}
|
|
152
|
+
</Aix>
|
|
153
|
+
)
|
|
145
154
|
}
|
|
146
155
|
```
|
|
147
156
|
|
|
@@ -197,6 +206,8 @@ scrolling for chat interfaces.
|
|
|
197
206
|
| `additionalScrollIndicatorInsets` | `object` | - | Additional insets for the scroll indicator, added to existing safe area insets. Applied to `verticalScrollIndicatorInsets` on iOS. |
|
|
198
207
|
| `mainScrollViewID` | `string` | - | The `nativeID` of the scroll view to use. If provided, will search for a scroll view with this `accessibilityIdentifier`. |
|
|
199
208
|
| `penultimateCellIndex` | `number` | - | The index of the second-to-last message (typically the last user message in AI chat apps). Used to determine which message will be scrolled into view. Useful when you have custom message types like timestamps in your list. |
|
|
209
|
+
| `scrollToIndex` | `number` | - | When set, triggers an animated scroll to this cell index. Use a nullish value (`null` or `undefined`) to indicate no scroll target. After the scroll completes, `onDidScrollToIndex` is called. Reset after receiving the callback. |
|
|
210
|
+
| `onDidScrollToIndex` | `() => void` | - | Called when the animated scroll to `scrollToIndex` completes. Use this to clear the `scrollToIndex` state. |
|
|
200
211
|
|
|
201
212
|
#### Ref Methods
|
|
202
213
|
|
|
@@ -207,19 +218,14 @@ const aix = useAixRef()
|
|
|
207
218
|
|
|
208
219
|
// Scroll to the end of the content
|
|
209
220
|
aix.current?.scrollToEnd(animated)
|
|
210
|
-
|
|
211
|
-
// Scroll to a specific index when the blank size is ready
|
|
212
|
-
aix.current?.scrollToIndexWhenBlankSizeReady(
|
|
213
|
-
index,
|
|
214
|
-
animated,
|
|
215
|
-
waitForKeyboardToEnd
|
|
216
|
-
)
|
|
217
221
|
```
|
|
218
222
|
|
|
219
|
-
| Method
|
|
220
|
-
|
|
|
221
|
-
| `scrollToEnd`
|
|
222
|
-
|
|
223
|
+
| Method | Parameters | Description |
|
|
224
|
+
| ------------- | -------------------- | ---------------------------------- |
|
|
225
|
+
| `scrollToEnd` | `animated?: boolean` | Scrolls to the end of the content. |
|
|
226
|
+
|
|
227
|
+
> **Note:** For scrolling to a specific message index, prefer using the
|
|
228
|
+
> declarative `scrollToIndex` prop instead of imperative methods.
|
|
223
229
|
|
|
224
230
|
---
|
|
225
231
|
|
|
@@ -258,31 +264,8 @@ Accepts all standard React Native `View` props.
|
|
|
258
264
|
<Composer />
|
|
259
265
|
</AixFooter>
|
|
260
266
|
```
|
|
261
|
-
|
|
262
267
|
---
|
|
263
268
|
|
|
264
|
-
### `useAixRef`
|
|
265
|
-
|
|
266
|
-
A hook that returns a ref to access imperative methods on the `Aix` component.
|
|
267
|
-
|
|
268
|
-
```tsx
|
|
269
|
-
import { useAixRef } from 'aix'
|
|
270
|
-
|
|
271
|
-
function Chat({ messages }) {
|
|
272
|
-
const aix = useAixRef()
|
|
273
|
-
const send = useSendMessage()
|
|
274
|
-
|
|
275
|
-
const handleSend = () => {
|
|
276
|
-
// Scroll to end after sending a message
|
|
277
|
-
send(message)
|
|
278
|
-
aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
|
|
279
|
-
requestAnimationFrame(Keyboard.dismiss)
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
return <Aix ref={aix}>{/* ... */}</Aix>
|
|
283
|
-
}
|
|
284
|
-
```
|
|
285
|
-
|
|
286
269
|
### Rules
|
|
287
270
|
|
|
288
271
|
- Do **NOT** apply padding to `contentContainerStyle`. Instead, use padding on
|
package/ios/HybridAix.swift
CHANGED
|
@@ -326,7 +326,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
private func calculateBlankSize(keyboardHeight: CGFloat, additionalContentInsetBottom: CGFloat) -> CGFloat {
|
|
329
|
-
guard let scrollView, let blankView else {
|
|
329
|
+
guard let scrollView, let blankView else {
|
|
330
330
|
return lastCalculatedBlankSize
|
|
331
331
|
}
|
|
332
332
|
|
|
@@ -646,7 +646,15 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
646
646
|
return
|
|
647
647
|
}
|
|
648
648
|
|
|
649
|
-
//
|
|
649
|
+
// Check if this blankView matches the scrollToIndex target
|
|
650
|
+
// This handles the case where isLast prop updates after cell registration
|
|
651
|
+
if let target = scrollToIndexTarget, target == index {
|
|
652
|
+
print("[Aix] reportBlankViewSizeChange - scrollToIndex target matches")
|
|
653
|
+
performScrollToIndex()
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Skip regular inset updates when animated scroll is in progress to avoid interfering
|
|
650
658
|
if scrollToIndexTarget != nil {
|
|
651
659
|
print("[Aix] reportBlankViewSizeChange - skipping, scrollToIndex active")
|
|
652
660
|
return
|
|
@@ -740,47 +748,47 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
740
748
|
cells.setObject(cell, forKey: NSNumber(value: cell.index))
|
|
741
749
|
|
|
742
750
|
if cell.isLast {
|
|
743
|
-
print("[Aix] registerCell - this is blankView
|
|
751
|
+
print("[Aix] registerCell - this is blankView")
|
|
744
752
|
blankView = cell
|
|
753
|
+
|
|
754
|
+
if let target = scrollToIndexTarget, target == Int(cell.index) {
|
|
755
|
+
print("[Aix] registerCell - scrollToIndex target matches!")
|
|
756
|
+
performScrollToIndex()
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
|
|
745
760
|
let currentSize = cell.view.bounds.size
|
|
746
761
|
reportBlankViewSizeChange(size: currentSize, index: Int(cell.index))
|
|
747
762
|
} else if !didScrollToEndInitially {
|
|
748
|
-
// During initial mount, check if all cells are now registered
|
|
749
763
|
print("[Aix] registerCell - during initial mount, checking completion")
|
|
750
764
|
tryCompleteInitialLayout()
|
|
751
765
|
} else {
|
|
752
|
-
// After initial layout, apply insets for blankSize compensation
|
|
753
766
|
print("[Aix] registerCell - post-initial, applying insets")
|
|
754
767
|
|
|
755
|
-
// Only perform animated scroll when scrollToIndex is explicitly set.
|
|
756
|
-
// This happens when a new user message is added (becomes penultimate cell).
|
|
757
768
|
if let target = scrollToIndexTarget, let blankView, target == Int(blankView.index) {
|
|
758
|
-
let isKeyboardTransitioning = startEvent != nil
|
|
759
769
|
print("[Aix] registerCell - scrollToIndex target matches")
|
|
760
|
-
|
|
761
|
-
applyAllInsets()
|
|
762
|
-
|
|
763
|
-
if isKeyboardTransitioning {
|
|
764
|
-
// Keyboard is animating - defer our scroll to avoid conflicts
|
|
765
|
-
// handleKeyboardDidMove will execute the scroll when keyboard animation ends
|
|
766
|
-
print("[Aix] registerCell - deferring animated scroll until keyboard animation completes")
|
|
767
|
-
pendingAnimatedScroll = true
|
|
768
|
-
// onDidScrollToIndex will be called when keyboard animation completes
|
|
769
|
-
} else {
|
|
770
|
-
// No keyboard animation - scroll immediately
|
|
771
|
-
// Call onDidScrollToIndex after animation completes so scrollToIndexTarget
|
|
772
|
-
// stays set during animation (used by skip logic in reportCellHeightChange)
|
|
773
|
-
scrollToEndInternal(animated: true) { [weak self] in
|
|
774
|
-
self?.onDidScrollToIndex?()
|
|
775
|
-
}
|
|
776
|
-
}
|
|
770
|
+
performScrollToIndex()
|
|
777
771
|
} else if scrollToIndexTarget == nil {
|
|
778
|
-
// Only apply insets when no animated scroll is active
|
|
779
772
|
applyAllInsets()
|
|
780
773
|
}
|
|
781
774
|
}
|
|
782
775
|
}
|
|
783
776
|
|
|
777
|
+
/// Performs animated scroll to end with proper keyboard transition handling.
|
|
778
|
+
/// If keyboard is animating, defers scroll until animation completes.
|
|
779
|
+
private func performScrollToIndex() {
|
|
780
|
+
applyAllInsets()
|
|
781
|
+
|
|
782
|
+
if startEvent != nil {
|
|
783
|
+
print("[Aix] performScrollToIndex - deferring until keyboard animation completes")
|
|
784
|
+
pendingAnimatedScroll = true
|
|
785
|
+
} else {
|
|
786
|
+
scrollToEndInternal(animated: true) { [weak self] in
|
|
787
|
+
self?.onDidScrollToIndex?()
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
784
792
|
func unregisterCell(_ cell: HybridAixCellView) {
|
|
785
793
|
cells.removeObject(forKey: NSNumber(value: cell.index))
|
|
786
794
|
|
|
@@ -21,15 +21,15 @@ class HybridAixComposer: HybridAixComposerSpec {
|
|
|
21
21
|
if let ctx = cachedAixContext {
|
|
22
22
|
applyKeyboardTransform(height: ctx.keyboardHeight, heightWhenOpen: ctx.keyboardHeightWhenOpen, animated: false)
|
|
23
23
|
}
|
|
24
|
+
// Re-apply text input fixes (content inset adjustment depends on stickToKeyboard)
|
|
25
|
+
applyTextInputFixes()
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
var fixInput: Bool? = nil {
|
|
28
30
|
didSet {
|
|
29
31
|
cachedTextInput = nil
|
|
30
|
-
|
|
31
|
-
resolveTextInput()
|
|
32
|
-
}
|
|
32
|
+
applyTextInputFixes()
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -141,8 +141,8 @@ class HybridAixComposer: HybridAixComposerSpec {
|
|
|
141
141
|
applyKeyboardTransform(height: ctx.keyboardHeight, heightWhenOpen: ctx.keyboardHeightWhenOpen, animated: false)
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
//
|
|
145
|
-
|
|
144
|
+
// Apply text input fixes once the hierarchy is connected
|
|
145
|
+
applyTextInputFixes()
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/// Called when the view is about to be removed from superview
|
|
@@ -179,12 +179,20 @@ class HybridAixComposer: HybridAixComposerSpec {
|
|
|
179
179
|
return input
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
///
|
|
183
|
-
private func
|
|
184
|
-
|
|
185
|
-
guard fixInput == true, let input = textInput else { return }
|
|
182
|
+
/// Apply fixes to the text input based on current props
|
|
183
|
+
private func applyTextInputFixes() {
|
|
184
|
+
guard let input = textInput else { return }
|
|
186
185
|
guard let scrollView = input as? UIScrollView else { return }
|
|
187
|
-
|
|
186
|
+
|
|
187
|
+
// When stickToKeyboard is enabled, the footer uses transforms to position above keyboard.
|
|
188
|
+
// iOS calculates safe area based on frame (not visual position), so it incorrectly adds
|
|
189
|
+
// bottom safe area insets. Disable automatic adjustment since we manage positioning.
|
|
190
|
+
if stickToKeyboard?.enabled == true {
|
|
191
|
+
scrollView.contentInsetAdjustmentBehavior = .never
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Apply fixInput patches
|
|
195
|
+
guard fixInput == true else { return }
|
|
188
196
|
scrollView.showsVerticalScrollIndicator = false
|
|
189
197
|
scrollView.showsHorizontalScrollIndicator = false
|
|
190
198
|
scrollView.bounces = false
|