One set of widgets that feels native on both phones — Material on Android, Cupertino on
iOS — without a single Platform.isIOS check in your own code.
You write PlatformButton once. Android users get a real Material button, iOS users get a real
CupertinoButton, and neither group feels like they wandered into the other platform's app. When a
platform genuinely needs special treatment, there's a typed knob for it — used only when you reach
for one.
- Why you'd want it
- Getting started
- See it live
- Widget catalog
- Customizing per platform
- Compile-time platform pruning, verified
- Coming from flutter_platform_widgets?
- Contributing
- Contributors
- Authors
- Used By
Why you'd want it
Flutter ships both Material and Cupertino widgets, but it leaves the choosing to you. So apps tend to
drift one of two ways: all-Material everywhere (which looks a little off on iOS), or a slowly growing
tangle of if (Platform.isIOS) branches you'll be maintaining for years.
This package makes that call for you, one widget at a time. Each PlatformXxx checks the platform and
builds the right native widget underneath, so your tree stays readable and the branching lives in one
tested place instead of sprinkled across the codebase.
Two things it goes out of its way to avoid:
- Lock-in. The shared parts (your callbacks, values, controllers) sit right on the widget.
Anything platform-specific goes in optional
MaterialXxxData/CupertinoXxxDatarecords you can happily ignore until you actually want to tweak one side. - Dead weight. Release builds leave behind the platform you're not on: no Cupertino code tags along on Android, no Material code on iOS. And that isn't wishful thinking — CI checks it on every PR.
// The usual way: branch by hand, and remember to do it everywhere.
Widget build(BuildContext context) {
if (defaultTargetPlatform == TargetPlatform.iOS) {
return CupertinoButton(onPressed: _save, child: const Text('Save'));
}
return ElevatedButton(onPressed: _save, child: const Text('Save'));
}
// With this package: one widget, the right look on each platform.
PlatformButton(onPressed: _save, child: const Text('Save'));
Getting started
flutter pub add platform_adaptive_widgets
Wrap your app in a PlatformApp and start using PlatformXxx widgets anywhere you'd normally reach
for a Material or Cupertino one:
import 'package:flutter/widgets.dart';
import 'package:platform_adaptive_widgets/platform_adaptive_widgets.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => PlatformApp(
title: 'Adaptive Demo',
home: PlatformScaffold(
appBarData: const PlatformAppBar(title: Text('Adaptive Demo')),
body: Center(
child: PlatformButton(
onPressed: () {},
child: const Text('Tap me'),
),
),
),
);
}
Run that on an Android phone, and it's Material from top to bottom. Run the exact same code on an iPhone and it's Cupertino. That's the whole trick.
Heads up: this one's for Android and iOS. Web and desktop are out of scope on purpose —
APPENDIX.mdhas the reasoning.
See it live
Honestly, the quickest way to get a feel for it is to play with it. The example/ app is
a little interactive catalog: every widget gets its own card, and most come with a built-in
property editor so you can flip isEnabled, drag a slider, or pick a color and watch the widget
react right there on the device. There's also an About tab that swaps the rendered platform and
theme on the fly, which makes it easy to hold Material and Cupertino up next to each other without
ever rebuilding.
| Material (Android) | Cupertino (iOS) |
|---|---|
![]() |
![]() |
cd example
flutter run
It comes with two entry points, depending on how you like to route:
lib/main.dart for plain navigator routing, and
lib/main_go_router.dart for the declarative router.
Widget catalog
Here's everything in the box. Each widget renders the native counterpart listed, and the *Data
column shows what you can pass to tune each side — see
Customizing per platform for how those work.
Dialogs
| Widget / Function | Material | Cupertino | Data Classes |
|---|---|---|---|
showPlatformDatePicker() |
showDatePicker |
CupertinoDatePicker + showCupertinoModalPopup |
MaterialDatePickerData, CupertinoDatePickerData |
showPlatformTimePicker() |
showTimePicker |
CupertinoDatePicker (time mode) + showCupertinoModalPopup |
MaterialTimePickerData, CupertinoDatePickerData |
PlatformMenuPicker<T> |
DropdownMenu + DropdownMenuEntry |
CupertinoMenuAnchor + CupertinoMenuItem (≤5 items) or CupertinoPicker + showCupertinoModalPopup (>5 items) |
MaterialMenuPickerData, CupertinoMenuPickerData |
showPlatformDialog<T>() |
showDialog + Dialog |
showCupertinoDialog + CupertinoPopupSurface |
MaterialDialogData |
showPlatformFullscreenDialog<T>() |
showDialog + Dialog.fullscreen |
showCupertinoDialog + CupertinoPopupSurface (no native iOS fullscreen-dialog concept) |
MaterialFullscreenDialogData |
showPlatformAlertDialog<T>() + PlatformDialogAction |
AlertDialog + TextButton |
CupertinoAlertDialog + CupertinoDialogAction |
MaterialAlertDialogData, CupertinoAlertDialogData |
showPlatformRawDialog<T>() |
showDialog (no surface wrap) |
showCupertinoDialog (no surface wrap) |
— (caller owns the surface) |
showPlatformModalBottomSheet<T>() |
showModalBottomSheet |
showCupertinoModalPopup + CupertinoPopupSurface |
MaterialModalBottomSheetData, CupertinoModalPopupData |
showPlatformRawModalBottomSheet<T>() |
showModalBottomSheet (native Material) |
showCupertinoModalPopup (no surface wrap) |
MaterialModalBottomSheetData, CupertinoModalPopupData |
showPlatformToast() |
SnackBar via ScaffoldMessenger |
Custom HUD-style banner overlay (built in the package — iOS has no native toast) | MaterialToastData, CupertinoToastData |
showPlatformAcknowledge() |
AlertDialog + single OK action |
CupertinoAlertDialog + single OK action |
MaterialAlertDialogData, CupertinoAlertDialogData |
Interaction
| Widget | Material | Cupertino | Data Classes |
|---|---|---|---|
PlatformButton |
TextButton, ElevatedButton, OutlinedButton, FilledButton, FilledButton.tonal (via MaterialButtonVariant) — .icon factories selected by PlatformButton.icon |
CupertinoButton, CupertinoButton.filled, CupertinoButton.tinted (via CupertinoButtonVariant) — PlatformButton.icon wraps the icon + label in a Row |
MaterialButtonData, CupertinoButtonData |
PlatformCheckbox |
Checkbox (.tristate constructor → tristate: true) |
CupertinoCheckbox |
MaterialCheckboxData, CupertinoCheckboxData |
PlatformExpansionTile |
ExpansionTile |
CupertinoExpansionTile |
MaterialExpansionTileData, CupertinoExpansionTileData |
PlatformRadio<T> |
Radio |
CupertinoRadio |
MaterialRadioData, CupertinoRadioData |
PlatformRadioGroupBuilder<T> |
RadioGroup + Wrap (convenience layout) |
same | — (flat params; no data classes) |
PlatformScrollbar |
Scrollbar |
CupertinoScrollbar |
MaterialScrollbarData, CupertinoScrollbarData |
PlatformSearchBar |
SearchBar |
CupertinoSearchTextField |
MaterialSearchBarData, CupertinoSearchBarData |
PlatformSegmentButton<T> |
SegmentedButton + ButtonSegment |
CupertinoSlidingSegmentedControl |
MaterialSegmentButtonData, CupertinoSegmentButtonData<T> |
PlatformSlider |
Slider |
CupertinoSlider |
MaterialSliderData, CupertinoSliderData |
PlatformSwitch |
Switch |
CupertinoSwitch |
MaterialSwitchData, CupertinoSwitchData |
PlatformTextField |
TextField |
CupertinoTextField |
MaterialTextFieldData, CupertinoTextFieldData |
Layout
| Widget | Material | Cupertino | Data Classes |
|---|---|---|---|
PlatformApp / PlatformApp.router |
MaterialApp / MaterialApp.router |
CupertinoApp / CupertinoApp.router |
MaterialAppData, CupertinoAppData (shared config is flat on the widget) |
PlatformAppBar |
AppBar |
CupertinoNavigationBar |
MaterialAppBarData, CupertinoNavigationBarData |
PlatformScaffold |
Scaffold |
CupertinoPageScaffold |
MaterialScaffoldData, CupertinoScaffoldData |
PlatformTabScaffold |
Scaffold + NavigationBar + NavigationDestination |
CupertinoTabScaffold + CupertinoTabBar + CupertinoTabView |
MaterialTabScaffoldData, TabDestination |
Painting
| Widget | Material | Cupertino | Data Classes |
|---|---|---|---|
PlatformListTile |
ListTile |
CupertinoListTile / CupertinoListTile.notched |
MaterialListTileData, CupertinoListTileData |
PlatformProgressIndicator |
CircularProgressIndicator |
CupertinoActivityIndicator |
MaterialProgressIndicatorData, CupertinoProgressIndicatorData |
Utilities — generic platform widgets, theme access, value selectors, extensions, and models
Generic Platform Widgets
| Widget | Description |
|---|---|
PlatformWidget |
Takes materialBuilder and cupertinoBuilder callbacks to render any custom widget per platform. |
PlatformWidgetBuilder |
Same as PlatformWidget but also passes a shared child widget to both builders. |
Platform Theme
PlatformTheme.of(context) — provides unified access to theme properties across platforms:
| Property | Material | Cupertino |
|---|---|---|
barBackgroundColor |
Theme.of(context).appBarTheme.backgroundColor |
CupertinoTheme.of(context).barBackgroundColor |
primaryColor |
Theme.of(context).primaryColor |
CupertinoTheme.of(context).primaryColor |
primaryContrastingColor |
Theme.of(context).colorScheme.onPrimary |
CupertinoTheme.of(context).primaryContrastingColor |
scaffoldBackgroundColor |
Theme.of(context).scaffoldBackgroundColor |
CupertinoTheme.of(context).scaffoldBackgroundColor |
selectionHandleColor |
Theme.of(context).colorScheme.onSurface |
CupertinoTheme.of(context).selectionHandleColor |
Platform value selectors
The value selectors are top-level functions (no BuildContext); platformIcon
is a BuildContext extension. The selectors evaluate the unused-platform arm
too, so its code is not tree-shaken from release builds (empirically
≈342 KB for one Cupertino widget) — prefer an inline switch (defaultTargetPlatform) when an arm builds a platform-specific widget:
| Helper | Description |
|---|---|
platformValue<T>(material:, cupertino:) |
Returns the value matching the current platform. |
platformValueNullable<T>(material:, cupertino:) |
Nullable variant of platformValue. |
platformLazyValue<T>(material:, cupertino:) |
Lazily evaluates only the callback for the current platform. |
platformLazyNullable<T>(material:, cupertino:) |
Nullable variant of platformLazyValue. |
platformIcon(material:, cupertino:) |
BuildContext extension for platform-specific IconData. |
Other Extensions
| Extension | Description |
|---|---|
DateTimeExtensions.toDate() |
Converts DateTime → Date. |
TimeOfDayExtensions.toDateTime() |
Converts TimeOfDay → DateTime. |
Models
| Model | Description |
|---|---|
Date |
An immutable gregorian calendar date (year, month, day) with comparison, arithmetic, and conversion utilities. |
PlatformAdaptiveIcons |
A class that provides adaptive icons based on the current platform. |
Customizing per platform
The mental model is short. Whatever both platforms share — your callbacks, values, controllers, plus
the odd visual that genuinely means the same thing on each side (a tint color, say) — lives right on
the widget, so there's one source of truth and nothing to keep in sync. Whatever is truly
platform-specific lives in optional typed records: hand a MaterialXxxData to the Android branch, a
CupertinoXxxData to the iOS one. For most widgets, most of the time, you'll touch neither — they're
there for the moments you want one platform to behave a little differently.
PlatformButton.icon(
onPressed: _add,
icon: const Icon(Icons.add),
label: const Text('Add'),
materialButtonVariant: .filled, // Android: FilledButton.icon
cupertinoButtonVariant: .filled, // iOS: CupertinoButton.filled
cupertinoButtonData: const CupertinoButtonData(pressedOpacity: 0.6), // iOS-only tuning
)
If you're ever unsure where a given property belongs,
APPENDIX.md spells out the rule the package follows.
Base Classes
All platform widgets extend one of these base classes, which use compile-time defaultTargetPlatform resolution:
| Base Class | Description |
|---|---|
PlatformWidgetBase |
Core abstract StatelessWidget with buildMaterial() and buildCupertino(). |
PlatformWidgetKeyedBase |
Adds an optional widgetKey for the underlying platform widget. |
PlatformWidgetBuilderBase |
Adds a required child widget passed through to the platform builder. |
PlatformWidgetKeyedBuilderBase |
Combines both widgetKey and child. |
Compile-time platform pruning, verified
This is the part people tend not to believe at first: the platform you're not on actually disappears
from your release build. Under AOT, defaultTargetPlatform is a compile-time constant, so on Android
the Cupertino branches are simply dead code that the compiler tree-shakes away — and the same in
reverse on iOS.
Since "trust me" is a weak engineering argument, two CI checks keep it honest on every PR:
- a static AST guard
(
test/aot_pruning_regression_test.dart) that fails the build if anyone reintroduces the closure-style dispatch that quietly defeats pruning, and - a real size benchmark
(
tool/check_size_regression.dart) that compiles an Android-only app with--analyze-sizeand trips if any Cupertino-pathed bytes slip past a calibrated budget.
Want the full mechanism and the actual byte counts? They're in
APPENDIX.md#aot-pruning-rules.
Coming from flutter_platform_widgets?
You'll feel right at home. This package is a spiritual continuation of
flutter_platform_widgets, and the PlatformXxx
naming carries straight over. What's changed is mostly under the hood: per-platform tweaks now go
through typed MaterialXxxData / CupertinoXxxData records (see
Customizing per platform), the scope is intentionally Android + iOS, and
the AOT-pruning promise is checked by CI rather than taken on faith. Skim the
widget catalog to find the equivalent of whatever you're using today.
Contributing
Issues and PRs welcome at
github.com/LahaLuhem/platform_adaptive_widgets. Before sending a
non-trivial change, read CODESTYLE.md for the house style,
.ai/AGENTS.md for the hard rules and contributor / AI-agent
guidelines, and APPENDIX.md for the design rationale.
Optional: AI-agent discovery symlinks
The canonical text for AGENTS.md and CLAUDE.md lives under .ai/. The repo root
holds gitignored symlinks (AGENTS.md → .ai/AGENTS.md,
CLAUDE.md → .ai/CLAUDE.md, example/AGENTS.md → example/.ai/AGENTS.md) so coding
agents that auto-discover root-level guidance files (Claude Code, Codex, Cursor,
Copilot, …) find them without polluting the file tree with two extra Markdown files at
each level. The arrangement is opt-in per contributor:
-
If you use a coding agent, set the symlinks up once from the repo root:
ln -s .ai/AGENTS.md AGENTS.md ln -s .ai/CLAUDE.md CLAUDE.md ln -s .ai/AGENTS.md example/AGENTS.md -
If you don't use one, skip the step entirely. The canonical files under
.ai/are committed; nothing in the build, lint, or test pipeline depends on the symlinks existing. -
If you want different agent guidance for your own workflow, drop a real
AGENTS.mdorCLAUDE.mdat the repo root. A real file beats the symlink convention — your agent reads the root file you put there instead of the canonical one under.ai/. The committed.ai/copies remain the project default for everyone else.
The CODESTYLE.md files are not symlinked — they sit directly at the repo root and at
example/, since style serves humans and agents alike and is not AI-specific. See
APPENDIX.md for the rationale
behind the .ai/ arrangement.
Contributors
Made with contrib.rocks.
Authors
Used By
This project is used by the following companies:
- Didata Automatisering B.V
- Dimerce B.V

