expo-ai-composer 0.0.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/LICENSE +34 -0
- package/README.md +469 -0
- package/android/build.gradle +39 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/expo/modules/expoaicomposer/AiComposerView.kt +527 -0
- package/android/src/main/java/expo/modules/expoaicomposer/AiComposerWrapper.kt +674 -0
- package/android/src/main/java/expo/modules/expoaicomposer/ExpoAiComposerModule.kt +96 -0
- package/android/src/main/java/expo/modules/expoaicomposer/ImeKeyboardAnimationController.kt +144 -0
- package/android/src/main/java/expo/modules/expoaicomposer/PinToTopRunway.kt +74 -0
- package/android/src/main/java/expo/modules/expoaicomposer/ScrollToBottomButtonController.kt +170 -0
- package/build/AiComposerView.d.ts +4 -0
- package/build/AiComposerView.d.ts.map +1 -0
- package/build/AiComposerView.js +73 -0
- package/build/AiComposerView.js.map +1 -0
- package/build/AiComposerWrapper.d.ts +30 -0
- package/build/AiComposerWrapper.d.ts.map +1 -0
- package/build/AiComposerWrapper.js +9 -0
- package/build/AiComposerWrapper.js.map +1 -0
- package/build/ExpoAiComposer.types.d.ts +117 -0
- package/build/ExpoAiComposer.types.d.ts.map +1 -0
- package/build/ExpoAiComposer.types.js +2 -0
- package/build/ExpoAiComposer.types.js.map +1 -0
- package/build/ExpoAiComposerModule.d.ts +11 -0
- package/build/ExpoAiComposerModule.d.ts.map +1 -0
- package/build/ExpoAiComposerModule.js +9 -0
- package/build/ExpoAiComposerModule.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AiComposerView.swift +616 -0
- package/ios/AiComposerWrapper.swift +320 -0
- package/ios/ComposerHeightCoordinator.swift +35 -0
- package/ios/ExpoAiComposer.podspec +23 -0
- package/ios/ExpoAiComposerModule.swift +102 -0
- package/ios/KeyboardAwareScrollHandler.swift +601 -0
- package/ios/ScrollToBottomButtonController.swift +138 -0
- package/ios/ViewHierarchyFinder.swift +35 -0
- package/ios/WrapperPropertyObservers.swift +32 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oguzhan Cakmak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
1. The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
2. Attribution Requirement: Any project, product, or distribution that uses
|
|
16
|
+
this Software or substantial portions of it must include a visible
|
|
17
|
+
attribution to the original repository in at least one of the following
|
|
18
|
+
locations: the project's README, documentation, about page, or credits
|
|
19
|
+
section. The attribution must include:
|
|
20
|
+
|
|
21
|
+
- The name "expo-ai-composer"
|
|
22
|
+
- A link to https://github.com/muratcakmak/expo-ai-chat
|
|
23
|
+
|
|
24
|
+
Example attribution:
|
|
25
|
+
|
|
26
|
+
Built with [expo-ai-composer](https://github.com/muratcakmak/expo-ai-chat)
|
|
27
|
+
|
|
28
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
29
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
30
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
31
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
32
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
33
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
34
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# expo-ai-composer
|
|
2
|
+
|
|
3
|
+
A native keyboard-aware AI composer component for React Native. Provides smooth, system-level keyboard animations and a ChatGPT-style pin-to-top scroll experience for chat UIs.
|
|
4
|
+
|
|
5
|
+
<table>
|
|
6
|
+
<tr>
|
|
7
|
+
<td width="50%" align="center">
|
|
8
|
+
<img src="./assets/demo-1.gif" width="280" alt="First message animation and streaming" />
|
|
9
|
+
<br/><b>First message + streaming</b>
|
|
10
|
+
</td>
|
|
11
|
+
<td width="50%" align="center">
|
|
12
|
+
<img src="./assets/demo-2.gif" width="280" alt="Pin-to-top and keyboard handling" />
|
|
13
|
+
<br/><b>Pin-to-top + keyboard</b>
|
|
14
|
+
</td>
|
|
15
|
+
</tr>
|
|
16
|
+
</table>
|
|
17
|
+
|
|
18
|
+
## Highlights
|
|
19
|
+
|
|
20
|
+
<table>
|
|
21
|
+
<tr>
|
|
22
|
+
<td width="50%">
|
|
23
|
+
|
|
24
|
+
### Keyboard Tracking
|
|
25
|
+
Native keyboard animations that match system apps like iMessage. No JS bridge lag.
|
|
26
|
+
|
|
27
|
+
</td>
|
|
28
|
+
<td width="50%">
|
|
29
|
+
|
|
30
|
+
### Pin-to-Top Scroll
|
|
31
|
+
New messages pin to the top of the viewport. Streaming responses grow below with a runway — no jarring scroll jumps.
|
|
32
|
+
|
|
33
|
+
</td>
|
|
34
|
+
</tr>
|
|
35
|
+
<tr>
|
|
36
|
+
<td width="50%">
|
|
37
|
+
|
|
38
|
+
### First Message Animation
|
|
39
|
+
The first message slides from the composer to the top of the screen with a spring animation, just like ChatGPT and v0.
|
|
40
|
+
|
|
41
|
+
</td>
|
|
42
|
+
<td width="50%">
|
|
43
|
+
|
|
44
|
+
### Streaming + Stop
|
|
45
|
+
Built-in send/stop button with haptic feedback. The stop button appears during streaming with a single prop toggle.
|
|
46
|
+
|
|
47
|
+
</td>
|
|
48
|
+
</tr>
|
|
49
|
+
</table>
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- **Native keyboard tracking** — pixel-perfect animations using iOS keyboard notifications and Android `WindowInsetsAnimationCompat`
|
|
54
|
+
- **Pin-to-top scroll** — ChatGPT-style: user message pins to top, response streams below with runway inset
|
|
55
|
+
- **First message animation** — native pin automatically skips the first send so you can implement a slide-from-bottom animation in JS
|
|
56
|
+
- **Auto-growing text input** — multiline input that grows between configurable `minHeight` and `maxHeight`
|
|
57
|
+
- **Send/Stop button** — built-in circular send arrow and square stop icon with haptic feedback
|
|
58
|
+
- **Scroll-to-bottom FAB** — floating button appears when scrolled away, animates in sync with keyboard
|
|
59
|
+
- **Customizable accessory slots** — `headerAccessory`, `leadingAccessory`, `trailingAccessory`, `footerAccessory`
|
|
60
|
+
- **Transparent background** — no hardcoded colors; style from React Native
|
|
61
|
+
- **Expanded editor** — full-screen text editor sheet when maxHeight is reached (iOS)
|
|
62
|
+
- **Imperative ref** — `focus()`, `blur()`, `clear()` via React ref
|
|
63
|
+
- **Cross-platform** — full native implementations on both iOS and Android
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx expo install expo-ai-composer
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Requires Expo SDK 55+ with the [Expo Modules](https://docs.expo.dev/modules/overview/) system.
|
|
72
|
+
|
|
73
|
+
## Quick Start
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { useState } from "react";
|
|
77
|
+
import { View, ScrollView } from "react-native";
|
|
78
|
+
import { AiComposer, AiComposerWrapper, constants } from "expo-ai-composer";
|
|
79
|
+
|
|
80
|
+
export default function ChatScreen() {
|
|
81
|
+
const [composerHeight, setComposerHeight] = useState(constants.defaultMinHeight);
|
|
82
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
83
|
+
|
|
84
|
+
const handleSend = (text: string) => {
|
|
85
|
+
// Add user message, start streaming AI response...
|
|
86
|
+
setIsStreaming(true);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<View style={{ flex: 1 }}>
|
|
91
|
+
<AiComposerWrapper
|
|
92
|
+
style={{ flex: 1 }}
|
|
93
|
+
pinToTopEnabled
|
|
94
|
+
extraBottomInset={composerHeight}
|
|
95
|
+
>
|
|
96
|
+
<ScrollView style={{ flex: 1 }}>
|
|
97
|
+
{/* Your chat messages */}
|
|
98
|
+
</ScrollView>
|
|
99
|
+
|
|
100
|
+
<View style={{
|
|
101
|
+
position: "absolute",
|
|
102
|
+
bottom: 0, left: 0, right: 0,
|
|
103
|
+
height: composerHeight,
|
|
104
|
+
}}>
|
|
105
|
+
<AiComposer
|
|
106
|
+
style={{ flex: 1 }}
|
|
107
|
+
placeholder="Ask anything"
|
|
108
|
+
onSend={handleSend}
|
|
109
|
+
onStop={() => setIsStreaming(false)}
|
|
110
|
+
onHeightChange={setComposerHeight}
|
|
111
|
+
isStreaming={isStreaming}
|
|
112
|
+
sendButtonEnabled
|
|
113
|
+
/>
|
|
114
|
+
</View>
|
|
115
|
+
</AiComposerWrapper>
|
|
116
|
+
</View>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## API Reference
|
|
122
|
+
|
|
123
|
+
### `<AiComposer />`
|
|
124
|
+
|
|
125
|
+
The native text input with send/stop button.
|
|
126
|
+
|
|
127
|
+
#### Props
|
|
128
|
+
|
|
129
|
+
| Prop | Type | Default | Description |
|
|
130
|
+
|------|------|---------|-------------|
|
|
131
|
+
| `placeholder` | `string` | `"Type a message..."` | Placeholder text |
|
|
132
|
+
| `text` | `string` | — | Controlled text value |
|
|
133
|
+
| `minHeight` | `number` | `48` | Minimum composer height (points/dp) |
|
|
134
|
+
| `maxHeight` | `number` | `120` | Maximum height before scrolling |
|
|
135
|
+
| `sendButtonEnabled` | `boolean` | `true` | Whether send button is enabled |
|
|
136
|
+
| `showSendButton` | `boolean` | `true` | Show/hide the send button entirely |
|
|
137
|
+
| `editable` | `boolean` | `true` | Whether input is editable |
|
|
138
|
+
| `autoFocus` | `boolean` | `false` | Focus input on mount |
|
|
139
|
+
| `isStreaming` | `boolean` | `false` | Show stop button instead of send |
|
|
140
|
+
| `expandedEditorEnabled` | `boolean` | `false` | Enable full-screen editor (iOS only) |
|
|
141
|
+
|
|
142
|
+
#### Callbacks
|
|
143
|
+
|
|
144
|
+
| Callback | Type | Description |
|
|
145
|
+
|----------|------|-------------|
|
|
146
|
+
| `onChangeText` | `(text: string) => void` | Text changed |
|
|
147
|
+
| `onSend` | `(text: string) => void` | Send button pressed |
|
|
148
|
+
| `onStop` | `() => void` | Stop button pressed |
|
|
149
|
+
| `onHeightChange` | `(height: number) => void` | Composer height changed (for `extraBottomInset`) |
|
|
150
|
+
| `onKeyboardHeightChange` | `(height: number) => void` | Keyboard height changed |
|
|
151
|
+
| `onComposerFocus` | `() => void` | Input gained focus |
|
|
152
|
+
| `onComposerBlur` | `() => void` | Input lost focus |
|
|
153
|
+
|
|
154
|
+
#### Accessory Slots
|
|
155
|
+
|
|
156
|
+
Customize the area around the native text input with React Native views:
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
<AiComposer
|
|
160
|
+
headerAccessory={<FormattingToolbar />}
|
|
161
|
+
leadingAccessory={<AttachmentButton />}
|
|
162
|
+
trailingAccessory={<CustomSendButton />}
|
|
163
|
+
footerAccessory={<ModelSelector />}
|
|
164
|
+
/>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
| Slot | Position | Notes |
|
|
168
|
+
|------|----------|-------|
|
|
169
|
+
| `headerAccessory` | Above the input row | Formatting toolbar, context chips |
|
|
170
|
+
| `leadingAccessory` | Left of the input | Attachment, camera, mic button |
|
|
171
|
+
| `trailingAccessory` | Right of the input | **Replaces** built-in send button |
|
|
172
|
+
| `footerAccessory` | Below the input row | Model selector, file previews |
|
|
173
|
+
|
|
174
|
+
<!-- TODO: Replace with actual screenshot -->
|
|
175
|
+
<!--  -->
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
┌─────────────────────────────────┐
|
|
179
|
+
│ headerAccessory │
|
|
180
|
+
├──────┬──────────────────┬───────┤
|
|
181
|
+
│lead- │ │trail- │
|
|
182
|
+
│ing │ Native Input │ing │
|
|
183
|
+
│ │ │ │
|
|
184
|
+
├──────┴──────────────────┴───────┤
|
|
185
|
+
│ footerAccessory │
|
|
186
|
+
└─────────────────────────────────┘
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### Ref Methods
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
import { useRef } from "react";
|
|
193
|
+
import { AiComposer, type AiComposerRef } from "expo-ai-composer";
|
|
194
|
+
|
|
195
|
+
const composerRef = useRef<AiComposerRef>(null);
|
|
196
|
+
|
|
197
|
+
// Programmatic control
|
|
198
|
+
composerRef.current?.focus(); // Focus the input
|
|
199
|
+
composerRef.current?.blur(); // Blur the input
|
|
200
|
+
composerRef.current?.clear(); // Clear all text
|
|
201
|
+
|
|
202
|
+
<AiComposer ref={composerRef} ... />
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
### `<AiComposerWrapper />`
|
|
208
|
+
|
|
209
|
+
Keyboard-aware container that manages scroll position, keyboard animations, and composer translation. Wrap your `ScrollView` and `AiComposer` together inside this component.
|
|
210
|
+
|
|
211
|
+
#### Props
|
|
212
|
+
|
|
213
|
+
| Prop | Type | Default | Description |
|
|
214
|
+
|------|------|---------|-------------|
|
|
215
|
+
| `pinToTopEnabled` | `boolean` | `false` | Enable ChatGPT-style pin-to-top on send |
|
|
216
|
+
| `extraBottomInset` | `number` | `0` | Composer height — pass from `onHeightChange` |
|
|
217
|
+
| `extraTopInset` | `number` | `0` | Extra top inset for transparent headers (Android) |
|
|
218
|
+
| `scrollToTopTrigger` | `number` | `0` | Manually trigger pin (use `Date.now()` or counter) |
|
|
219
|
+
| `children` | `ReactNode` | — | Must contain a ScrollView and the composer |
|
|
220
|
+
| `style` | `ViewStyle` | — | Container style |
|
|
221
|
+
|
|
222
|
+
#### Keyboard Behavior
|
|
223
|
+
|
|
224
|
+
| Scenario | Behavior |
|
|
225
|
+
|----------|----------|
|
|
226
|
+
| Keyboard opens while at bottom | Auto-scrolls to keep content visible |
|
|
227
|
+
| Keyboard opens while mid-scroll | Opens over content, no scroll change |
|
|
228
|
+
| User sends message (2nd+) | Message pins to top, response streams below |
|
|
229
|
+
| User sends first message | Native pin skipped — implement JS animation |
|
|
230
|
+
| User scrolls away from bottom | Scroll-to-bottom FAB appears |
|
|
231
|
+
| User drags scroll view down quickly | Keyboard dismisses (interactive) |
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
### `constants`
|
|
236
|
+
|
|
237
|
+
Native constants exported from the module:
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
import { constants } from "expo-ai-composer";
|
|
241
|
+
|
|
242
|
+
constants.defaultMinHeight; // 48 — default minimum composer height
|
|
243
|
+
constants.defaultMaxHeight; // 120 — default maximum composer height
|
|
244
|
+
constants.contentGap; // 0 — gap between content and composer
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Layout Guide
|
|
248
|
+
|
|
249
|
+
### Recommended Structure
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
<View style={{ flex: 1 }}>
|
|
253
|
+
{/* Optional: Header above the wrapper */}
|
|
254
|
+
<Header />
|
|
255
|
+
|
|
256
|
+
<AiComposerWrapper
|
|
257
|
+
style={{ flex: 1 }}
|
|
258
|
+
pinToTopEnabled
|
|
259
|
+
extraBottomInset={composerHeight}
|
|
260
|
+
>
|
|
261
|
+
{/* ScrollView fills available space */}
|
|
262
|
+
<ScrollView
|
|
263
|
+
style={{ flex: 1 }}
|
|
264
|
+
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16 }}
|
|
265
|
+
>
|
|
266
|
+
{messages.map(renderMessage)}
|
|
267
|
+
</ScrollView>
|
|
268
|
+
|
|
269
|
+
{/* Composer is absolutely positioned — native code handles translation */}
|
|
270
|
+
<View
|
|
271
|
+
style={{
|
|
272
|
+
position: "absolute",
|
|
273
|
+
bottom: 0, left: 0, right: 0,
|
|
274
|
+
height: composerHeight,
|
|
275
|
+
}}
|
|
276
|
+
pointerEvents="box-none"
|
|
277
|
+
>
|
|
278
|
+
<View style={{ paddingHorizontal: 16, flex: 1 }}>
|
|
279
|
+
<View style={{
|
|
280
|
+
borderRadius: 24,
|
|
281
|
+
overflow: "hidden",
|
|
282
|
+
backgroundColor: "#F2F2F7",
|
|
283
|
+
flex: 1,
|
|
284
|
+
}}>
|
|
285
|
+
<AiComposer
|
|
286
|
+
style={{ flex: 1 }}
|
|
287
|
+
placeholder="Ask anything"
|
|
288
|
+
onSend={handleSend}
|
|
289
|
+
onStop={handleStop}
|
|
290
|
+
onHeightChange={setComposerHeight}
|
|
291
|
+
minHeight={constants.defaultMinHeight}
|
|
292
|
+
maxHeight={constants.defaultMaxHeight}
|
|
293
|
+
isStreaming={isStreaming}
|
|
294
|
+
sendButtonEnabled
|
|
295
|
+
/>
|
|
296
|
+
</View>
|
|
297
|
+
</View>
|
|
298
|
+
</View>
|
|
299
|
+
</AiComposerWrapper>
|
|
300
|
+
</View>
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Important Layout Rules
|
|
304
|
+
|
|
305
|
+
1. **No `paddingBottom` on scroll content** — the native wrapper handles bottom spacing via `extraBottomInset`
|
|
306
|
+
2. **Composer uses `height: composerHeight`** — track via `onHeightChange` callback
|
|
307
|
+
3. **Safe area is handled natively** — don't wrap the composer in `SafeAreaView`
|
|
308
|
+
4. **Use `pointerEvents="box-none"`** on the composer container to allow scroll touches to pass through
|
|
309
|
+
|
|
310
|
+
## First Message Animation
|
|
311
|
+
|
|
312
|
+
The native pin-to-top automatically skips the first send. This lets you implement a smooth slide-from-bottom animation in JavaScript:
|
|
313
|
+
|
|
314
|
+
```tsx
|
|
315
|
+
import { useRef, useEffect } from "react";
|
|
316
|
+
import { Animated, useWindowDimensions } from "react-native";
|
|
317
|
+
|
|
318
|
+
function FirstMessageAnimated({
|
|
319
|
+
children,
|
|
320
|
+
isFirst,
|
|
321
|
+
role,
|
|
322
|
+
}: {
|
|
323
|
+
children: React.ReactNode;
|
|
324
|
+
isFirst: boolean;
|
|
325
|
+
role: "user" | "assistant";
|
|
326
|
+
}) {
|
|
327
|
+
const { height } = useWindowDimensions();
|
|
328
|
+
const translateY = useRef(
|
|
329
|
+
new Animated.Value(isFirst && role === "user" ? height * 0.6 : 0)
|
|
330
|
+
).current;
|
|
331
|
+
const opacity = useRef(new Animated.Value(isFirst ? 0 : 1)).current;
|
|
332
|
+
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (!isFirst) return;
|
|
335
|
+
|
|
336
|
+
if (role === "user") {
|
|
337
|
+
// Slide from bottom to top + fade in
|
|
338
|
+
Animated.parallel([
|
|
339
|
+
Animated.spring(translateY, {
|
|
340
|
+
toValue: 0,
|
|
341
|
+
damping: 20,
|
|
342
|
+
stiffness: 180,
|
|
343
|
+
mass: 1,
|
|
344
|
+
useNativeDriver: true,
|
|
345
|
+
}),
|
|
346
|
+
Animated.timing(opacity, {
|
|
347
|
+
toValue: 1,
|
|
348
|
+
duration: 300,
|
|
349
|
+
useNativeDriver: true,
|
|
350
|
+
}),
|
|
351
|
+
]).start();
|
|
352
|
+
} else {
|
|
353
|
+
// Assistant: staggered fade in
|
|
354
|
+
Animated.timing(opacity, {
|
|
355
|
+
toValue: 1,
|
|
356
|
+
duration: 350,
|
|
357
|
+
delay: 200,
|
|
358
|
+
useNativeDriver: true,
|
|
359
|
+
}).start();
|
|
360
|
+
}
|
|
361
|
+
}, []);
|
|
362
|
+
|
|
363
|
+
if (!isFirst) return <>{children}</>;
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<Animated.View style={{ transform: [{ translateY }], opacity }}>
|
|
367
|
+
{children}
|
|
368
|
+
</Animated.View>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
## How It Works
|
|
374
|
+
|
|
375
|
+
### Architecture
|
|
376
|
+
|
|
377
|
+
```
|
|
378
|
+
┌─────────────────────────────────────────┐
|
|
379
|
+
│ AiComposerWrapper │
|
|
380
|
+
│ (native: manages keyboard + scroll) │
|
|
381
|
+
│ │
|
|
382
|
+
│ ┌───────────────────────────────────┐ │
|
|
383
|
+
│ │ ScrollView │ │
|
|
384
|
+
│ │ (content insets managed natively)│ │
|
|
385
|
+
│ │ │ │
|
|
386
|
+
│ │ ┌─────────────────────────────┐ │ │
|
|
387
|
+
│ │ │ Chat Messages │ │ │
|
|
388
|
+
│ │ └─────────────────────────────┘ │ │
|
|
389
|
+
│ │ │ │
|
|
390
|
+
│ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┐ │ │
|
|
391
|
+
│ │ │ Runway (pin-to-top inset) │ │ │
|
|
392
|
+
│ │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┘ │ │
|
|
393
|
+
│ └───────────────────────────────────┘ │
|
|
394
|
+
│ │
|
|
395
|
+
│ ┌───────────────────────────────────┐ │
|
|
396
|
+
│ │ AiComposer │ │
|
|
397
|
+
│ │ (native: translated by keyboard) │ │
|
|
398
|
+
│ └───────────────────────────────────┘ │
|
|
399
|
+
│ │
|
|
400
|
+
│ [FAB] Scroll-to-bottom button │
|
|
401
|
+
└─────────────────────────────────────────┘
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Pin-to-Top State Machine (iOS)
|
|
405
|
+
|
|
406
|
+
```
|
|
407
|
+
idle → armed → animating → pinned(enforce) → idle
|
|
408
|
+
↓
|
|
409
|
+
deferred (keyboard still closing)
|
|
410
|
+
↓
|
|
411
|
+
pinned(enforce)
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
1. **`idle`** — no pin active
|
|
415
|
+
2. **`armed`** — send triggered, waiting for content to grow
|
|
416
|
+
3. **`deferred`** — content grew but keyboard is still animating closed
|
|
417
|
+
4. **`animating`** — scroll animation to pinned offset in progress
|
|
418
|
+
5. **`pinned(enforce)`** — locked at offset, runway consumed as content streams
|
|
419
|
+
|
|
420
|
+
### Keyboard Animation Sync
|
|
421
|
+
|
|
422
|
+
| Platform | Mechanism | Sync Target |
|
|
423
|
+
|----------|-----------|-------------|
|
|
424
|
+
| iOS | `keyboardWillShow/Hide` notifications | `UIView.animate` with system curve |
|
|
425
|
+
| Android | `WindowInsetsAnimationCompat.Callback` | Frame-by-frame inset interpolation |
|
|
426
|
+
|
|
427
|
+
Both platforms translate the composer container and update scroll view padding in the same animation frame as the keyboard, producing a seamless native feel.
|
|
428
|
+
|
|
429
|
+
## Platform Notes
|
|
430
|
+
|
|
431
|
+
### iOS
|
|
432
|
+
- Uses `UITextView` for multiline input
|
|
433
|
+
- Keyboard curve extracted from notification `userInfo`
|
|
434
|
+
- Pin animation uses `UIViewPropertyAnimator` with velocity-based duration (1800 pts/sec)
|
|
435
|
+
- Expanded editor presents as `.pageSheet` with detent and grab handle
|
|
436
|
+
- `keyboardDismissMode: .interactive` — drag to dismiss
|
|
437
|
+
- Minimum deployment target: **iOS 15.1**
|
|
438
|
+
|
|
439
|
+
### Android
|
|
440
|
+
- Uses `EditText` with `TextWatcher`
|
|
441
|
+
- Keyboard tracked via `WindowInsetsAnimationCompat` (API 21+, compat)
|
|
442
|
+
- Send/stop buttons drawn with `Canvas` (no image assets needed)
|
|
443
|
+
- Composer translation applied frame-by-frame during IME animation
|
|
444
|
+
- Minimum SDK: **24**
|
|
445
|
+
|
|
446
|
+
## Types
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
import type {
|
|
450
|
+
AiComposerProps,
|
|
451
|
+
AiComposerRef,
|
|
452
|
+
AiComposerViewProps,
|
|
453
|
+
AiComposerConstants,
|
|
454
|
+
AiComposerWrapperProps,
|
|
455
|
+
TextEventPayload,
|
|
456
|
+
HeightEventPayload,
|
|
457
|
+
} from "expo-ai-composer";
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
## Requirements
|
|
461
|
+
|
|
462
|
+
- Expo SDK 55+
|
|
463
|
+
- React Native 0.83+
|
|
464
|
+
- iOS 15.1+
|
|
465
|
+
- Android SDK 24+
|
|
466
|
+
|
|
467
|
+
## License
|
|
468
|
+
|
|
469
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
|
|
3
|
+
group = 'expo.modules.expoaicomposer'
|
|
4
|
+
version = '0.1.0'
|
|
5
|
+
|
|
6
|
+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
7
|
+
apply from: expoModulesCorePlugin
|
|
8
|
+
applyKotlinExpoModulesCorePlugin()
|
|
9
|
+
useCoreDependencies()
|
|
10
|
+
useExpoPublishing()
|
|
11
|
+
|
|
12
|
+
def useManagedAndroidSdkVersions = false
|
|
13
|
+
if (useManagedAndroidSdkVersions) {
|
|
14
|
+
useDefaultAndroidSdkVersions()
|
|
15
|
+
} else {
|
|
16
|
+
buildscript {
|
|
17
|
+
ext.safeExtGet = { prop, fallback ->
|
|
18
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
project.android {
|
|
22
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
|
23
|
+
defaultConfig {
|
|
24
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
25
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
android {
|
|
31
|
+
namespace "expo.modules.expoaicomposer"
|
|
32
|
+
defaultConfig {
|
|
33
|
+
versionCode 1
|
|
34
|
+
versionName "0.1.0"
|
|
35
|
+
}
|
|
36
|
+
lintOptions {
|
|
37
|
+
abortOnError false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest/>
|