Dart Tutorial

Flutter Lesson 61 of 83 5 min read

Adaptive Apps in Flutter: Android, iOS, Web and Desktop

Make a Flutter app feel native on each platform: detect the platform, use adaptive widgets, support mouse and keyboard, and handle web.

On this page

Responsive means fitting the screen size. Adaptive means fitting the platform: its conventions, its input devices and its abilities. An app can run everywhere and still feel foreign if it ignores them.

Detecting the platform #

import 'package:flutter/foundation.dart';

bool get isWeb => kIsWeb;

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

bool get isDesktop => switch (defaultTargetPlatform) {
      TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => true,
      _ => false,
    };

bool get isMobile =>
    defaultTargetPlatform == TargetPlatform.android ||
    defaultTargetPlatform == TargetPlatform.iOS;

defaultTargetPlatform and kIsWeb work on every platform, including web. Avoid Platform.isAndroid from dart:io, which makes a web build fail.

Note that on the web defaultTargetPlatform reports the device’s operating system, so “is this a desktop?” may need !kIsWeb as well, depending on what you are deciding.

Inside widgets, Theme.of(context).platform gives the same value and can be overridden in tests.

Adaptive widgets #

Several Material widgets have an .adaptive constructor that draws the iOS style on Apple platforms.

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: AdaptiveDemo()));

class AdaptiveDemo extends StatefulWidget {
  const AdaptiveDemo({super.key});

  @override
  State<AdaptiveDemo> createState() => _AdaptiveDemoState();
}

class _AdaptiveDemoState extends State<AdaptiveDemo> {
  bool _on = true;
  double _value = 0.5;

  Future<void> _confirm() async {
    await showAdaptiveDialog<void>(
      context: context,
      builder: (context) => AlertDialog.adaptive(
        title: const Text('Delete this item?'),
        content: const Text('This cannot be undone.'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
          TextButton(onPressed: () => Navigator.pop(context), child: const Text('Delete')),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Adaptive widgets')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          SwitchListTile.adaptive(
            title: const Text('Notifications'),
            value: _on,
            onChanged: (v) => setState(() => _on = v),
          ),
          Slider.adaptive(value: _value, onChanged: (v) => setState(() => _value = v)),
          const SizedBox(height: 16),
          const Center(child: CircularProgressIndicator.adaptive()),
          const SizedBox(height: 16),
          FilledButton(onPressed: _confirm, child: const Text('Show dialog')),
        ],
      ),
    );
  }
}
Adaptive constructorOn iOS it draws
Switch.adaptive, SwitchListTile.adaptiveAn iOS switch
Slider.adaptiveAn iOS slider
CircularProgressIndicator.adaptiveThe iOS activity spinner
AlertDialog.adaptive, with showAdaptiveDialogAn iOS alert
Icon(Icons.adaptive.share)The platform’s share, back and more icons

Platform conventions worth following #

AndroidiOS
BackSystem back button or gestureSwipe from the left edge, and a back button at the top-left
App bar titleLeft-alignedCentred
ScrollingStops with a glow or stretchBounces
Page transitionFade and slide upSlide in from the right
Date pickerCalendarWheels

Flutter handles the first four for you when you use MaterialApp and MaterialPageRoute. Do not override them without a reason.

Fully Cupertino screens #

For an app that should look entirely like iOS, the cupertino library has a complete widget set: CupertinoApp, CupertinoPageScaffold, CupertinoNavigationBar, CupertinoButton, CupertinoTextField, CupertinoPicker. Most cross-platform apps use Material with a custom theme and the adaptive constructors, which is far less work than maintaining two interfaces.

Mouse and keyboard #

Desktop and web users have a pointer and a keyboard. Material widgets support hover, focus and keyboard activation already. For your own widgets:

class HoverCard extends StatefulWidget {
  const HoverCard({super.key, required this.child, required this.onTap});

  final Widget child;
  final VoidCallback onTap;

  @override
  State<HoverCard> createState() => _HoverCardState();
}

class _HoverCardState extends State<HoverCard> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      cursor: SystemMouseCursors.click,
      onEnter: (_) => setState(() => _hovered = true),
      onExit: (_) => setState(() => _hovered = false),
      child: GestureDetector(
        onTap: widget.onTap,
        child: AnimatedScale(
          scale: _hovered ? 1.03 : 1,
          duration: const Duration(milliseconds: 120),
          child: widget.child,
        ),
      ),
    );
  }
}

InkWell gives hover and focus highlights for free, so prefer it where it fits.

Keyboard shortcuts:

CallbackShortcuts(
  bindings: {
    const SingleActivator(LogicalKeyboardKey.keyS, control: true): _save,
    const SingleActivator(LogicalKeyboardKey.escape): _close,
  },
  child: Focus(autofocus: true, child: const EditorView()),
)

LogicalKeyboardKey comes from package:flutter/services.dart. Also make sure Tab moves through your form fields in a sensible order.

Other desktop and web expectations #

  • Tooltips on icon buttons, since there is hover.
  • Scrollbars, which Flutter shows automatically on desktop.
  • Right-click menus where they make sense (MenuAnchor).
  • Denser layouts. A mouse is more precise than a finger, so set visualDensity: VisualDensity.compact for desktop.
  • Resizable windows, which means truly responsive layouts.
  • Selectable text. Wrap content in SelectionArea.

Features that do not exist everywhere #

Not every plugin supports every platform. The camera plugin has no Linux support, and dart:io files do not exist on the web. Check each package’s platform list on pub.dev, and guard the feature:

if (!kIsWeb && isMobile)
  IconButton(icon: const Icon(Icons.camera_alt), onPressed: _takePhoto),

For larger differences, hide the choice behind an interface with one implementation per platform, so the rest of the app does not care which is in use.

Web specifics #

  • Use go_router so the address bar, back button and bookmarks work.
  • The first load is larger than a typical web page. Show something quickly, and keep the app’s assets small.
  • Flutter web is best for applications. For pages that must rank in search engines, plain HTML is the better tool.
  • Browsers block requests to servers that do not allow them (CORS). This is a server setting, not a Flutter bug.

Try it yourself #

Take a settings screen and make it adaptive: use adaptive switches and dialogs, show a hover effect and tooltips on desktop, add a keyboard shortcut for “save”, and hide a “take photo” option on platforms where the camera is not available.

Practise in the playground Updated by Santosh Adhikari