← Journal

ENGINEERING · · 5 min read

Platform Differences Are Features: Lessons from Two Years of Flutter

Two years of shipping Flutter taught us to stop forcing iOS and Android to behave identically and to share code only where it earns its keep.

The first thing a user does in your app is rarely a tap on something you designed. It’s a swipe from the left edge, a thumb to the bottom of the screen, or a press of the system back button. Those actions belong to the platform, and users perform them without thinking.

We’ve been building with Flutter for two years. The biggest improvement in our results came from changing what we asked of the framework. We stopped asking it to make both platforms behave the same, and started asking where sameness helps the user and where it only helps us.

Why “write once” overreaches

Shared code is a good deal for the parts of an app that are about your product: data models, business rules, networking, state, content layout, brand tokens. None of that has an iOS or Android flavor.

Trouble starts when the same goal is applied to behavior the operating system has already taught your users. A person who has used an iPhone for years has muscle memory that your app can either respect or fight. Fighting it never shows up in a crash report. It shows up as a vague sense that the app feels off.

Gestures are a contract

On iOS, swiping from the left edge goes back. If your custom transition or a horizontal carousel swallows that gesture, the app feels broken, and the user can’t say why.

On Android, back is a system-level action, whether it comes from a gesture or a button. It should pop the current route, close the sheet, or dismiss the keyboard before it ever exits the app. Intercepting it for your own purposes breaks the same kind of trust.

Scrolling is another place where this matters. Flutter already picks bouncing physics on iOS and clamping physics on Android. We’ve seen teams override this globally for consistency. We don’t. Scroll physics is something users feel in their hands, and the default is correct.

Navigation patterns differ for a reason

Both platforms support tabs, stacks, and modal flows, but they weight them differently.

  • Top-level destinations: a bottom tab bar is the iOS convention. On Android, a navigation bar or a drawer is common, depending on how many destinations you have.
  • Titles and app bars: iOS leans on large collapsing titles and a back label. Android leans on a top app bar with an up action.
  • Dialogs and sheets: action placement, button styling, and destructive-action treatment all differ, and users read those cues quickly.
  • Text selection and context menus: each platform has its own handles, magnifier, and menu. Rebuilding them is rarely worth it.

You can force one navigation model onto both platforms. Half your users will then be using an app designed for the other half.

A small adaptive seam

The way to keep this manageable is to put the divergence in a thin layer of adaptive widgets. Feature code calls AdaptiveAction and never asks which platform it’s on. Here’s a dialog action that follows platform conventions:

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

bool get _useCupertino =>
    defaultTargetPlatform == TargetPlatform.iOS ||
    defaultTargetPlatform == TargetPlatform.macOS;

class AdaptiveAction extends StatelessWidget {
  const AdaptiveAction({
    super.key,
    required this.label,
    required this.onPressed,
    this.destructive = false,
  });

  final String label;
  final VoidCallback onPressed;
  final bool destructive;

  @override
  Widget build(BuildContext context) {
    if (_useCupertino) {
      return CupertinoDialogAction(
        isDestructiveAction: destructive,
        onPressed: onPressed,
        child: Text(label),
      );
    }
    return TextButton(
      style: destructive
          ? TextButton.styleFrom(
              foregroundColor: Theme.of(context).colorScheme.error,
            )
          : null,
      onPressed: onPressed,
      child: Text(label),
    );
  }
}

Used inside showAdaptiveDialog with AlertDialog.adaptive, a single call site produces the right dialog on each platform. Feature code stays shared, and only the seam knows the difference.

The decision rule

When we’re unsure whether to share or diverge, we ask one question:

Would the user notice this behavior is missing, even if they couldn’t explain what’s wrong?

If yes, follow the platform. That covers back navigation, scroll feel, dialog conventions, text selection, and haptics. The user’s expectation was set before they opened your app.

If no, and the only inconsistency would be between your app and itself, share it. That covers data, logic, content layout, copy, color, type, and spacing. The user’s expectation comes from your product, so one implementation serves it best.

A corollary keeps the seam honest. If a feature needs more than a handful of platform branches, the abstraction is wrong. Either the feature is really two features, or the shared version should be simpler.

Where sharing stops paying off

Shared code earns its keep through the layers that don’t touch hardware or system conventions. It stops paying off at the edges: navigation chrome, system dialogs, permission flows, share sheets, and anything that interacts with platform gestures. Past that point, you spend more effort suppressing platform behavior than you saved by sharing it.

The practical result is that most of the code is shared and a small, deliberate part is not. We accept that on purpose, because it keeps the app feeling native on both platforms without maintaining two codebases.

What we’d tell a team starting out

Audit your app for places where you’ve overridden a platform default. Ask of each one whether it was a product decision or just a convenience. Most of the time it was convenience, and removing it makes the app feel better and the code smaller.

If you’re planning a mobile product and want a second opinion on where to draw the seam, we’re glad to talk it through.