---
title: "Build your own layout"
description: "Place SeatLayerPickerScope and arrange the picker widgets yourself, reading state and issuing commands while SeatLayer keeps holds and checkout."
---

This is rung 3 of the [three ways in](/buyer-sdk/flutter). Reach for it when the
*arrangement* has to differ — a persistent sidebar, a map embedded in a tab, a
sheet that already exists in your design system. If you only need different
colours, wording, sizes, or one replaced part, rung 2 on the
[customise page](/buyer-sdk/flutter/customise) is less code and less to
maintain.

You are rearranging presentation, not reimplementing anything. Inventory,
availability, validation, holds, expiry, and the checkout handoff all still come
from the same controller.

## Which widget owns which region

![A phone frame mapping each screen region to its widget — header at the top, price legend and floor strip over the map, map controls in the corners, confirm card or 3D chrome over the map, dock bar under it, cart sheet at the bottom — beside the Flutter tree that produces the same thing.](/diagrams/flutter-custom-layout.svg)

## Step 1 — place the scope

`SeatLayerPickerScope` starts the session and publishes the state. Everything
below it can read that state; nothing above it can.

```dart
SeatLayerPickerScope(
  configuration: SeatLayerConfiguration(
    event: 'ev_your_event_key',
    publicKey: 'pk_test_your_public_key',
  ),
  themeMode: SeatLayerThemeMode.auto,
  options: const SeatLayerPickerOptions(),
  child: MyPickerLayout(),
)
```

Pass a `controller:` when your own code needs to drive the picker; the scope
creates and disposes one otherwise. A controller you create is yours to
`dispose()`, and you should `await controller.close()` before deliberately
removing an inline picker, so a picker-owned hold is acknowledged as released.

## Step 2 — arrange the widgets

```dart
class MyPickerLayout extends StatefulWidget {
  const MyPickerLayout({super.key});

  @override
  State<MyPickerLayout> createState() => _MyPickerLayoutState();
}

class _MyPickerLayoutState extends State<MyPickerLayout> {
  bool expanded = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SeatLayerPickerHeader(compact: true, onClose: () => Navigator.pop(context)),
        Expanded(
          child: Stack(
            children: [
              const Positioned.fill(child: SeatLayerChart()),
              const Positioned(
                top: 8,
                left: 0,
                child: SeatLayerPriceLegend(compact: true),
              ),
              const Positioned(top: 40, left: 0, child: SeatLayerFloorStrip()),
              const Positioned.fill(
                child: SeatLayerPickerMapControls(compact: true),
              ),
              const Positioned.fill(child: SeatLayerVenue3D()),
              const Positioned(left: 0, right: 0, bottom: 0, child: SeatLayerDockBar()),
            ],
          ),
        ),
        SeatLayerCartSheet(
          expanded: expanded,
          onExpandedChanged: (value) => setState(() => expanded = value),
          onCheckout: (handoff) => bookOnYourBackend(handoff.holdId),
        ),
        const SeatLayerPickerAttribution(),
      ],
    );
  }
}
```

`SeatLayerChart` needs bounded space and owns pan and pinch. Give it an
`Expanded`, a `SizedBox` with a definite height, or a full-screen route — never
a competing gesture-driven scroll view.

<Aside type="caution" title="Two things you must include">
  A manual composition must still render `SeatLayerPickerAttribution` and
  `SeatLayerPickerTestModeIndicator`. They are required chrome, and the drop-in
  places them for you; when you own the layout, you place them.
</Aside>

## Step 3 — read state in your own widgets

The scope exposes static lookups rather than a single `of`. Each one subscribes
your widget to exactly what it asked for.

| Lookup | Returns |
| --- | --- |
| `SeatLayerPickerScope.stateOf(context)` | `SeatLayerPickerState` — the live snapshot-backed state |
| `SeatLayerPickerScope.controllerOf(context)` | `SeatLayerPickerController` |
| `SeatLayerPickerScope.configurationOf(context)` | `SeatLayerConfiguration` |
| `SeatLayerPickerScope.optionsOf(context)` | `SeatLayerPickerOptions` |
| `SeatLayerPickerScope.themeOf(context)` | `SeatLayerPickerThemeData?` |
| `SeatLayerPickerScope.themeModeOf(context)` | `SeatLayerThemeMode` |
| `SeatLayerPickerScope.brightnessOf(context)` | `Brightness` — the resolved side |
| `SeatLayerPickerScope.stringsOf(context)` | `SeatLayerPickerStrings` |
| `SeatLayerPickerScope.callbacksOf(context)` | `SeatLayerPickerCallbacks` |
| `SeatLayerPickerScope.cartSheetExpandedOf(context)` | `bool` |

A custom section list, in full:

