aix 0.6.1 → 0.7.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 +173 -90
- package/ios/HybridAix.swift +18 -3
- package/ios/HybridAixCellView.swift +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
<img src="https://github.com/vercel/aix/blob/main/Aix.png?raw=true"
|
|
2
|
+
alt="aix" width="1600" height="900" />
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
# AIX
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
UI Primitives for building AI apps in React Native.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
> aix is currently in alpha preview. The API is likely to change.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- Start a chat scrolled to end on the first frame
|
|
13
|
+
- Animate scrolling to new messages when they send
|
|
14
|
+
- Float messages to the top of the screen with automated "blank size" handling
|
|
15
|
+
- Animate message content as it streams
|
|
16
|
+
- Keyboard handling out-of-the-box with no external dependencies
|
|
17
|
+
- Support for absolute-positioned composers
|
|
18
|
+
- Detect "is scrolled near end" for ScrollToEnd buttons
|
|
19
|
+
|
|
20
|
+
To learn about the motivation behind AIX, you can read our blog post on
|
|
21
|
+
[How we built the v0 iOS app](https://vercel.com/blog/how-we-built-the-v0-ios-app).
|
|
22
|
+
AIX is an opinionated, feature-complete, and extensible way to implement every
|
|
23
|
+
single feature mentioned in that blog post.
|
|
24
|
+
|
|
25
|
+
When building AIX, we started by copying the code from v0 into a separate
|
|
26
|
+
repository. However, as we worked to make it flexible for use cases outside of
|
|
27
|
+
our own app, we decided to rewrite it from scratch in native code. What you see
|
|
28
|
+
here is a Nitro Module which handles all business logic in UIKit. We plan on
|
|
29
|
+
adding support for Android as well and welcome contributions.
|
|
10
30
|
|
|
11
31
|
## Installation
|
|
12
32
|
|
|
@@ -14,24 +34,23 @@ aix is a native module with UIKit with Nitro Modules.
|
|
|
14
34
|
npm i aix react-native-nitro-modules
|
|
15
35
|
```
|
|
16
36
|
|
|
17
|
-
Next, rebuild your native app. For Expo users, run `npx expo prebuild` and
|
|
37
|
+
Next, rebuild your native app. For Expo users, run `npx expo prebuild` and
|
|
38
|
+
rebuild.
|
|
39
|
+
|
|
40
|
+
- For a full example, see the [example app](./react-native-aix/example/App.tsx).
|
|
18
41
|
|
|
19
42
|
## Usage
|
|
20
43
|
|
|
21
44
|
Wrap your `ScrollView` with `Aix`, and wrap your messages with `AixCell`.
|
|
22
45
|
|
|
23
|
-
<details>
|
|
24
|
-
<summary>Click here to view a full example</summary>
|
|
25
|
-
</details>
|
|
26
|
-
|
|
27
46
|
```tsx
|
|
28
|
-
import { Aix, AixCell } from 'aix'
|
|
29
|
-
import { Message } from 'path/to/your/message'
|
|
30
|
-
import { Composer } from 'path/to/your/composer'
|
|
47
|
+
import { Aix, AixCell } from 'aix'
|
|
48
|
+
import { Message } from 'path/to/your/message'
|
|
49
|
+
import { Composer } from 'path/to/your/composer'
|
|
31
50
|
|
|
32
51
|
export function ChatScreen({ messages }) {
|
|
33
52
|
return (
|
|
34
|
-
<Aix style={{ flex: 1 }}>
|
|
53
|
+
<Aix style={{ flex: 1 }} shouldStartAtEnd>
|
|
35
54
|
<ScrollView>
|
|
36
55
|
{messages.map((message) => (
|
|
37
56
|
<AixCell
|
|
@@ -44,19 +63,32 @@ export function ChatScreen({ messages }) {
|
|
|
44
63
|
))}
|
|
45
64
|
</ScrollView>
|
|
46
65
|
</Aix>
|
|
47
|
-
)
|
|
66
|
+
)
|
|
48
67
|
}
|
|
49
68
|
```
|
|
50
69
|
|
|
51
|
-
|
|
70
|
+
### Add a floating composer
|
|
71
|
+
|
|
72
|
+
To add a floating composer which lets content scroll under it, you can use the
|
|
73
|
+
`AixFooter`. Pair it with `Aix.scrollOnFooterSizeUpdate` to ensure content
|
|
74
|
+
scrolls under the footer when it changes size.
|
|
52
75
|
|
|
53
76
|
```tsx
|
|
54
|
-
import { Aix, AixCell, AixFooter } from 'aix'
|
|
55
|
-
import { KeyboardStickyView } from 'react-native-keyboard-controller';
|
|
77
|
+
import { Aix, AixCell, AixFooter } from 'aix'
|
|
56
78
|
|
|
57
79
|
export function ChatScreen({ messages }) {
|
|
80
|
+
const { bottom } = useSafeAreaInsets()
|
|
81
|
+
|
|
58
82
|
return (
|
|
59
|
-
<Aix
|
|
83
|
+
<Aix
|
|
84
|
+
style={{ flex: 1 }}
|
|
85
|
+
shouldStartAtEnd
|
|
86
|
+
scrollOnFooterSizeUpdate={{
|
|
87
|
+
enabled: true,
|
|
88
|
+
scrolledToEndThreshold: 100,
|
|
89
|
+
animated: false,
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
60
92
|
<ScrollView>
|
|
61
93
|
{messages.map((message) => (
|
|
62
94
|
<AixCell
|
|
@@ -69,78 +101,147 @@ export function ChatScreen({ messages }) {
|
|
|
69
101
|
))}
|
|
70
102
|
</ScrollView>
|
|
71
103
|
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
104
|
+
<AixFooter
|
|
105
|
+
style={{ position: 'absolute', inset: 0, top: 'auto' }}
|
|
106
|
+
stickToKeyboard={{
|
|
107
|
+
enabled: true,
|
|
108
|
+
offset: {
|
|
109
|
+
whenKeyboardOpen: 0,
|
|
110
|
+
whenKeyboardClosed: -bottom,
|
|
111
|
+
},
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<Composer />
|
|
115
|
+
</AixFooter>
|
|
77
116
|
</Aix>
|
|
78
|
-
)
|
|
117
|
+
)
|
|
79
118
|
}
|
|
80
119
|
```
|
|
81
120
|
|
|
82
|
-
|
|
121
|
+
### Send a message
|
|
83
122
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
123
|
+
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.
|
|
128
|
+
|
|
129
|
+
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.
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
import { Keyboard } from 'react-native'
|
|
134
|
+
import { useAixRef } from 'aix'
|
|
135
|
+
|
|
136
|
+
function Chat() {
|
|
137
|
+
const aix = useAixRef()
|
|
138
|
+
|
|
139
|
+
const onSubmit = () => {
|
|
140
|
+
aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
|
|
141
|
+
requestAnimationFrame(Keyboard.dismiss)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return <Aix ref={aix}>{/* ... */}</Aix>
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Add a scroll to end button
|
|
149
|
+
|
|
150
|
+
You can use `onScrolledNearEndChange` to show a "scroll to end" button when the
|
|
151
|
+
user scrolls away from the bottom:
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
import { Aix, useAixRef } from 'aix'
|
|
155
|
+
import { useState } from 'react'
|
|
156
|
+
import { Button } from 'react-native'
|
|
157
|
+
|
|
158
|
+
function Chat() {
|
|
159
|
+
const aix = useAixRef()
|
|
160
|
+
const [isNearEnd, setIsNearEnd] = useState(false)
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Aix
|
|
164
|
+
ref={aix}
|
|
165
|
+
scrollEndReachedThreshold={200}
|
|
166
|
+
onScrolledNearEndChange={setIsNearEnd}
|
|
167
|
+
>
|
|
168
|
+
{/* ScrollView and messages... */}
|
|
169
|
+
|
|
170
|
+
{!isNearEnd && (
|
|
171
|
+
<Button
|
|
172
|
+
onPress={() => aix.current?.scrollToEnd(true)}
|
|
173
|
+
title='Scroll to end'
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
176
|
+
</Aix>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
```
|
|
87
180
|
|
|
88
181
|
## API Reference
|
|
89
182
|
|
|
90
183
|
### `Aix`
|
|
91
184
|
|
|
92
|
-
The main container component that provides keyboard-aware behavior and manages
|
|
185
|
+
The main container component that provides keyboard-aware behavior and manages
|
|
186
|
+
scrolling for chat interfaces.
|
|
93
187
|
|
|
94
188
|
#### Props
|
|
95
189
|
|
|
96
|
-
| Prop
|
|
97
|
-
|
|
98
|
-
| `shouldStartAtEnd`
|
|
99
|
-
| `scrollOnFooterSizeUpdate`
|
|
100
|
-
| `scrollEndReachedThreshold`
|
|
101
|
-
| `onScrolledNearEndChange`
|
|
102
|
-
| `additionalContentInsets`
|
|
103
|
-
| `additionalScrollIndicatorInsets` | `object`
|
|
104
|
-
| `mainScrollViewID`
|
|
105
|
-
| `penultimateCellIndex`
|
|
190
|
+
| Prop | Type | Default | Description |
|
|
191
|
+
| --------------------------------- | ------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
192
|
+
| `shouldStartAtEnd` | `boolean` | - | Whether the scroll view should start scrolled to the end of the content. |
|
|
193
|
+
| `scrollOnFooterSizeUpdate` | `object` | `{ enabled: true, scrolledToEndThreshold: 100, animated: false }` | Control the behavior of scrolling when the footer size changes. By default, changing the height of the footer will shift content up in the scroll view. |
|
|
194
|
+
| `scrollEndReachedThreshold` | `number` | `max(blankSize, 200)` | The number of pixels from the bottom of the scroll view to the end of the content that is considered "near the end". Used by `onScrolledNearEndChange` and to determine if content should shift up when keyboard opens. |
|
|
195
|
+
| `onScrolledNearEndChange` | `(isNearEnd: boolean) => void` | - | Callback fired when the scroll position transitions between "near end" and "not near end" states. Reactive to user scrolling, content size changes, parent size changes, and keyboard height changes. Uses `scrollEndReachedThreshold` to determine the threshold. |
|
|
196
|
+
| `additionalContentInsets` | `object` | - | Additional content insets applied when keyboard is open or closed. Shape: `{ top?: { whenKeyboardOpen, whenKeyboardClosed }, bottom?: { whenKeyboardOpen, whenKeyboardClosed } }` |
|
|
197
|
+
| `additionalScrollIndicatorInsets` | `object` | - | Additional insets for the scroll indicator, added to existing safe area insets. Applied to `verticalScrollIndicatorInsets` on iOS. |
|
|
198
|
+
| `mainScrollViewID` | `string` | - | The `nativeID` of the scroll view to use. If provided, will search for a scroll view with this `accessibilityIdentifier`. |
|
|
199
|
+
| `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. |
|
|
106
200
|
|
|
107
201
|
#### Ref Methods
|
|
108
202
|
|
|
109
203
|
Access these methods via `useAixRef()`:
|
|
110
204
|
|
|
111
205
|
```tsx
|
|
112
|
-
const aix = useAixRef()
|
|
206
|
+
const aix = useAixRef()
|
|
113
207
|
|
|
114
208
|
// Scroll to the end of the content
|
|
115
|
-
aix.current?.scrollToEnd(animated)
|
|
209
|
+
aix.current?.scrollToEnd(animated)
|
|
116
210
|
|
|
117
211
|
// Scroll to a specific index when the blank size is ready
|
|
118
|
-
aix.current?.scrollToIndexWhenBlankSizeReady(
|
|
212
|
+
aix.current?.scrollToIndexWhenBlankSizeReady(
|
|
213
|
+
index,
|
|
214
|
+
animated,
|
|
215
|
+
waitForKeyboardToEnd
|
|
216
|
+
)
|
|
119
217
|
```
|
|
120
218
|
|
|
121
|
-
| Method
|
|
122
|
-
|
|
123
|
-
| `scrollToEnd`
|
|
219
|
+
| Method | Parameters | Description |
|
|
220
|
+
| --------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
221
|
+
| `scrollToEnd` | `animated?: boolean` | Scrolls to the end of the content. |
|
|
124
222
|
| `scrollToIndexWhenBlankSizeReady` | `index: number, animated?: boolean, waitForKeyboardToEnd?: boolean` | Scrolls to a specific cell index once the blank size calculation is ready. |
|
|
125
223
|
|
|
126
224
|
---
|
|
127
225
|
|
|
128
226
|
### `AixCell`
|
|
129
227
|
|
|
130
|
-
A wrapper component for each message in the list. It communicates cell position
|
|
228
|
+
A wrapper component for each message in the list. It communicates cell position
|
|
229
|
+
and dimensions to the parent `Aix` component.
|
|
131
230
|
|
|
132
231
|
#### Props
|
|
133
232
|
|
|
134
|
-
| Prop
|
|
135
|
-
|
|
136
|
-
| `index`
|
|
137
|
-
| `isLast` | `boolean` | Yes
|
|
233
|
+
| Prop | Type | Required | Description |
|
|
234
|
+
| -------- | --------- | -------- | ------------------------------------------------------------------------------------------- |
|
|
235
|
+
| `index` | `number` | Yes | The index of this cell in the message list. |
|
|
236
|
+
| `isLast` | `boolean` | Yes | Whether this cell is the last item in the list. Used for scroll positioning and animations. |
|
|
138
237
|
|
|
139
238
|
---
|
|
140
239
|
|
|
141
240
|
### `AixFooter`
|
|
142
241
|
|
|
143
|
-
A footer component for floating composers that allows content to scroll
|
|
242
|
+
A footer component for floating composers that allows content to scroll
|
|
243
|
+
underneath it. The footer's height is automatically tracked for proper scroll
|
|
244
|
+
offset calculations.
|
|
144
245
|
|
|
145
246
|
#### Props
|
|
146
247
|
|
|
@@ -148,7 +249,8 @@ Accepts all standard React Native `View` props.
|
|
|
148
249
|
|
|
149
250
|
#### Important Notes
|
|
150
251
|
|
|
151
|
-
- **Do not apply vertical padding** (`padding`, `paddingBottom`) directly to
|
|
252
|
+
- **Do not apply vertical padding** (`padding`, `paddingBottom`) directly to
|
|
253
|
+
`AixFooter`. Apply padding to a child view instead.
|
|
152
254
|
- Position the footer absolutely at the bottom of the `Aix` container:
|
|
153
255
|
|
|
154
256
|
```tsx
|
|
@@ -164,57 +266,38 @@ Accepts all standard React Native `View` props.
|
|
|
164
266
|
A hook that returns a ref to access imperative methods on the `Aix` component.
|
|
165
267
|
|
|
166
268
|
```tsx
|
|
167
|
-
import { useAixRef } from 'aix'
|
|
269
|
+
import { useAixRef } from 'aix'
|
|
168
270
|
|
|
169
271
|
function Chat({ messages }) {
|
|
170
|
-
const aix = useAixRef()
|
|
272
|
+
const aix = useAixRef()
|
|
171
273
|
const send = useSendMessage()
|
|
172
|
-
|
|
274
|
+
|
|
173
275
|
const handleSend = () => {
|
|
174
276
|
// Scroll to end after sending a message
|
|
175
|
-
send(message)
|
|
176
|
-
aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
|
|
177
|
-
requestAnimationFrame(Keyboard.dismiss)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return <Aix ref={aix}>{/* ... */}</Aix
|
|
277
|
+
send(message)
|
|
278
|
+
aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
|
|
279
|
+
requestAnimationFrame(Keyboard.dismiss)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return <Aix ref={aix}>{/* ... */}</Aix>
|
|
181
283
|
}
|
|
182
284
|
```
|
|
183
285
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
### Scroll to End Button
|
|
187
|
-
|
|
188
|
-
You can use `onScrolledNearEndChange` to show a "scroll to end" button when the user scrolls away from the bottom:
|
|
189
|
-
|
|
190
|
-
```tsx
|
|
191
|
-
import { Aix, useAixRef } from 'aix';
|
|
192
|
-
import { useState } from 'react';
|
|
193
|
-
|
|
194
|
-
function Chat() {
|
|
195
|
-
const aix = useAixRef();
|
|
196
|
-
const [isNearEnd, setIsNearEnd] = useState(false);
|
|
286
|
+
### Rules
|
|
197
287
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
scrollEndReachedThreshold={200}
|
|
202
|
-
onScrolledNearEndChange={setIsNearEnd}
|
|
203
|
-
>
|
|
204
|
-
{/* ScrollView and messages... */}
|
|
288
|
+
- Do **NOT** apply padding to `contentContainerStyle`. Instead, use padding on
|
|
289
|
+
children directly.
|
|
290
|
+
- Do **NOT** apply padding to `AixFooter`. Instead, use padding on its children
|
|
205
291
|
|
|
206
|
-
|
|
207
|
-
<Button onPress={() => aix.current?.scrollToEnd(true)} />
|
|
208
|
-
)}
|
|
209
|
-
</Aix>
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
```
|
|
292
|
+
---
|
|
213
293
|
|
|
214
294
|
## TODOs
|
|
215
295
|
|
|
296
|
+
- [ ] Android support
|
|
297
|
+
- [ ] LegendList support
|
|
298
|
+
- [ ] FlashList support
|
|
216
299
|
|
|
217
300
|
## Requirements
|
|
218
301
|
|
|
219
302
|
- React Native v0.78.0 or higher
|
|
220
|
-
- Node 18.0.0 or higher
|
|
303
|
+
- Node 18.0.0 or higher
|
package/ios/HybridAix.swift
CHANGED
|
@@ -432,14 +432,29 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
432
432
|
private func calculateBlankSize(keyboardHeight: CGFloat, additionalContentInsetBottom: CGFloat) -> CGFloat {
|
|
433
433
|
guard let scrollView, let blankView else { return 0 }
|
|
434
434
|
|
|
435
|
-
let
|
|
436
|
-
let
|
|
435
|
+
let startIndex: Int
|
|
436
|
+
let endIndex = Int(blankView.index) - 1
|
|
437
|
+
if let penultimateCellIndex {
|
|
438
|
+
startIndex = Int(penultimateCellIndex)
|
|
439
|
+
} else {
|
|
440
|
+
startIndex = endIndex
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
var cellsBeforeBlankViewHeight: CGFloat = 0
|
|
444
|
+
if startIndex <= endIndex {
|
|
445
|
+
for i in startIndex...endIndex {
|
|
446
|
+
if let cell = getCell(index: i) {
|
|
447
|
+
cellsBeforeBlankViewHeight += cell.view.frame.height
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
437
452
|
let blankViewHeight = blankView.view.frame.height
|
|
438
453
|
|
|
439
454
|
// Calculate visible area above all bottom chrome (keyboard, composer, additional insets)
|
|
440
455
|
// The blank size fills the remaining space so the last message can scroll to the top
|
|
441
456
|
let visibleAreaHeight = scrollView.bounds.height - keyboardHeight - composerHeight - additionalContentInsetBottom
|
|
442
|
-
let inset = visibleAreaHeight - blankViewHeight -
|
|
457
|
+
let inset = visibleAreaHeight - blankViewHeight - cellsBeforeBlankViewHeight
|
|
443
458
|
return max(0, inset)
|
|
444
459
|
}
|
|
445
460
|
|
|
@@ -154,6 +154,7 @@ class HybridAixCellView: HybridAixCellViewSpec {
|
|
|
154
154
|
private func updateRegistration() {
|
|
155
155
|
guard let ctx = getAixContext() else { return }
|
|
156
156
|
ctx.registerCell(self)
|
|
157
|
+
updateBlankViewStatus()
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
/// Update blank view status (called when isLast changes)
|