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.
Files changed (55) hide show
  1. package/README.md +161 -72
  2. package/ios/HybridAix.swift +74 -6
  3. package/ios/HybridAixComposer.swift +63 -24
  4. package/lib/commonjs/aix.js +6 -2
  5. package/lib/commonjs/aix.js.map +1 -1
  6. package/lib/module/aix.js +6 -2
  7. package/lib/module/aix.js.map +1 -1
  8. package/lib/typescript/src/aix.d.ts +235 -11
  9. package/lib/typescript/src/aix.d.ts.map +1 -1
  10. package/lib/typescript/src/views/aix.nitro.d.ts +14 -0
  11. package/lib/typescript/src/views/aix.nitro.d.ts.map +1 -1
  12. package/nitrogen/generated/android/AixOnLoad.cpp +2 -0
  13. package/nitrogen/generated/android/c++/JAixStickToKeyboard.hpp +63 -0
  14. package/nitrogen/generated/android/c++/JAixStickToKeyboardOffset.hpp +61 -0
  15. package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  16. package/nitrogen/generated/android/c++/JHybridAixComposerSpec.cpp +18 -3
  17. package/nitrogen/generated/android/c++/JHybridAixComposerSpec.hpp +2 -1
  18. package/nitrogen/generated/android/c++/JHybridAixSpec.cpp +18 -0
  19. package/nitrogen/generated/android/c++/JHybridAixSpec.hpp +2 -0
  20. package/nitrogen/generated/android/c++/views/JHybridAixComposerStateUpdater.cpp +4 -1
  21. package/nitrogen/generated/android/c++/views/JHybridAixStateUpdater.cpp +4 -0
  22. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixStickToKeyboard.kt +41 -0
  23. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixStickToKeyboardOffset.kt +41 -0
  24. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/Func_void_bool.kt +80 -0
  25. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixComposerSpec.kt +5 -1
  26. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixSpec.kt +14 -0
  27. package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.cpp +8 -0
  28. package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.hpp +73 -0
  29. package/nitrogen/generated/ios/Aix-Swift-Cxx-Umbrella.hpp +6 -0
  30. package/nitrogen/generated/ios/c++/HybridAixComposerSpecSwift.hpp +14 -3
  31. package/nitrogen/generated/ios/c++/HybridAixSpecSwift.hpp +7 -0
  32. package/nitrogen/generated/ios/c++/views/HybridAixComponent.mm +5 -0
  33. package/nitrogen/generated/ios/c++/views/HybridAixComposerComponent.mm +5 -1
  34. package/nitrogen/generated/ios/swift/AixStickToKeyboard.swift +59 -0
  35. package/nitrogen/generated/ios/swift/AixStickToKeyboardOffset.swift +47 -0
  36. package/nitrogen/generated/ios/swift/Func_void_bool.swift +47 -0
  37. package/nitrogen/generated/ios/swift/HybridAixComposerSpec.swift +1 -1
  38. package/nitrogen/generated/ios/swift/HybridAixComposerSpec_cxx.swift +16 -1
  39. package/nitrogen/generated/ios/swift/HybridAixSpec.swift +1 -0
  40. package/nitrogen/generated/ios/swift/HybridAixSpec_cxx.swift +32 -0
  41. package/nitrogen/generated/shared/c++/AixStickToKeyboard.hpp +81 -0
  42. package/nitrogen/generated/shared/c++/AixStickToKeyboardOffset.hpp +79 -0
  43. package/nitrogen/generated/shared/c++/HybridAixComposerSpec.cpp +2 -1
  44. package/nitrogen/generated/shared/c++/HybridAixComposerSpec.hpp +6 -3
  45. package/nitrogen/generated/shared/c++/HybridAixSpec.cpp +2 -0
  46. package/nitrogen/generated/shared/c++/HybridAixSpec.hpp +2 -0
  47. package/nitrogen/generated/shared/c++/views/HybridAixComponent.cpp +12 -0
  48. package/nitrogen/generated/shared/c++/views/HybridAixComponent.hpp +1 -0
  49. package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.cpp +12 -0
  50. package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.hpp +3 -1
  51. package/nitrogen/generated/shared/json/AixComposerConfig.json +1 -0
  52. package/nitrogen/generated/shared/json/AixConfig.json +1 -0
  53. package/package.json +6 -6
  54. package/src/aix.tsx +8 -1
  55. package/src/views/aix.nitro.ts +18 -1
package/README.md CHANGED
@@ -1,12 +1,32 @@
1
- # aix
1
+ <img src="https://github.com/vercel/aix/blob/main/Aix.png?raw=true"
2
+ alt="aix" width="1600" height="900" />
2
3
 
3
- Primitives for building beautiful AI chat apps with React Native.
4
+ # AIX
4
5
 
5
- > aix is currently in alpha preview. The API will change, and it is not yet feature complete.
6
+ UI Primitives for building AI apps in React Native.
6
7
 
7
- We're rewriting the engine that powers the chat experience in the v0 mobile app with a focus on native feel.
8
+ ## Features
8
9
 
9
- aix is a native module with UIKit with Nitro Modules.
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 rebuild.
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
- To add a floating composer which lets content scroll under it, you can use the `AixFooter` and `KeyboardStickyView` from `react-native-keyboard-controller`:
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 style={{ flex: 1 }}>
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
- <KeyboardStickyView offset={{ opened: 0, closed: -bottomInsetPadding }}>
73
- <AixFooter style={{ position: 'absolute', inset: 0, top: 'auto'}}>
74
- <Composer />
75
- </AixFooter>
76
- </KeyboardStickyView>
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
- ## TODOs
113
+ ### Send a message
83
114
 
84
- - [ ] Android support
85
- - [ ] LegendList support
86
- - [ ] FlashList support
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 scrolling for chat interfaces.
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 | Type | Default | Description |
97
- |------|------|---------|-------------|
98
- | `shouldStartAtEnd` | `boolean` | - | Whether the scroll view should start scrolled to the end of the content. |
99
- | `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. |
100
- | `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 to determine if content should shift up when keyboard opens. |
101
- | `additionalContentInsets` | `object` | - | Additional content insets applied when keyboard is open or closed. Shape: `{ top?: { whenKeyboardOpen, whenKeyboardClosed }, bottom?: { whenKeyboardOpen, whenKeyboardClosed } }` |
102
- | `additionalScrollIndicatorInsets` | `object` | - | Additional insets for the scroll indicator, added to existing safe area insets. Applied to `verticalScrollIndicatorInsets` on iOS. |
103
- | `mainScrollViewID` | `string` | - | The `nativeID` of the scroll view to use. If provided, will search for a scroll view with this `accessibilityIdentifier`. |
104
- | `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. |
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 | Parameters | Description |
121
- |--------|------------|-------------|
122
- | `scrollToEnd` | `animated?: boolean` | Scrolls to the end of the content. |
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 and dimensions to the parent `Aix` component.
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 | Type | Required | Description |
134
- |------|------|----------|-------------|
135
- | `index` | `number` | Yes | The index of this cell in the message list. |
136
- | `isLast` | `boolean` | Yes | Whether this cell is the last item in the list. Used for scroll positioning and animations. |
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 underneath it. The footer's height is automatically tracked for proper scroll offset calculations.
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 `AixFooter`. Apply padding to a child view instead.
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
@@ -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) {