omni_pixel 0.2.1
omni_pixel: ^0.2.1 copied to clipboard
Figma-first responsive sizing for Flutter. Register the device frames your designers use, then write 48.opx to get pixel-perfect scaling on any screen.
omni_pixel #
Figma-first responsive sizing for Flutter. Register the device frames your designers use in Figma, then write the raw Figma numbers in your code — 48.opx — and get pixel-perfect scaling on every screen.
The idea #
Designers spec a screen on a concrete frame (say, an iPhone 14 at 390 × 844). Developers then eyeball those values onto dozens of real devices. omni_pixel closes that gap: you tell it which frames the design was made on, and .opx converts every design value to the current device using the longest side of the screen:
value.opx = value × (screen.longestSide / designDevice.longestSide) × multiplier
Using the longest side means portrait and landscape produce the same .opx value, and tall or short screens scale from the axis that actually differs the most between devices. The multiplier is an optional correction you define per aspect-ratio range and platform.
Quick start #
import 'package:flutter/material.dart';
import 'package:omni_pixel/omni_pixel.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// OmniBuilder in MaterialApp.builder covers every route.
builder: (context, child) => OmniBuilder(
devices: const [
OmniDevices.iphone13And14, // 390 x 844 — the frame in Figma
OmniDevices.androidCompact, // 412 x 917
OmniDevices.ipadPro11, // 834 x 1194 — the tablet frame
],
multipliers: const [
OmniMultiplier.above(2.2, 0.97, platform: OmniPlatform.android),
OmniMultiplier.below(1.6, 1.05), // squarish screens, any platform
],
child: child!,
),
home: const HomePage(),
);
}
}
Then anywhere in the app, write the numbers exactly as they appear in Figma:
Container(
width: 343.opx,
height: 220.opx,
padding: EdgeInsets.all(20.opx),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16.opx)),
child: SizedBox(height: 48.opx, child: FilledButton(...)), // 48 px in Figma
)
How the design device is chosen #
You can register several frames (a phone frame and a tablet frame, for example). On every rebuild OmniPixel picks the best one for the current screen:
- Devices whose
platformmatches the running platform are preferred. If none match, platform-agnostic devices (platform: null) are used; failing that, all devices are considered. - Within that pool, the device with the aspect ratio closest to the screen's wins.
- Ties are broken by the closest longest side.
So on an iPad your iPad frame drives the scaling, and on a phone your phone frame does — automatically. Force a specific frame with OmniBuilder(designDeviceOverride: ...) when you want to opt out of selection.
OmniDevices ships presets for the full Figma frame panel (iPhone SE through 17, Android Compact/Medium/Expanded, iPads, Surfaces, MacBooks, Desktop, TV, Apple Watches — plus the legacy presets), and any custom OmniDevice(name:, width:, height:, platform:) works.
Multipliers #
Multipliers let you tune scaling for families of screens instead of hardcoding if/else chains:
multipliers: const [
// Rules are matched against the orientation-independent aspect ratio
// (longestSide / shortestSide, always >= 1).
OmniMultiplier.between(2.04, 2.16, 1.02), // any platform
OmniMultiplier.above(2.16, 1.05, platform: OmniPlatform.android), // tall Androids
OmniMultiplier.below(1.6, 0.95, platform: OmniPlatform.ios), // iPads
]
Rules are min-inclusive and max-exclusive, so adjacent ranges never double-match at a boundary. Platform-specific rules take precedence over platform-agnostic ones; within each group the first match in list order wins; if nothing matches, the multiplier is 1.0. The resolved multiplier applies to .opx, .h and .w (use .dh/.dw when you want raw percentages untouched by your rules).
Device-type layouts #
Scaling handles the sizes; sometimes you want an entirely different layout per device class. OmniResponsiveBuilder picks one of four widgets by the current screen width:
OmniResponsiveBuilder(
mobile: MobileLayout(), // required — also the final fallback
tablet: TabletLayout(), // optional
desktop: DesktopLayout(), // optional
watch: WatchLayout(), // optional
)
Missing variants fall back toward mobile: watch → mobile, tablet → mobile, desktop → tablet → mobile. So providing only mobile and desktop gives phones/tablets/watches the mobile layout and desktops the desktop one.
The width thresholds are defined by OmniBreakpoints:
| width | class |
|---|---|
< 300 |
OmniDeviceType.watch |
300 – 599 |
OmniDeviceType.mobile |
600 – 1023 |
OmniDeviceType.tablet |
>= 1024 |
OmniDeviceType.desktop |
Bounds are min-inclusive and max-exclusive, like multipliers — a width of exactly 600 is a tablet. Customize them per widget or globally:
// Per widget:
OmniResponsiveBuilder(
breakpoints: const OmniBreakpoints(maxWatch: 250, maxMobile: 700, maxTablet: 1200),
mobile: ..., tablet: ..., desktop: ..., watch: ...,
)
// Globally, next to your devices (per-widget breakpoints still win):
OmniBuilder(
devices: const [...],
breakpoints: const OmniBreakpoints(maxMobile: 700),
child: child!,
)
Resolution order: the widget's breakpoints → the global ones from OmniBuilder / OmniPixel.configure → OmniBreakpoints.defaults. If you use OmniBuilder, set global breakpoints there — it re-initializes OmniPixel on every metrics change, overwriting values set manually via configure.
Two things to know:
- Classification is by width, so a phone rotated to landscape (e.g. 844 × 390) can classify as
tablet. That's intentional — layout follows the available horizontal space — and it's the opposite trade-off from.opx, which is deliberately orientation-independent. OmniResponsiveBuilderreadsMediaQuerydirectly and works with or withoutOmniBuilderinstalled.
For orientation-specific layouts there's OmniOrientationBuilder:
OmniOrientationBuilder(
portrait: (context) => PortraitLayout(),
landscape: (context) => LandscapeLayout(),
)
Unlike Flutter's OrientationBuilder, which infers orientation from the parent's constraints, it uses the screen orientation — so it always agrees with OmniPixel.instance.isPortrait, even inside a constrained parent (an exactly square screen counts as portrait). The Omni prefix also keeps it from shadowing Flutter's widget in your imports.
All helpers #
| Helper | Result |
|---|---|
48.opx |
48 Figma px → device px (scale × multiplier) |
10.h |
10% of screen height × multiplier |
10.w |
10% of screen width × multiplier |
10.dh |
10% of screen height (raw) |
10.dw |
10% of screen width (raw) |
10.ls |
10% of the longest side (raw, orientation-stable) |
10.ss |
10% of the shortest side (raw, orientation-stable) |
Globals for parity with classic sizer packages: deviceWidth, deviceHeight, deviceAspectRatio (longest / shortest).
Introspection for debug overlays: OmniPixel.instance.designDevice, .scale, .multiplier, .platform, .aspectRatio, .isPortrait, .deviceType (with .isWatch / .isMobile / .isTablet / .isDesktop), and OmniPixel.instance.debugDescription().
Without OmniBuilder #
OmniBuilder is just a thin wrapper. You can drive the engine yourself:
OmniPixel.init(context, devices: const [OmniDevices.iphone13And14]); // needs MediaQuery
OmniPixel.update(context); // re-run with the stored configuration
Testing your layouts #
OmniPixel.configure needs no BuildContext, which makes unit tests trivial:
OmniPixel.configure(
size: const Size(430, 932),
devices: const [OmniDevices.iphone13And14],
);
expect(48.opx, closeTo(48 * 932 / 844, 1e-9));
OmniPixel.debugPlatformOverride forces a platform, and OmniPixel.reset() returns to the uninitialized state between tests. See test/omni_pixel_test.dart for full examples, including widget tests that resize tester.view.
Notes and gotchas #
- Fonts:
fontSize: 16.opxworks, and Flutter's user text scaling (TextScaler) still applies on top of it — that's usually what you want for accessibility. - Web and desktop: platform detection uses
kIsWeb+defaultTargetPlatform, neverdart:io, so the package runs on all Flutter targets. Window resizes and split-screen changes re-triggerOmniBuilderautomatically, and it force-rebuilds its subtree so.opxvalues used anywhere below it re-render with the new metrics. - Name clashes: other sizer packages also define
.h/.w. Hide one side's extension withimport ... hide OmniPixelNum;(or hide theirs) in files that import both. - Fractional pixels:
.opxreturns unrounded doubles; Flutter handles sub-pixel values fine. Clamp when you need bounds:48.opx.clamp(44.0, 56.0).