Extending Expo UI with SwiftUI: Building a Native swipeActions Modifier
Building a real, reusable SwiftUI modifier from scratch.

@expo/ui/swift-ui lets you compose SwiftUI views and modifiers from JavaScript. HStack, VStack, List, font, foregroundStyle, onTapGesture, refreshable. It is a sizeable chunk of SwiftUI, but not all of it. One day you will reach for something that is not there.
For me, that something was swipeActions, the swipe-from-the-edge buttons you see in iOS Mail. My app already rendered each row inside a SwiftUI List, so wiring up Edit / Skip / Delete swipe actions should have been one line of SwiftUI. Except @expo/ui did not expose it.
This post walks through building it as a local Expo module. The result is around 80 lines of Swift, around 30 lines of TypeScript, and one npm install to wire up. If you have never written Swift before, that is fine. I will explain the Swift-specific bits as we go.
View or modifier? The choice is forced
Before any code, a decision: do you build this as a native view or a native modifier?
A native view is what npx create-expo-module --local creates by default. You expose a SwiftUI view to JS, and JS renders it like any other component.
A native modifier is what Expo UI uses for things like font(...) or onTapGesture(...). They do not render anything on their own, they style or extend an existing view. Think of them as the SwiftUI equivalent of styled-components mixins, except they can also attach behaviour like gestures and lifecycle callbacks.
For swipeActions, the choice is forced by SwiftUI itself. .swipeActions(edge:allowsFullSwipe:content:) only works when SwiftUI sees it attached to a row inside a List. If you wrap the row in a custom view, SwiftUI no longer sees a row inside a list, it sees a generic view containing your wrapper containing the row, and .swipeActions becomes a no-op. So, modifier it is.
Creating the module
npx create-expo-module@latest --local swipe-actions
This creates modules/swipe-actions/ with a sample WebView component, Android files, and a web fallback. For a SwiftUI-only modifier you can throw most of it away. Here is the final structure I ended up with:
modules/swipe-actions/
├── expo-module.config.json
├── package.json
├── index.ts
└── ios/
├── SwipeActions.podspec
├── SwipeActionRecord.swift
├── SwipeActionsModifier.swift
└── SwipeActionsModule.swift
The config narrows to iOS:
{
"platforms": ["apple"],
"apple": { "modules": ["SwipeActionsModule"] }
}
And the package.json is just a name plus main:
{
"name": "swipe-actions",
"version": "1.0.0",
"main": "index.ts",
"types": "index.ts",
"private": true
}
The podspec needs one line beyond the default, a dependency on ExpoUI, which is what gives us access to the modifier registry:
s.dependency 'ExpoModulesCore'
s.dependency 'ExpoUI'
The Swift side, in three small files
We will go bottom-up. A "record" that describes one swipe action, a modifier that consumes those records, and a module that registers the modifier so JS can use it.
A quick Swift primer for RN devs
A few Swift concepts come up in the next sections. If you already know Swift, skip this.
structandclassare like JS classes, butstructis a value type (copied when passed around) andclassis a reference type. SwiftUI uses structs almost everywhere because views are cheap to recreate.protocolis Swift's word for interface. When a type "conforms to" a protocol, it is promising to provide certain methods or properties.?after a type means "optional", essentially the same asT | nullin TypeScript.String?is a string or nil.@PropertyWrapper(anything starting with@) is a Swift annotation that adds behaviour to a property, similar to a decorator.some Viewis a return type that means "some specific type that conforms toView, the compiler will figure out which one." You will see it on every SwiftUI view'sbody.
That is enough to read everything below.
A record per action
Each swipe action has an id, a label, an optional icon, an optional tint colour, and an optional role. In Expo's SwiftUI extension, anything you decode from JS params follows the Record protocol and uses @Field to mark which properties come from the JS side:
// SwipeActionRecord.swift
import ExpoModulesCore
import SwiftUI
final class SwipeActionRecord: Record {
@Field var id: String = ""
@Field var label: String = ""
@Field var systemImage: String? = nil
@Field var tint: Color? = nil
@Field var role: String? = nil
}
Two things to notice. First, @Field var tint: Color? = nil. ExpoModulesCore decodes hex strings like "#FF3B30" from JS straight into a SwiftUI Color. No manual parsing on either side. Second, role is a string rather than a Swift enum, because the JS side sends "destructive" or "cancel" and decoding into custom Swift enums needs extra setup that is not worth it for two values.
systemImage refers to SF Symbols, Apple's built-in icon library. Names like "trash", "pencil", and "forward.fill" map to icons that ship with iOS, so you do not need to bundle anything.
The modifier
This is the heart of it. A type that conforms to both SwiftUI's ViewModifier protocol (so SwiftUI can apply it) and Expo's Record protocol (so it can be decoded from JS params):
// SwipeActionsModifier.swift
import ExpoModulesCore
import SwiftUI
struct SwipeActionsModifier: ViewModifier, Record {
@Field var leading: [SwipeActionRecord] = []
@Field var trailing: [SwipeActionRecord] = []
@Field var allowsFullSwipe: Bool = false
var eventDispatcher: EventDispatcher?
init() {}
init(from params: Dict, appContext: AppContext, eventDispatcher: EventDispatcher) throws {
try self = .init(from: params, appContext: appContext)
self.eventDispatcher = eventDispatcher
}
func body(content: Content) -> some View {
content
.swipeActions(edge: .trailing, allowsFullSwipe: allowsFullSwipe) {
ForEach(trailing, id: \.id) { action in
actionButton(action)
}
}
.swipeActions(edge: .leading, allowsFullSwipe: allowsFullSwipe) {
ForEach(leading, id: \.id) { action in
actionButton(action)
}
}
}
@ViewBuilder
private func actionButton(_ action: SwipeActionRecord) -> some View {
let role: ButtonRole? = {
switch action.role {
case "destructive": return .destructive
case "cancel": return .cancel
default: return nil
}
}()
Button(role: role) {
eventDispatcher?(["swipeActions": ["id": action.id]])
} label: {
if let icon = action.systemImage {
Label(action.label, systemImage: icon)
} else {
Text(action.label)
}
}
.tint(action.tint)
}
}
There is a lot happening here, so let's walk through it.
The two init methods exist because Record requires a no-argument init() for the decoding machinery. The second init is the one Expo actually calls when wiring this up, and it takes the decoded params plus an EventDispatcher, then forwards the params to the standard Record init and stashes the dispatcher.
The eventDispatcher is not a @Field. It is a regular stored property. This trips people up because most other things on a Record are @Fields. The dispatcher does not come from JSON params, it is injected by the registry at registration time, which is why it gets its own init parameter.
func body(content: Content) -> some View is the method every ViewModifier implements. content is the view this modifier is being applied to (in our case, the List row). We return a new view that takes that content and stacks two .swipeActions calls onto it, one for each edge.
ForEach(trailing, id: \.id) is SwiftUI's loop. The \.id syntax is a "key path", Swift's way of saying "use the id property of each element as the React-style key." Inside the closure, $0 is shorthand for the current element (Swift's equivalent of an arrow function's first arg).
actionButton builds a single button. The @ViewBuilder annotation lets us use if/else inside to conditionally include a Label (icon plus text) or a plain Text. When the button is tapped, we call eventDispatcher with a dictionary. That dictionary travels back to JS as an event payload.
One subtle but important detail: the key in ["swipeActions": ["id": action.id]] must match the modifier's type identifier (the \(type field on the JS side, which we will see in a moment). Expo UI registers event listeners in a map keyed by \)type, so if you dispatch under any other name the event silently disappears into nothing. If your event handler is not firing, this is the first thing to check.
Registering the modifier
// SwipeActionsModule.swift
import ExpoModulesCore
import ExpoUI
public class SwipeActionsModule: Module {
public func definition() -> ModuleDefinition {
Name("SwipeActions")
OnCreate {
ViewModifierRegistry.register("swipeActions") { params, appContext, eventDispatcher in
try SwipeActionsModifier(
from: params,
appContext: appContext,
eventDispatcher: eventDispatcher
)
}
}
OnDestroy {
ViewModifierRegistry.unregister("swipeActions")
}
}
}
Module is the Expo base class that every native module extends. definition() is where you describe what the module exposes: its name, lifecycle hooks, methods, views, and so on.
OnCreate and OnDestroy are lifecycle hooks. Registering inside OnCreate rather than at the top level is not optional, the docs call this out specifically to avoid a threading race where the registry is read before your modifier is added.
The third parameter of the registration closure (eventDispatcher) is what gets threaded into the modifier's secondary init. That is the missing link from the previous section.
The JavaScript side
The TS layer is small. It does two things: convert a friendly per-action onPress callback API into a structure that can be serialised across to native, and dispatch incoming events back to the right callback by id.
// index.ts
import { createModifierWithEventListener } from "@expo/ui/swift-ui/modifiers";
export type SwipeActionRole = "destructive" | "cancel";
export interface SwipeActionConfig {
id: string;
label: string;
systemImage?: string;
tint?: string;
role?: SwipeActionRole;
onPress: () => void;
}
export interface SwipeActionsParams {
leading?: SwipeActionConfig[];
trailing?: SwipeActionConfig[];
allowsFullSwipe?: boolean;
}
export function swipeActions(params: SwipeActionsParams) {
const handlers = new Map<string, () => void>();
for (const a of params.leading ?? []) handlers.set(a.id, a.onPress);
for (const a of params.trailing ?? []) handlers.set(a.id, a.onPress);
const stripPress = ({ onPress, ...rest }: SwipeActionConfig) => rest;
return createModifierWithEventListener(
"swipeActions",
({ id }: { id: string }) => handlers.get(id)?.(),
{
leading: (params.leading ?? []).map(stripPress),
trailing: (params.trailing ?? []).map(stripPress),
allowsFullSwipe: params.allowsFullSwipe ?? false,
}
);
}
createModifierWithEventListener is the helper Expo UI uses internally for onTapGesture, onAppear, refreshable, and friends. It returns a config object with a \(type (which must match the string we registered on the Swift side) and an eventListener. When the host view renders, it scans its modifiers for event listeners and registers them by \)type.
The stripPress step is necessary because functions cannot cross the JS-to-native boundary as props. We pull the callbacks into a JS-side Map keyed by id, send only the serialisable fields across, and let the single event listener dispatch to the right callback when the native side fires ["swipeActions": ["id": "delete"]].
Wiring it into a screen
From the JS side, you apply it like any other modifier. Drop it into the modifiers array on a row inside a List:
import { swipeActions } from "swipe-actions";
import { List, HStack } from "@expo/ui/swift-ui";
<List>
<HStack
alignment="center"
modifiers={[
swipeActions({
leading: [
{
id: "archive",
label: "Archive",
systemImage: "archivebox",
tint: "teal",
onPress: handleArchive,
},
],
trailing: [
{
id: "delete",
label: "Delete",
systemImage: "trash",
tint: "#FF3B30",
role: "destructive",
onPress: handleDelete,
},
{
id: "edit",
label: "Edit",
systemImage: "pencil",
tint: "#0A84FF",
onPress: handleEdit,
},
],
allowsFullSwipe: false,
}),
]}
>
{/* content */}
</HStack>
</List>
For TS resolution, add the local module to your root package.json:
"swipe-actions": "file:./modules/swipe-actions"
npm install symlinks it under node_modules/.
cd ios && pod install (or npx expo run:ios) autolinks the native side. Done.
Wrapping up
The full source is small enough to read in one sitting, around 110 lines across four files. The hardest part was not writing it, it was figuring out which 110 lines to write. If you are extending Expo UI with your own modifiers, the pattern is the same every time: a Record for params, a ViewModifier that consumes them, an EventDispatcher injected via a secondary init, and a Module that registers the whole thing in OnCreate. Once you’ve built one, the next time is much easier since you’re just repeating the same pattern.



