# Extending Expo UI with SwiftUI: Building a Native swipeActions Modifier

`@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. 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

```sh
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:

```plaintext
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:

```json
{
  "platforms": ["apple"],
  "apple": { "modules": ["SwipeActionsModule"] }
}
```

And the `package.json` is just a name plus main:

```json
{
  "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:

```ruby
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.

*   `struct` and `class` are like JS classes, but `struct` is a value type (copied when passed around) and `class` is a reference type. SwiftUI uses structs almost everywhere because views are cheap to recreate.
    
*   `protocol` is 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 as `T | null` in 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 View` is a return type that means "some specific type that conforms to `View`, the compiler will figure out which one." You will see it on every SwiftUI view's `body`.
    

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:

```swift
// 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):

```swift
// 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 `@Field`s. 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

```swift
// 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.

```ts
// 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`:

```tsx
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`:

```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.