```dart
class MySectionList extends StatelessWidget {
  const MySectionList({super.key});

  @override
  Widget build(BuildContext context) {
    final state = SeatLayerPickerScope.stateOf(context);
    final picker = SeatLayerPickerScope.controllerOf(context);
    final strings = SeatLayerPickerScope.stringsOf(context);

    return ListView(
      children: [
        for (final section in state.snapshot?.sections ?? const [])
          ListTile(
            title: Text(section.displayLabel ?? section.label),
            subtitle: section.seatsLeft == null
                ? null
                : Text(strings.seatsLeft(section.seatsLeft!)),
            selected: section.id == state.snapshot?.map.focusedSectionId,
            onTap: () => picker.focusSection(section.id),
          ),
        TextButton(
          onPressed: picker.overview,
          child: Text(strings.overview),
        ),
      ],
    );
  }
}
```

`SeatLayerPickerController` is a `ValueNotifier`, so a
`ValueListenableBuilder<SeatLayerPickerState>` works too when you hold the
controller directly.

## Step 4 — drive the picker

The commands a custom layout reaches for most:

| You want | Call |
| --- | --- |
| Focus a section | `picker.focusSection(sectionId)` |
| Go back to the venue | `picker.overview()` |
| Move to a floor | `picker.setFloor(floorId)` · `picker.showAllFloors()` |
| Select or drop seats | `picker.selectObjects([...])` · `picker.deselectObjects([...])` |
| Select a whole category | `picker.selectCategories([...])` · `picker.deselectCategories([...])` |
| Remove one ticket | `picker.removeObject(objectId)` |
| Constrain the buyer | `picker.setSelectableObjects([...])` · `picker.setMaxSelection(4)` |
| Filter the map | `picker.setCategoryFilter({'stalls'}, focus: true)` |
| Find seats | `picker.bestAvailable(quantity: 4, categoryKey: 'stalls')` |
| Switch view | `picker.setBuyerView(SeatLayerBuyerView.venue3D)` · `picker.setViewMode(mode)` |
| Inspect one seat | `picker.showSeatIn3D(seat)` · `picker.openSeatView(seat)` |
| Restore a hold | `picker.resumeHold(holdId)` · `picker.extendHold()` |
| Take the handoff | `await picker.checkout()` |

Every method returns a `Future`, and inventory-changing calls are serialised, so
a double tap cannot produce two holds.

## Step 5 — tell the map where your chrome is

The runtime frames a focused section, the venue overview, and a best-available
result into the part of the map the buyer can actually see. Report what your
chrome covers, or those framings will centre behind it:

```dart
SeatLayerPickerScope.setViewportInsets(
  context,
  const SeatLayerViewportInsets(top: 96, bottom: 140),
);
```

`SeatLayerViewportInsets` has `top`, `right`, `bottom`, and `left`, all
defaulting to `0`, plus `SeatLayerViewportInsets.zero`. It is a framing inset,
not a clip — the venue still draws and pans underneath. What the runtime is
honouring comes back as `viewportInsets` inside the snapshot's `map` field.
`SeatLayerPickerController.setViewportInsets` is the same call when you hold the
controller.

## Step 6 — lock the map behind your own prompt

Whenever your own chrome covers the map, disable map interaction for exactly as
long as it is up, and restore it in a `finally`:

```dart
await picker.setMapInteractionEnabled(false);
try {
  await showMyNativeSeatPrompt();
} finally {
  await picker.setMapInteractionEnabled(true);
}
```

This makes the map itself inert. Keep a Flutter `IgnorePointer` over your
chrome as well — the map surface can still be hit-tested beneath composited
Flutter widgets — so the tap that opened your prompt cannot select a seat
underneath it.

Do not wrap the map in an app-level drag or scale recogniser, and do not forward
raw touch coordinates to it. Both compete with the map and break its
tap-versus-pan suppression.

## Step 7 — own the back ladder

The drop-in ships a `PopScope` that collapses the sheet, then dismisses the
confirm card, then returns to the overview, before letting your route pop. A
custom layout owns that ladder itself:

```dart
PopScope(
  canPop: false,
  onPopInvokedWithResult: (didPop, _) async {
    if (didPop) return;
    final map = SeatLayerPickerScope.stateOf(context).snapshot?.map;
    if (expanded) {
      setState(() => expanded = false);
    } else if (map?.focusedSectionId != null) {
      await picker.overview();
    } else {
      if (context.mounted) Navigator.of(context).pop();
    }
  },
  child: MyPickerLayout(),
)
```

## Step 8 — close cleanly

Before removing an inline picker you control, await `close()` so a picker-owned
hold is acknowledged as released, then dispose a controller you created:

```dart
await picker.close();
picker.dispose();
```

A hold already handed to your app through `onCheckout` is **not** released —
ownership moved with the handoff. Process termination cannot guarantee a
release, so the server-side TTL remains the final safety boundary.

## Next steps

- [Customise the picker instead](/buyer-sdk/flutter/customise)
- [Understand the architecture](/buyer-sdk/flutter/architecture)
- [Back to the Flutter quick start](/buyer-sdk/flutter)