Skip to content

Commit 292fd5f

Browse files
committed
feat(navigation): add swipe tool for horizontal and vertical swiping
- Support direction-based swipes (left, right, up, down) - Support custom coordinate-based swipes with configurable duration - Optimized horizontal swipe handling for Android RecyclerViews - Platform-specific implementations for Android and iOS - Updated documentation in README and tools README
1 parent 9f8ef3a commit 292fd5f

File tree

4 files changed

+370
-0
lines changed

4 files changed

+370
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ MCP Appium provides a comprehensive set of tools organized into the following ca
195195
| `appium_screenshot` | Take a screenshot of the current screen and save as PNG |
196196
| `appium_scroll` | Scroll the screen vertically (up or down) |
197197
| `appium_scroll_to_element` | Scroll until a specific element becomes visible |
198+
| `appium_swipe` | Swipe the screen in a direction (left, right, up, down) or between custom coordinates |
198199
| `appium_get_page_source` | Get the page source (XML) from the current screen |
199200

200201
### App Management

src/tools/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ This directory contains all MCP tools available in MCP Appium.
2121

2222
- `scroll.ts` - Scroll screens
2323
- `scroll-to-element.ts` - Scroll until element found
24+
- `swipe.ts` - Swipe screens in any direction or between custom coordinates
2425

2526
### Element Interactions (`interactions/`)
2627

