skip to content

In a Flutter app, which route choices disable the iOS edge swipe-back gesture, and how do you keep it alongside a custom transition?

level: middleimportance: must knowfreq 50%

answer

  1. Flutter draws the swipe itself
  2. detector inside the Cupertino transition
  3. PageRouteBuilder replaces that transition
  4. fullscreenDialog and canPop false
  5. custom look on Android only

basics

~20 s

The swipe is a detector inside Flutter's Cupertino page transition, so PageRouteBuilder or CustomTransitionPage routes lack it. It is also off for fullscreenDialog routes, the first route, a route with PopScope canPop false, and while a transition runs.

solid answer

~40 s

iOS swipe-back in Flutter is not the native navigation controller's gesture; Flutter draws it. `CupertinoRouteTransitionMixin.buildPageTransitions` wraps a non-dialog page in a private back-gesture detector on a leading-edge strip, 20 logical pixels or the safe-area inset, whichever is wider. `CupertinoPageRoute`, `CupertinoPage` and a `MaterialPageRoute` whose theme uses `CupertinoPageTransitionsBuilder` for iOS (the default) all get it. It disappears when a route builds its own transition (`PageRouteBuilder`, go_router's `CustomTransitionPage`), when the theme maps iOS to another builder, and when `popGestureEnabled` is false: the first route, `fullscreenDialog: true`, a `PopScope` with `canPop: false`, or a transition still running. To keep it, put the custom animation only on Android and leave iOS on the Cupertino builder; custom visuals with a swipe mean writing your own drag handling.

code

dart · 20 lines
dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

Route<void> chatRoute(Widget chat) {
  if (defaultTargetPlatform == TargetPlatform.iOS) {
    // Keeps the edge swipe-back detector.
    return CupertinoPageRoute<void>(builder: (context) => chat);
  }
  return PageRouteBuilder<void>(
    pageBuilder: (context, animation, secondaryAnimation) => chat,
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      final Animation<Offset> offset = Tween<Offset>(
        begin: const Offset(0, 1),
        end: Offset.zero,
      ).animate(animation);
      return SlideTransition(position: offset, child: child);
    },
  );
}

go deeper

for a junior

Recall that CupertinoPageRoute and MaterialPageRoute on iOS support the swipe while a PageRouteBuilder does not.

for a middle

Explain that the detector lives in the Cupertino transition and list the popGestureEnabled checks: first route, fullscreenDialog, PopScope, running animation.

for a senior

Diagnose a lost swipe after a transition change and fix it with a per-platform theme or route choice instead of a hand-rolled gesture.

for a principal

Decide when brand motion is worth breaking a platform convention, and set a team rule for which routes may opt out.

