aix 0.2.0 → 0.3.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.
- package/README.md +161 -72
- package/ios/HybridAix.swift +74 -6
- package/ios/HybridAixComposer.swift +63 -24
- package/lib/commonjs/aix.js +6 -2
- package/lib/commonjs/aix.js.map +1 -1
- package/lib/module/aix.js +6 -2
- package/lib/module/aix.js.map +1 -1
- package/lib/typescript/src/aix.d.ts +235 -11
- package/lib/typescript/src/aix.d.ts.map +1 -1
- package/lib/typescript/src/views/aix.nitro.d.ts +14 -0
- package/lib/typescript/src/views/aix.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/AixOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JAixStickToKeyboard.hpp +63 -0
- package/nitrogen/generated/android/c++/JAixStickToKeyboardOffset.hpp +61 -0
- package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridAixComposerSpec.cpp +18 -3
- package/nitrogen/generated/android/c++/JHybridAixComposerSpec.hpp +2 -1
- package/nitrogen/generated/android/c++/JHybridAixSpec.cpp +18 -0
- package/nitrogen/generated/android/c++/JHybridAixSpec.hpp +2 -0
- package/nitrogen/generated/android/c++/views/JHybridAixComposerStateUpdater.cpp +4 -1
- package/nitrogen/generated/android/c++/views/JHybridAixStateUpdater.cpp +4 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixStickToKeyboard.kt +41 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixStickToKeyboardOffset.kt +41 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/Func_void_bool.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixComposerSpec.kt +5 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixSpec.kt +14 -0
- package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.hpp +73 -0
- package/nitrogen/generated/ios/Aix-Swift-Cxx-Umbrella.hpp +6 -0
- package/nitrogen/generated/ios/c++/HybridAixComposerSpecSwift.hpp +14 -3
- package/nitrogen/generated/ios/c++/HybridAixSpecSwift.hpp +7 -0
- package/nitrogen/generated/ios/c++/views/HybridAixComponent.mm +5 -0
- package/nitrogen/generated/ios/c++/views/HybridAixComposerComponent.mm +5 -1
- package/nitrogen/generated/ios/swift/AixStickToKeyboard.swift +59 -0
- package/nitrogen/generated/ios/swift/AixStickToKeyboardOffset.swift +47 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +47 -0
- package/nitrogen/generated/ios/swift/HybridAixComposerSpec.swift +1 -1
- package/nitrogen/generated/ios/swift/HybridAixComposerSpec_cxx.swift +16 -1
- package/nitrogen/generated/ios/swift/HybridAixSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridAixSpec_cxx.swift +32 -0
- package/nitrogen/generated/shared/c++/AixStickToKeyboard.hpp +81 -0
- package/nitrogen/generated/shared/c++/AixStickToKeyboardOffset.hpp +79 -0
- package/nitrogen/generated/shared/c++/HybridAixComposerSpec.cpp +2 -1
- package/nitrogen/generated/shared/c++/HybridAixComposerSpec.hpp +6 -3
- package/nitrogen/generated/shared/c++/HybridAixSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridAixSpec.hpp +2 -0
- package/nitrogen/generated/shared/c++/views/HybridAixComponent.cpp +12 -0
- package/nitrogen/generated/shared/c++/views/HybridAixComponent.hpp +1 -0
- package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.cpp +12 -0
- package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.hpp +3 -1
- package/nitrogen/generated/shared/json/AixComposerConfig.json +1 -0
- package/nitrogen/generated/shared/json/AixConfig.json +1 -0
- package/package.json +6 -6
- package/src/aix.tsx +8 -1
- package/src/views/aix.nitro.ts +18 -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
|
+
## Features
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
- Start a chat scrolled to end on the first frame
|
|
11
|
+
- Animate scrolling to new messages when they send
|
|
12
|
+
- Float messages to the top of the screen with automated "blank size" handling
|
|
13
|
+
- Animate message content as it streams
|
|
14
|
+
- Keyboard handling out-of-the-box with no external dependencies
|
|
15
|
+
- Support for absolute-positioned composers
|
|
16
|
+
- Detect "is scrolled near end" for ScrollToEnd buttons
|
|
17
|
+
|
|
18
|
+
To learn about the motivation behind AIX, you can read our blog post on
|
|
19
|
+
[How we built the v0 iOS app](https://vercel.com/blog/how-we-built-the-v0-ios-app).
|
|
20
|
+
AIX is an opinionated, feature-complete, and extensible way to implement every
|
|
21
|
+
single feature mentioned in that blog post.
|
|
22
|
+
|
|
23
|
+
When building AIX, we started by copying the code from v0 into a separate
|
|
24
|
+
repository. However, as we worked to make it flexible for use cases outside of
|
|
25
|
+
our own app, we decided to rewrite it from scratch in native code. What you see
|
|
26
|
+
here is a Nitro Module which handles all business logic in UIKit. We plan on
|
|
27
|
+
adding support for Android as well and welcome contributions.
|
|
28
|
+
|
|
29
|
+
> aix is currently in alpha preview. The API may change.
|
|
10
30
|
|
|
11
31
|
## Installation
|
|
12
32
|
|
|
@@ -14,132 +34,195 @@ 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
|
-
<AixCell
|
|
38
|
-
key={message.id}
|
|
39
|
-
index={index}
|
|
40
|
-
isLast={index === messages.length - 1}
|
|
41
|
-
>
|
|
56
|
+
<AixCell key={message.id} index={index} isLast={index === messages.length - 1}>
|
|
42
57
|
<Message message={message} />
|
|
43
58
|
</AixCell>
|
|
44
59
|
))}
|
|
45
60
|
</ScrollView>
|
|
46
61
|
</Aix>
|
|
47
|
-
)
|
|
62
|
+
)
|
|
48
63
|
}
|
|
49
64
|
```
|
|
50
65
|
|
|
51
|
-
|
|
66
|
+
### Add a floating composer
|
|
67
|
+
|
|
68
|
+
To add a floating composer which lets content scroll under it, you can use the
|
|
69
|
+
`AixFooter`. Pair it with `Aix.scrollOnFooterSizeUpdate` to ensure content
|
|
70
|
+
scrolls under the footer when it changes size.
|
|
52
71
|
|
|
53
72
|
```tsx
|
|
54
|
-
import { Aix, AixCell, AixFooter } from 'aix'
|
|
55
|
-
import { KeyboardStickyView } from 'react-native-keyboard-controller';
|
|
73
|
+
import { Aix, AixCell, AixFooter } from 'aix'
|
|
56
74
|
|
|
57
75
|
export function ChatScreen({ messages }) {
|
|
76
|
+
const { bottom } = useSafeAreaInsets()
|
|
77
|
+
|
|
58
78
|
return (
|
|
59
|
-
<Aix
|
|
79
|
+
<Aix
|
|
80
|
+
style={{ flex: 1 }}
|
|
81
|
+
shouldStartAtEnd
|
|
82
|
+
scrollOnFooterSizeUpdate={{
|
|
83
|
+
enabled: true,
|
|
84
|
+
scrolledToEndThreshold: 100,
|
|
85
|
+
animated: false,
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
60
88
|
<ScrollView>
|
|
61
89
|
{messages.map((message) => (
|
|
62
|
-
<AixCell
|
|
63
|
-
key={message.id}
|
|
64
|
-
index={index}
|
|
65
|
-
isLast={index === messages.length - 1}
|
|
66
|
-
>
|
|
90
|
+
<AixCell key={message.id} index={index} isLast={index === messages.length - 1}>
|
|
67
91
|
<Message message={message} />
|
|
68
92
|
</AixCell>
|
|
69
93
|
))}
|
|
70
94
|
</ScrollView>
|
|
71
95
|
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
96
|
+
<AixFooter
|
|
97
|
+
style={{ position: 'absolute', inset: 0, top: 'auto' }}
|
|
98
|
+
stickToKeyboard={{
|
|
99
|
+
enabled: true,
|
|
100
|
+
offset: {
|
|
101
|
+
whenKeyboardOpen: 0,
|
|
102
|
+
whenKeyboardClosed: -bottom,
|
|
103
|
+
},
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<Composer />
|
|
107
|
+
</AixFooter>
|
|
77
108
|
</Aix>
|
|
78
|
-
)
|
|
109
|
+
)
|
|
79
110
|
}
|
|
80
111
|
```
|
|
81
112
|
|
|
82
|
-
|
|
113
|
+
### Send a message
|
|
83
114
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
115
|
+
When sending a message, you will likely want to scroll to it after it gets added
|
|
116
|
+
to the list.
|
|
117
|
+
|
|
118
|
+
Simply call `aix.current?.scrollToIndexWhenBlankSizeReady(index)` in your submit
|
|
119
|
+
handler.
|
|
120
|
+
|
|
121
|
+
The `index` you pass should correspond to the newest message in the list. For AI
|
|
122
|
+
chats, this is typically the next assistant message index.
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
import { Keyboard } from 'react-native'
|
|
126
|
+
import { useAixRef } from 'aix'
|
|
127
|
+
|
|
128
|
+
function Chat() {
|
|
129
|
+
const aix = useAixRef()
|
|
130
|
+
|
|
131
|
+
const onSubmit = () => {
|
|
132
|
+
aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
|
|
133
|
+
requestAnimationFrame(Keyboard.dismiss)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return <Aix ref={aix}>{/* ... */}</Aix>
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Add a scroll to end button
|
|
141
|
+
|
|
142
|
+
You can use `onScrolledNearEndChange` to show a "scroll to end" button when the
|
|
143
|
+
user scrolls away from the bottom:
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
import { Aix, useAixRef } from 'aix'
|
|
147
|
+
import { useState } from 'react'
|
|
148
|
+
import { Button } from 'react-native'
|
|
149
|
+
|
|
150
|
+
function Chat() {
|
|
151
|
+
const aix = useAixRef()
|
|
152
|
+
const [isNearEnd, setIsNearEnd] = useState(false)
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<Aix ref={aix} scrollEndReachedThreshold={200} onScrolledNearEndChange={setIsNearEnd}>
|
|
156
|
+
{/* ScrollView and messages... */}
|
|
157
|
+
|
|
158
|
+
{!isNearEnd && (
|
|
159
|
+
<Button onPress={() => aix.current?.scrollToEnd(true)} title='Scroll to end' />
|
|
160
|
+
)}
|
|
161
|
+
</Aix>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
```
|
|
87
165
|
|
|
88
166
|
## API Reference
|
|
89
167
|
|
|
90
168
|
### `Aix`
|
|
91
169
|
|
|
92
|
-
The main container component that provides keyboard-aware behavior and manages
|
|
170
|
+
The main container component that provides keyboard-aware behavior and manages
|
|
171
|
+
scrolling for chat interfaces.
|
|
93
172
|
|
|
94
173
|
#### Props
|
|
95
174
|
|
|
96
|
-
| Prop
|
|
97
|
-
|
|
98
|
-
| `shouldStartAtEnd`
|
|
99
|
-
| `scrollOnFooterSizeUpdate`
|
|
100
|
-
| `scrollEndReachedThreshold`
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
175
|
+
| Prop | Type | Default | Description |
|
|
176
|
+
| --------------------------------- | ------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
177
|
+
| `shouldStartAtEnd` | `boolean` | - | Whether the scroll view should start scrolled to the end of the content. |
|
|
178
|
+
| `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. |
|
|
179
|
+
| `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. |
|
|
180
|
+
| `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. |
|
|
181
|
+
| `additionalContentInsets` | `object` | - | Additional content insets applied when keyboard is open or closed. Shape: `{ top?: { whenKeyboardOpen, whenKeyboardClosed }, bottom?: { whenKeyboardOpen, whenKeyboardClosed } }` |
|
|
182
|
+
| `additionalScrollIndicatorInsets` | `object` | - | Additional insets for the scroll indicator, added to existing safe area insets. Applied to `verticalScrollIndicatorInsets` on iOS. |
|
|
183
|
+
| `mainScrollViewID` | `string` | - | The `nativeID` of the scroll view to use. If provided, will search for a scroll view with this `accessibilityIdentifier`. |
|
|
184
|
+
| `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. |
|
|
105
185
|
|
|
106
186
|
#### Ref Methods
|
|
107
187
|
|
|
108
188
|
Access these methods via `useAixRef()`:
|
|
109
189
|
|
|
110
190
|
```tsx
|
|
111
|
-
const aix = useAixRef()
|
|
191
|
+
const aix = useAixRef()
|
|
112
192
|
|
|
113
193
|
// Scroll to the end of the content
|
|
114
|
-
aix.current?.scrollToEnd(animated)
|
|
194
|
+
aix.current?.scrollToEnd(animated)
|
|
115
195
|
|
|
116
196
|
// Scroll to a specific index when the blank size is ready
|
|
117
|
-
aix.current?.scrollToIndexWhenBlankSizeReady(index, animated, waitForKeyboardToEnd)
|
|
197
|
+
aix.current?.scrollToIndexWhenBlankSizeReady(index, animated, waitForKeyboardToEnd)
|
|
118
198
|
```
|
|
119
199
|
|
|
120
|
-
| Method
|
|
121
|
-
|
|
122
|
-
| `scrollToEnd`
|
|
200
|
+
| Method | Parameters | Description |
|
|
201
|
+
| --------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
202
|
+
| `scrollToEnd` | `animated?: boolean` | Scrolls to the end of the content. |
|
|
123
203
|
| `scrollToIndexWhenBlankSizeReady` | `index: number, animated?: boolean, waitForKeyboardToEnd?: boolean` | Scrolls to a specific cell index once the blank size calculation is ready. |
|
|
124
204
|
|
|
125
205
|
---
|
|
126
206
|
|
|
127
207
|
### `AixCell`
|
|
128
208
|
|
|
129
|
-
A wrapper component for each message in the list. It communicates cell position
|
|
209
|
+
A wrapper component for each message in the list. It communicates cell position
|
|
210
|
+
and dimensions to the parent `Aix` component.
|
|
130
211
|
|
|
131
212
|
#### Props
|
|
132
213
|
|
|
133
|
-
| Prop
|
|
134
|
-
|
|
135
|
-
| `index`
|
|
136
|
-
| `isLast` | `boolean` | Yes
|
|
214
|
+
| Prop | Type | Required | Description |
|
|
215
|
+
| -------- | --------- | -------- | ------------------------------------------------------------------------------------------- |
|
|
216
|
+
| `index` | `number` | Yes | The index of this cell in the message list. |
|
|
217
|
+
| `isLast` | `boolean` | Yes | Whether this cell is the last item in the list. Used for scroll positioning and animations. |
|
|
137
218
|
|
|
138
219
|
---
|
|
139
220
|
|
|
140
221
|
### `AixFooter`
|
|
141
222
|
|
|
142
|
-
A footer component for floating composers that allows content to scroll
|
|
223
|
+
A footer component for floating composers that allows content to scroll
|
|
224
|
+
underneath it. The footer's height is automatically tracked for proper scroll
|
|
225
|
+
offset calculations.
|
|
143
226
|
|
|
144
227
|
#### Props
|
|
145
228
|
|
|
@@ -147,7 +230,8 @@ Accepts all standard React Native `View` props.
|
|
|
147
230
|
|
|
148
231
|
#### Important Notes
|
|
149
232
|
|
|
150
|
-
- **Do not apply vertical padding** (`padding`, `paddingBottom`) directly to
|
|
233
|
+
- **Do not apply vertical padding** (`padding`, `paddingBottom`) directly to
|
|
234
|
+
`AixFooter`. Apply padding to a child view instead.
|
|
151
235
|
- Position the footer absolutely at the bottom of the `Aix` container:
|
|
152
236
|
|
|
153
237
|
```tsx
|
|
@@ -163,27 +247,32 @@ Accepts all standard React Native `View` props.
|
|
|
163
247
|
A hook that returns a ref to access imperative methods on the `Aix` component.
|
|
164
248
|
|
|
165
249
|
```tsx
|
|
166
|
-
import { useAixRef } from 'aix'
|
|
250
|
+
import { useAixRef } from 'aix'
|
|
167
251
|
|
|
168
252
|
function Chat({ messages }) {
|
|
169
|
-
const aix = useAixRef()
|
|
253
|
+
const aix = useAixRef()
|
|
170
254
|
const send = useSendMessage()
|
|
171
|
-
|
|
255
|
+
|
|
172
256
|
const handleSend = () => {
|
|
173
257
|
// Scroll to end after sending a message
|
|
174
|
-
send(message)
|
|
175
|
-
aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
|
|
176
|
-
requestAnimationFrame(Keyboard.dismiss)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return <Aix ref={aix}>{/* ... */}</Aix
|
|
258
|
+
send(message)
|
|
259
|
+
aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
|
|
260
|
+
requestAnimationFrame(Keyboard.dismiss)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return <Aix ref={aix}>{/* ... */}</Aix>
|
|
180
264
|
}
|
|
181
265
|
```
|
|
182
266
|
|
|
267
|
+
---
|
|
268
|
+
|
|
183
269
|
## TODOs
|
|
184
270
|
|
|
271
|
+
- [ ] Android support
|
|
272
|
+
- [ ] LegendList support
|
|
273
|
+
- [ ] FlashList support
|
|
185
274
|
|
|
186
275
|
## Requirements
|
|
187
276
|
|
|
188
277
|
- React Native v0.78.0 or higher
|
|
189
|
-
- Node 18.0.0 or higher
|
|
278
|
+
- Node 18.0.0 or higher
|
package/ios/HybridAix.swift
CHANGED
|
@@ -15,19 +15,19 @@ private var aixContextKey: UInt8 = 0
|
|
|
15
15
|
protocol AixContext: AnyObject {
|
|
16
16
|
/// The blank view (last cell) - used for calculating blank size
|
|
17
17
|
var blankView: HybridAixCellView? { get set }
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
/// The composer view
|
|
20
20
|
var composerView: HybridAixComposer? { get set }
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
/// Called when the blank view's size changes
|
|
23
23
|
func reportBlankViewSizeChange(size: CGSize, index: Int)
|
|
24
|
-
|
|
24
|
+
|
|
25
25
|
/// Register a cell with the context
|
|
26
26
|
func registerCell(_ cell: HybridAixCellView)
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
/// Unregister a cell from the context
|
|
29
29
|
func unregisterCell(_ cell: HybridAixCellView)
|
|
30
|
-
|
|
30
|
+
|
|
31
31
|
/// Register the composer view
|
|
32
32
|
func registerComposerView(_ composerView: HybridAixComposer)
|
|
33
33
|
|
|
@@ -36,6 +36,14 @@ protocol AixContext: AnyObject {
|
|
|
36
36
|
|
|
37
37
|
/// Called when the composer's height changes
|
|
38
38
|
func reportComposerHeightChange(height: CGFloat)
|
|
39
|
+
|
|
40
|
+
// MARK: - Keyboard State (for composer sticky behavior)
|
|
41
|
+
|
|
42
|
+
/// Current keyboard height
|
|
43
|
+
var keyboardHeight: CGFloat { get }
|
|
44
|
+
|
|
45
|
+
/// Keyboard height when fully open (for calculating progress)
|
|
46
|
+
var keyboardHeightWhenOpen: CGFloat { get }
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
extension UIView {
|
|
@@ -93,6 +101,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
93
101
|
|
|
94
102
|
var shouldApplyContentInsets: Bool? = nil
|
|
95
103
|
var onWillApplyContentInsets: ((_ insets: AixContentInsets) -> Void)? = nil
|
|
104
|
+
var onScrolledNearEndChange: ((_ isNearEnd: Bool) -> Void)? = nil
|
|
96
105
|
|
|
97
106
|
var additionalContentInsets: AixAdditionalContentInsetsProp?
|
|
98
107
|
|
|
@@ -199,6 +208,14 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
199
208
|
/// Flag to track if we're currently in an interactive keyboard dismiss
|
|
200
209
|
private var isInInteractiveDismiss = false
|
|
201
210
|
|
|
211
|
+
/// Previous "scrolled near end" state for change detection
|
|
212
|
+
private var prevIsScrolledNearEnd: Bool? = nil
|
|
213
|
+
|
|
214
|
+
/// KVO observation tokens for scroll view
|
|
215
|
+
private var contentOffsetObservation: NSKeyValueObservation?
|
|
216
|
+
private var contentSizeObservation: NSKeyValueObservation?
|
|
217
|
+
private var boundsObservation: NSKeyValueObservation?
|
|
218
|
+
|
|
202
219
|
// MARK: - Context References (weak to avoid retain cycles)
|
|
203
220
|
|
|
204
221
|
weak var blankView: HybridAixCellView? = nil {
|
|
@@ -244,12 +261,41 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
244
261
|
scrollView.automaticallyAdjustsScrollIndicatorInsets = false
|
|
245
262
|
|
|
246
263
|
setupPanGestureObserver()
|
|
264
|
+
setupScrollViewObservers(scrollView)
|
|
247
265
|
applyScrollIndicatorInsets()
|
|
248
266
|
}
|
|
249
267
|
|
|
250
268
|
return sv
|
|
251
269
|
}
|
|
252
270
|
|
|
271
|
+
/// Set up KVO observers on scroll view for contentOffset, contentSize, and bounds changes
|
|
272
|
+
private func setupScrollViewObservers(_ scrollView: UIScrollView) {
|
|
273
|
+
// Observe contentOffset (user scrolling)
|
|
274
|
+
contentOffsetObservation = scrollView.observe(\.contentOffset, options: [.new]) { [weak self] _, _ in
|
|
275
|
+
self?.updateScrolledNearEndState()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Observe contentSize (content size changes)
|
|
279
|
+
contentSizeObservation = scrollView.observe(\.contentSize, options: [.new]) { [weak self] _, _ in
|
|
280
|
+
self?.updateScrolledNearEndState()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Observe bounds (parent size changes)
|
|
284
|
+
boundsObservation = scrollView.observe(\.bounds, options: [.new]) { [weak self] _, _ in
|
|
285
|
+
self?.updateScrolledNearEndState()
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/// Clean up KVO observers
|
|
290
|
+
private func removeScrollViewObservers() {
|
|
291
|
+
contentOffsetObservation?.invalidate()
|
|
292
|
+
contentOffsetObservation = nil
|
|
293
|
+
contentSizeObservation?.invalidate()
|
|
294
|
+
contentSizeObservation = nil
|
|
295
|
+
boundsObservation?.invalidate()
|
|
296
|
+
boundsObservation = nil
|
|
297
|
+
}
|
|
298
|
+
|
|
253
299
|
/// Set up observer on scroll view's pan gesture to detect interactive keyboard dismiss
|
|
254
300
|
private func setupPanGestureObserver() {
|
|
255
301
|
guard let scrollView = cachedScrollView else { return }
|
|
@@ -416,6 +462,19 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
416
462
|
if scrollView.contentInset.bottom != targetBottom {
|
|
417
463
|
scrollView.contentInset.bottom = targetBottom
|
|
418
464
|
}
|
|
465
|
+
|
|
466
|
+
// Update scrolled near end state after insets change
|
|
467
|
+
updateScrolledNearEndState()
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/// Centralized function to check and fire onScrolledNearEndChange callback
|
|
471
|
+
/// Called from KVO observers and after content inset changes
|
|
472
|
+
private func updateScrolledNearEndState() {
|
|
473
|
+
guard scrollView != nil else { return }
|
|
474
|
+
let isNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
|
|
475
|
+
guard isNearEnd != prevIsScrolledNearEnd else { return }
|
|
476
|
+
prevIsScrolledNearEnd = isNearEnd
|
|
477
|
+
onScrolledNearEndChange?(isNearEnd)
|
|
419
478
|
}
|
|
420
479
|
|
|
421
480
|
/// Apply scroll indicator insets to the scroll view
|
|
@@ -568,9 +627,12 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
568
627
|
startEvent = event
|
|
569
628
|
}
|
|
570
629
|
}
|
|
571
|
-
|
|
630
|
+
|
|
572
631
|
// Update keyboard state
|
|
573
632
|
handleKeyboardMove(height: height, progress: progress)
|
|
633
|
+
|
|
634
|
+
// Update composer transform
|
|
635
|
+
composerView?.applyKeyboardTransform(height: height, heightWhenOpen: keyboardHeightWhenOpen, animated: false)
|
|
574
636
|
}
|
|
575
637
|
|
|
576
638
|
// MARK: - Initialization
|
|
@@ -587,6 +649,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
587
649
|
|
|
588
650
|
deinit {
|
|
589
651
|
removePanGestureObserver()
|
|
652
|
+
removeScrollViewObservers()
|
|
590
653
|
}
|
|
591
654
|
|
|
592
655
|
// MARK: - Lifecycle
|
|
@@ -657,6 +720,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
657
720
|
applyContentInset()
|
|
658
721
|
applyScrollIndicatorInsets()
|
|
659
722
|
scrollToEndInternal(animated: false)
|
|
723
|
+
updateScrolledNearEndState()
|
|
660
724
|
}
|
|
661
725
|
didScrollToEndInitially = true
|
|
662
726
|
} else {
|
|
@@ -812,6 +876,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
812
876
|
keyboardProgress = 1.0
|
|
813
877
|
applyContentInset()
|
|
814
878
|
applyScrollIndicatorInsets()
|
|
879
|
+
composerView?.applyKeyboardTransform(height: targetHeight, heightWhenOpen: keyboardHeightWhenOpen, animated: false)
|
|
815
880
|
return
|
|
816
881
|
}
|
|
817
882
|
|
|
@@ -826,6 +891,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
826
891
|
}
|
|
827
892
|
self.applyContentInset()
|
|
828
893
|
self.applyScrollIndicatorInsets()
|
|
894
|
+
self.composerView?.applyKeyboardTransform(height: targetHeight, heightWhenOpen: self.keyboardHeightWhenOpen, animated: false)
|
|
829
895
|
|
|
830
896
|
if let (startY, endY) = self.startEvent?.interpolateContentOffsetY {
|
|
831
897
|
self.scrollView?.setContentOffset(CGPoint(x: 0, y: endY), animated: false)
|
|
@@ -853,6 +919,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
853
919
|
self.keyboardProgress = 0
|
|
854
920
|
self.applyContentInset()
|
|
855
921
|
self.applyScrollIndicatorInsets()
|
|
922
|
+
self.composerView?.applyKeyboardTransform(height: 0, heightWhenOpen: self.keyboardHeightWhenOpen, animated: false)
|
|
856
923
|
}, completion: { [weak self] _ in
|
|
857
924
|
self?.handleKeyboardDidMove(height: 0, progress: 0)
|
|
858
925
|
})
|
|
@@ -865,6 +932,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
|
865
932
|
func keyboardDidHide(notification: NSNotification) {
|
|
866
933
|
print("[Aix] keyboardDidHide")
|
|
867
934
|
keyboardHeightWhenOpen = 0
|
|
935
|
+
composerView?.applyKeyboardTransform(height: 0, heightWhenOpen: 0, animated: false)
|
|
868
936
|
}
|
|
869
937
|
|
|
870
938
|
func keyboardWillChangeFrame(notification: NSNotification) {
|