src/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import installWDA from './ios/install-wda.js';
2626
import generateTest from './test-generation/generate-tests.js';
2727
import scroll from './navigations/scroll.js';
2828
import scrollToElement from './navigations/scroll-to-element.js';
29+
import swipe from './navigations/swipe.js';
2930
import findElement from './interactions/find.js';
3031
import clickElement from './interactions/click.js';
3132
import doubleTap from './interactions/double-tap.js';
@@ -120,6 +121,7 @@ export default function registerTools(server: FastMCP): void {
120121
// Navigation
121122
scroll(server);
122123
scrollToElement(server);
124+
swipe(server);
123125

124126
// Element Interactions
125127
findElement(server);

src/tools/navigations/swipe.ts

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
import { z } from 'zod';
2+
import { getDriver, getPlatformName } from '../../session-store.js';
3+
import log from '../../logger.js';
4+
import { elementUUIDScheme } from '../../schema.js';
5+
6+
function calculateSwipeCoordinates(
7+
direction: 'left' | 'right' | 'up' | 'down',
8+
width: number,
9+
height: number
10+
): { startX: number; startY: number; endX: number; endY: number } {
11+
const centerX = Math.floor(width / 2);
12+
const centerY = Math.floor(height / 2);
13+
14+
switch (direction) {
15+
case 'left':
16+
return {
17+
startX: Math.floor(width * 0.8),
18+
startY: centerY,
19+
endX: Math.floor(width * 0.2),
20+
endY: centerY,
21+
};
22+
case 'right':
23+
return {
24+
startX: Math.floor(width * 0.2),
25+
startY: centerY,
26+
endX: Math.floor(width * 0.8),
27+
endY: centerY,
28+
};
29+
case 'up':
30+
return {
31+
startX: centerX,
32+
startY: Math.floor(height * 0.8),
33+
endX: centerX,
34+
endY: Math.floor(height * 0.2),
35+
};
36+
case 'down':
37+
return {
38+
startX: centerX,
39+
startY: Math.floor(height * 0.2),
40+
endX: centerX,
41+
endY: Math.floor(height * 0.8),
42+
};
43+
default:
44+
throw new Error(`Invalid direction: ${direction}`);
45+
}
46+
}
47+
48+
async function performAndroidSwipe(
49+
driver: any,
50+
startX: number,
51+
startY: number,
52+
endX: number,
53+
endY: number,
54+
duration: number
55+
): Promise<void> {
56+
await driver.performActions([
57+
{
58+
type: 'pointer',
59+
id: 'finger1',
60+
parameters: { pointerType: 'touch' },
61+
actions: [
62+
{ type: 'pointerMove', duration: 0, x: startX, y: startY },
63+
{ type: 'pointerDown', button: 0 },
64+
{ type: 'pause', duration: 250 },
65+
{ type: 'pointerMove', duration: duration, x: endX, y: endY },
66+
{ type: 'pointerUp', button: 0 },
67+
],
68+
},
69+
]);
70+
}
71+
72+
async function performiOSSwipe(
73+
driver: any,
74+
startX: number,
75+
startY: number,
76+
endX: number,
77+
endY: number,
78+
duration: number
79+
): Promise<void> {
80+
try {
81+
await driver.execute('mobile: dragFromToForDuration', {
82+
fromX: startX,
83+
fromY: startY,
84+
toX: endX,
85+
toY: endY,
86+
duration: duration / 1000,
87+
});
88+
log.info('iOS swipe completed using mobile: dragFromToForDuration');
89+
} catch (dragError) {
90+
log.info('mobile: dragFromToForDuration failed, trying performActions');
91+
await driver.performActions([
92+
{
93+
type: 'pointer',
94+
id: 'finger1',
95+
parameters: { pointerType: 'touch' },
96+
actions: [
97+
{ type: 'pointerMove', duration: 0, x: startX, y: startY },
98+
{ type: 'pointerDown', button: 0 },
99+
{ type: 'pause', duration: 200 },
100+
{ type: 'pointerMove', duration: duration, x: endX, y: endY },
101+
{ type: 'pause', duration: 50 },
102+
{ type: 'pointerUp', button: 0 },
103+
],
104+
},
105+
]);
106+
log.info('iOS swipe completed using performActions');
107+
}
108+
}
109+
110+
export default function swipe(server: any): void {
111+
server.addTool({
112+
name: 'appium_swipe',
113+
description: `Swipe on the current screen in a specified direction or between custom coordinates.
114+
Supports four directions: left, right, up, down.
115+
Can also perform custom coordinate-based swipes for precise control.
116+
This is useful for navigating carousels, switching tabs, dismissing elements, or navigating between screens.`,
117+
parameters: z.object({
118+
direction: z
119+
.enum(['left', 'right', 'up', 'down'])
120+
.optional()
121+
.describe(
122+
'Direction to swipe. If provided, coordinates will be calculated automatically based on screen size or, when elementUUID is set, relative to that element. Either direction OR custom coordinates must be provided.'
123+
),
124+
elementUUID: elementUUIDScheme
125+
.optional()
126+
.describe(
127+
'Optional element to base the swipe on. When provided with direction, the swipe is calculated relative to this element instead of the whole screen.'
128+
),
129+
startX: z
130+
.number()
131+
.int()
132+
.min(0)
133+
.optional()
134+
.describe(
135+
'Starting X coordinate for custom swipe. Required if direction is not provided.'
136+
),
137+
startY: z
138+
.number()
139+
.int()
140+
.min(0)
141+
.optional()
142+
.describe(
143+
'Starting Y coordinate for custom swipe. Required if direction is not provided.'
144+
),
145+
endX: z
146+
.number()
147+
.int()
148+
.min(0)
149+
.optional()
150+
.describe(
151+
'Ending X coordinate for custom swipe. Required if direction is not provided.'
152+
),
153+
endY: z
154+
.number()
155+
.int()
156+
.min(0)
157+
.optional()
158+
.describe(
159+
'Ending Y coordinate for custom swipe. Required if direction is not provided.'
160+
),
161+
duration: z
162+
.number()
163+
.int()
164+
.min(0)
165+
.max(5000)
166+
.default(600)
167+
.optional()
168+
.describe(
169+
'Duration of the swipe gesture in milliseconds. Default is 600ms. Higher values create slower swipes.'
170+
),
171+
}),
172+
annotations: {
173+
readOnlyHint: false,
174+
openWorldHint: false,
175+
},
176+
execute: async (args: any, context: any): Promise<any> => {
177+
const driver = getDriver();
178+
if (!driver) {
179+
throw new Error(
180+
'No active driver session. Please create a session first.'
181+
);
182+
}
183+
184+
try {
185+
const platform = getPlatformName(driver);
186+
let startX: number, startY: number, endX: number, endY: number;
187+
188+
if (args.direction) {
189+
if (args.elementUUID) {
190+
const rect = await (driver as any).getElementRect(
191+
args.elementUUID
192+
);
193+
const elementCenterX = Math.floor(rect.x + rect.width / 2);
194+
const elementCenterY = Math.floor(rect.y + rect.height / 2);
195+
196+
switch (args.direction) {
197+
case 'left':
198+
startX = Math.floor(rect.x + rect.width * 0.8);
199+
startY = elementCenterY;
200+
endX = Math.floor(rect.x + rect.width * 0.2);
201+
endY = elementCenterY;
202+
break;
203+
case 'right':
204+
startX = Math.floor(rect.x + rect.width * 0.2);
205+
startY = elementCenterY;
206+
endX = Math.floor(rect.x + rect.width * 0.8);
207+
endY = elementCenterY;
208+
break;
209+
case 'up':
210+
startX = elementCenterX;
211+
startY = Math.floor(rect.y + rect.height * 0.8);
212+
endX = elementCenterX;
213+
endY = Math.floor(rect.y + rect.height * 0.2);
214+
break;
215+
case 'down':
216+
startX = elementCenterX;
217+
startY = Math.floor(rect.y + rect.height * 0.2);
218+
endX = elementCenterX;
219+
endY = Math.floor(rect.y + rect.height * 0.8);
220+
break;
221+
default:
222+
throw new Error(`Invalid direction: ${args.direction}`);
223+
}
224+
log.info('Calculated element-based swipe coordinates:', {
225+
elementUUID: args.elementUUID,
226+
startX,
227+
startY,
228+
endX,
229+
endY,
230+
});
231+
} else {
232+
const { width, height } = await driver.getWindowSize();
233+
log.info('Device screen size:', { width, height });
234+
const coords = calculateSwipeCoordinates(
235+
args.direction,
236+
width,
237+
height
238+
);
239+
startX = coords.startX;
240+
startY = coords.startY;
241+
endX = coords.endX;
242+
endY = coords.endY;
243+
}
244+
} else if (
245+
args.startX !== undefined &&
246+
args.startY !== undefined &&
247+
args.endX !== undefined &&
248+
args.endY !== undefined
249+
) {
250+
startX = args.startX;
251+
startY = args.startY;
252+
endX = args.endX;
253+
endY = args.endY;
254+
} else {
255+
throw new Error(
256+
'Either direction or all custom coordinates (startX, startY, endX, endY) must be provided.'
257+
);
258+
}
259+
260+
const duration = args.duration || 600;
261+
262+
log.info('Swipe coordinates:', {
263+
startX,
264+
startY,
265+
endX,
266+
endY,
267+
duration,
268+
});
269+
270+
if (platform === 'Android') {
271+
if (startX !== endX && Math.abs(startY - endY) < 50) {
272+
const swipeDuration = Math.min(duration, 400);
273+
await driver.performActions([
274+
{
275+
type: 'pointer',
276+
id: 'finger1',
277+
parameters: { pointerType: 'touch' },
278+
actions: [
279+
{ type: 'pointerMove', duration: 0, x: startX, y: startY },
280+
{ type: 'pointerDown', button: 0 },
281+
{ type: 'pause', duration: 200 },
282+
{
283+
type: 'pointerMove',
284+
duration: swipeDuration,
285+
x: endX,
286+
y: endY,
287+
},
288+
{ type: 'pause', duration: 50 },
289+
{ type: 'pointerUp', button: 0 },
290+
],
291+
},
292+
]);
293+
log.info('Android horizontal swipe completed');
294+
} else {
295+
await performAndroidSwipe(
296+
driver,
297+
startX,
298+
startY,
299+
endX,
300+
endY,
301+
duration
302+
);
303+
}
304+
log.info('Android swipe action completed successfully.');
305+
} else if (platform === 'iOS') {
306+
if (args.direction) {
307+
try {
308+
await driver.execute('mobile: swipe', {
309+
direction: args.direction,
310+
});
311+
log.info(
312+
`iOS swipe completed using mobile: swipe (${args.direction})`
313+
);
314+
} catch (swipeError) {
315+
log.info('mobile: swipe failed, trying dragFromToForDuration');
316+
await performiOSSwipe(
317+
driver,
318+
startX,
319+
startY,
320+
endX,
321+
endY,
322+
duration
323+
);
324+
}
325+
} else {
326+
await performiOSSwipe(
327+
driver,
328+
startX,
329+
startY,
330+
endX,
331+
endY,
332+
duration
333+
);
334+
}
335+
log.info('iOS swipe action completed successfully.');
336+
} else {
337+
throw new Error(
338+
`Unsupported platform: ${platform}. Only Android and iOS are supported.`
339+
);
340+
}
341+
342+
const directionText = args.direction
343+
? ` ${args.direction}`
344+
: ` from (${startX}, ${startY}) to (${endX}, ${endY})`;
345+
346+
return {
347+
content: [
348+
{
349+
type: 'text',
350+
text: `Swiped${directionText} successfully.`,
351+
},
352+
],
353+
};
354+
} catch (err: any) {
355+
return {
356+
content: [
357+
{
358+
type: 'text',
359+
text: `Failed to perform swipe. Error: ${err.toString()}`,
360+
},
361+
],
362+
};
363+
}
364+
},
365+
});
366+
}

0 commit comments

Comments
 (0)