## Where the gesture comes from On iOS, native apps get the edge swipe from the platform's navigation controller. A Flutter app does not use that controller: Flutter **draws every pixel and handles every gesture itself**, so the swipe-back is a Flutter widget. It lives in `CupertinoRouteTransitionMixin.buildPageTransitions`, which wraps a normal (non-dialog) page in two things: - a **`CupertinoPageTransition`** — the horizontal slide with a parallax shift of the page underneath; - a private **back-gesture detector** — a transparent strip on the leading edge, **20 logical pixels wide or the device's left safe-area inset, whichever is larger** (the right edge in right-to-left locales). A drag that starts there drives the route's own animation controller backwards, and releasing past the threshold or with enough velocity pops the route. Three route types reach that code: 1. **`CupertinoPageRoute`** and its page-API twin **`CupertinoPage`**, which mix in `CupertinoRouteTransitionMixin` directly. 2. **`MaterialPageRoute`** or `MaterialPage` on iOS, because the default `PageTransitionsTheme` maps `TargetPlatform.iOS` to `CupertinoPageTransitionsBuilder`, which calls the same method. 3. Anything else that calls `CupertinoRouteTransitionMixin.buildPageTransitions` from its own `buildTransitions`. ## What a swipe actually does 1. A pointer lands in the edge strip; the detector asks the route's `popGestureEnabled` and ignores the pointer if it is false. 2. When a horizontal drag starts, a gesture controller calls the navigator's `didStartUserGesture()`, so `popGestureInProgress` becomes true and the Cupertino transition switches to a linear curve that tracks the finger. 3. Each drag update lowers the route's controller value by the drag distance as a fraction of the screen width. 4. On release, a fast enough fling decides the direction; otherwise the route pops if it was dragged past the halfway point and snaps back if not. The navigator's `didStopUserGesture()` runs when that settling animation ends. ## What switches it off | Cause | Why | |---|---| | `PageRouteBuilder` with a `transitionsBuilder` | your callback replaces the Cupertino transition, detector included | | go_router `CustomTransitionPage` | it builds a plain `PageRoute` from your `transitionsBuilder` | | theme maps iOS to another builder | the Cupertino builder is never called | | `fullscreenDialog: true` | `PageRoute.popGestureEnabled` returns false and the dialog transition has no detector | | first route on the stack | `popGestureEnabled` is false when there is nothing to go back to | | a `PopScope` with `canPop: false` | the route's pop disposition becomes `doNotPop` | | a transition still running | the gesture only starts once the route's animation has completed | The last four are checks on **`ModalRoute.popGestureEnabled`**; the detector asks it on every pointer-down. The first three remove the detector entirely. ## A dating-app regression A team switches its chat screen from `MaterialPageRoute` to a `PageRouteBuilder` so the conversation slides up from the match list. Android testers approve it. iOS users report that the swipe back from the chat no longer works and that the screen feels trapped. Nothing is wrong with the gesture system: the new route never built the detector. Wrapping the route in a `GestureDetector` does not help either, because the detector's job is to drive the **route's animation controller** frame by frame, which a plain gesture callback cannot reach from outside the route. ## Keeping swipe-back with a custom look - **Custom on Android, native on iOS** — the usual answer. Put the slide-up in a `PageTransitionsBuilder` subclass mapped to `TargetPlatform.android` in `ThemeData.pageTransitionsTheme`, keep `TargetPlatform.iOS: CupertinoPageTransitionsBuilder()`, and push ordinary `MaterialPageRoute`s. iOS users get the slide and the swipe they expect. - **Per route** — choose the route by platform at the push site: `CupertinoPageRoute` on iOS, your `PageRouteBuilder` elsewhere. - **Truly custom visuals plus a swipe** — subclass `PageRoute` and drive its protected `controller` from your own horizontal drag recogniser, bracketing the drag with the navigator's `didStartUserGesture()` and `didStopUserGesture()`. It works, but you now own edge detection, velocity thresholds and cancellation, which the Cupertino code already gets right. ## Diagnosing it quickly 1. Check what the pushed route is: `PageRouteBuilder`, `CustomTransitionPage` or a Cupertino/Material route. 2. If it is a `MaterialPageRoute`, check `ThemeData.pageTransitionsTheme` for an iOS entry that is not `CupertinoPageTransitionsBuilder`. 3. Check the route's flags and guards: `fullscreenDialog`, any `PopScope` with `canPop: false`, and whether it is the first route. A design choice is worth making explicitly: a `fullscreenDialog` route such as a new-message composer is meant to be dismissed by its close button, so losing the swipe there is intended platform behaviour, not a bug.

  • Why does wrapping a PageRouteBuilder's page in a GestureDetector that calls Navigator.pop not reproduce swipe-back?
    The native-feeling swipe tracks the finger: the Cupertino detector drives the route's animation controller in step with the drag and decides on release whether to finish or snap back. A `GestureDetector` calling `pop` can only trigger the full reverse animation once, with no tracking and no cancel.
  • Does the Android back button still close a fullscreenDialog route?
    Yes. `fullscreenDialog` only makes `popGestureEnabled` false, which turns off the iOS swipe and the Android predictive-back preview. A system back still asks the Navigator to pop the top route, and the route pops normally unless a `PopScope` blocks it.
  • Why is the gesture ignored while a push animation is still running?
    `popGestureEnabled` returns false until the route's animation has completed, because the gesture needs to take over that same controller. A swipe that starts mid-push is simply not picked up; the user can swipe once the page has settled.

saying these in an interview costs you the question

  • Flutter gets iOS swipe-back from UINavigationController automatically.
  • Any route with a slide animation supports swipe-back on iOS.
  • fullscreenDialog only changes the app bar icon, not gestures.
  • Wrapping the page in a GestureDetector restores the native swipe.
  • Swipe-back works from anywhere on the screen, not just the edge.