💡 I recommend reading my article either in the GitHub repository or on Medium because there you can also see video examples.
Flutter has become a go-to framework for developing cross-platform apps, but the real challenge lies in making these apps responsive across a variety of devices. This article is all about mastering the world of Flutter to ensure your app looks and works great, whether on mobile, desktop, or web.
I was thrilled to see the community's enthusiastic response and the many insightful questions, when I released my first short guide on Building Responsive UIs. So, I've put together this detailed follow-up to tackle all those burning questions. Whether you're just starting out or looking to improve your UI skills, this guide's got you covered!
REPOSITORY: GitHub Here you will also find high resolution images, animations and gifs, which unfortunately are not supported on Dev.to
If you have any questions, feedback, or suggestions, feel free to reach out to me on X/Twitter.
Side Note: This guide avoids using additional packages. However, I recommend using at least a state management solution like Riverpod or Bloc to manage your app's state effectively. To keep this guide simple we will use the build in API ChangeNotifier
to update the UI.
Table of Contents
- Setup and Key Insights
- Foundation for Responsive Design
- Basic Layout
- Widgets
- Adapting to different devices and platforms
- Debugging
- Testing
- Tips and Tricks
- Conclusion
Introduction
Flutter's versatility allows developers to target a wide range of platforms including iOS, Android, Web, Desktop, and Linux. However, embracing this wide platform support introduces challenges such as:
- Handling different form factors and types: Phones, tablets, foldables, desktops, web interfaces, smartwatches, TVs, cars, smart displays, IoT, AR, VR, etc.
- Addressing notches, cutouts, punch holes.
- Scaling UI and text; ensuring accessibility.
- Supporting both RTL (Right-to-Left) and LTR (Left-to-Right) text directions and different type of fonts.
❓ Do you need to support all these devices?
It's important to understand your app's requirements and the target audience before diving into responsive design. Also consider the trade-offs and the additional effort required to support each platform. But with the right approach and setup you can build apps that look great on any device.
Setup and Key Insights
Before diving into coding, setting up the right device and understanding key concepts are essential.
Recommended Emulators/Simulators or Devices
Choose a device that you can test your app on:
Environment | HotReload | Resizable Window | Text Scaling | UI Scaling |
---|---|---|---|---|
Windows/Mac/Linux | Yes | Yes | Yes | Yes |
Web | No | Yes | Yes | Yes |
Android Emulator | Yes | Experimental (only breakpoints) | Yes | Yes |
iOS Simulator | Yes | No | Yes | Yes |
iPadOS (Stage Manager) | Yes | Limited | Yes | Yes |
MacOS (Designed for iPad) | Yes | Yes | No | No |
iPad Stage Manager:
Android Resizable Emulator:
Your App doesn't support a Platform?
What If Your App Doesn't Support a Platform or you can not build on that platform?
Depending on the platform you are targeting, you might not be able to build or test your app on that platform. For example, if you are developing on Windows, you won't be able to build for iOS.
Some alternatives:
- MacOS (Designed for iPad) or the iPadOS (Stage Manager) are great alternatives to test your iOS app as with a resizable window without building it nativly for MacOS.
- Use a cloud-based service like Codemagic, Bitrise, or GitHub Actions to build and test your app on different platforms.
- Create a version of your app that removes dependencies that are not supported on the platform you are targeting and use that version only to test the responsiveness of your app
Foundation for Responsive Design
Mobile First
Starting your design with mobile in mind makes scaling up to larger screens smoother. This approach helps in efficiently adapting your designs for tablets or desktops. One popular pattern is using a Master Detail interface to adapt your screens, which we will be implementing in this guide.
Screen-based Breakpoints
First things first, we establish our breakpoints. These are key in determining how our app will flex and adapt to different screen sizes. What breakpoints you should use is up to you and your usecase of the app, but here's a common example:
dart
enum ScreenSize {
small(300),
normal(400),
large(600),
extraLarge(1200);
final double size;
const ScreenSize(this.size);
}
ScreenSize getScreenSize(BuildContext context) {
double deviceWidth = MediaQuery.sizeOf(context).shortestSide;
if (deviceWidth > ScreenSize.extraLarge.size) return ScreenSize.extraLarge;
if (deviceWidth > ScreenSize.large.size) return ScreenSize.large;
if (deviceWidth > ScreenSize.normal.size) return ScreenSize.normal;
return ScreenSize.small;
}
Device Segmentation
Categorize devices based on their form factor and type. This will help you understand the different types of devices, you need to support and how your app will adapt to them.
bool get isMobileDevice => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
bool get isDesktopDevice =>
!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux);
bool get isMobileDeviceOrWeb => kIsWeb || isMobileDevice;
bool get isDesktopDeviceOrWeb => kIsWeb || isDesktopDevice;
Style File
Having a style file with your app's colors, fonts, and text styles will help you maintain a consistent look and feel across your app. This will also help you in scaling your UI and text effectively when needed for different touch targets.
Basic Layout
We’ll be enhancing the classic Counter App to showcase responsive design in Flutter. The goal is to manage multiple counters and introduce a master-detail
interface for larger screens. Take a look into the repository to find out how the different topics have been implemented in detail.
Layout Foundation
We create a directory pages
where we place all widgets which define a screen for a mobile device. Since we are using a Master-Detail design, we will define a counters_page.dart
and a counter_detail_page.dart
.
Building the Responsive Layout
Now, onto the layout. We’re keeping things modular and adaptable, using Dart Patterns or more specificly Switch Expressions to easily switch layouts. Here’s how app.dart
looks:
class _CounterAppState extends State<CounterApp> {
// ...
@override
Widget build(BuildContext context) {
final screenSize = getScreenSize(context);
return Scaffold(
bottomNavigationBar: switch (screenSize) {
ScreenSize.normal || ScreenSize.small => const CounterNavigationBar(),
_ => null,
},
body: switch (screenSize) {
ScreenSize.large || ScreenSize.extraLarge => Row(
children: [
const CounterNavigationRail(),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: CountersPage(isPage: false)),
Expanded(child: CounterDetailPage(isPage: false)),
],
),
_ => CountersPage(isPage: true),
},
);
}
}
Here, we’re using different navigation widgets based on the screen size — a NavigationBar for smaller screens and a NavigationRail for larger ones. Unfortunately it is a bit tricky using NavigationRail together with NavigationBar because Scaffold has only an input for a bottomNavigationBar. With the underscore we specify which layout should be shown on default.
Maybe you noticed the isPage
flag. This flag is used for multiple purposes like to decide if the Counter Detail page should be pushed to the Navigation Stack or not.
Adapting to Orientation Changes
What about when users flip their phones to landscape? Using Dart’s Record feature, we can elegantly handle multiple conditions, adapting our layout to both screen size and orientation. You can react to more variables by adding them to a Record.
class _CounterAppState extends State<CounterApp> {
// ...
@override
Widget build(BuildContext context) {
final screenSize = getScreenSize(context);
final orientation = MediaQuery.orientationOf(context);
return Scaffold(
bottomNavigationBar: switch ((screenSize, orientation)) {
(ScreenSize.normal || ScreenSize.small, Orientation.portrait) =>
const CounterNavigationBar(),
(_) => null,
},
body: switch ((screenSize, orientation)) {
(ScreenSize.large || ScreenSize.extraLarge, _) => Row(
children: [
const CounterNavigationRail(),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: CountersPage(isPage: false)),
Expanded(child: CounterDetailPage(isPage: false)),
],
),
(_, Orientation.landscape) => Row(
children: [
const CounterNavigationRail(),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: CountersPage(isPage: true))
],
),
(_) => CountersPage(isPage: true),
},
);
}
}
Dialogs
For a responsive UI, dialogs should adjust to screen size. In our example we want to display a fullscreen dialog on smaller screens and a dialog with dismissable background with constrained maxWidth
on larger screens. We can use a similar approach as we did with the layout to achieve this. Here is how:
onPressed: () {
showDialog(
context: context,
builder: (context) {
if (screenSize == ScreenSize.large || screenSize == ScreenSize.extraLarge) {
return Dialog(
child: ConstrainedBox(
constraints:
BoxConstraints(maxWidth: ScreenSize.normal.size),
child: const AddCounterDialog(),
),
);
}
return const Dialog.fullscreen(
child: AddCounterDialog(),
);
});
},
Responsive Navigation
Consider a user navigating to the Counter Detail Screen on a small device. If the screen is resized to a larger dimension, the Counter Detail Screen remains due to direct navigation. To adapt to a larger layout, the Counter Detail Screen must be removed from the navigation stack when a resize to a bigger ScreenSize
is being detected. This ensures the UI remains responsive to screen resizing. We implement this behavior as follows:
if (screenSize == ScreenSize.large || screenSize == ScreenSize.extraLarge) {
SchedulerBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).popUntil((route) => route.isFirst);
});
}
Wrap the popUntil
method within a SchedulerBinding.instance.addPostFrameCallback
to delay its execution until after the current build cycle to avoiding build method conflicts.
Center ListView with whitespace
On larger screens, constraining the width of scrollable lists and centering them improves aesthetics and usability. Directly wrapping a ListView
with a ConstrainedBox
may restrict scrollable area to the constrained width, excluding the white space. A workaround involves using the padding
parameter of ListView
to dynamically calculate and apply horizontal padding based on screen size:
return Scaffold(body:
LayoutBuilder(builder: (context, constraints) {
double horizontalPadding = constraints.maxWidth > ScreenSize.large.size
? ((constraints.maxWidth - ScreenSize.large.size) / 2)
: Spacing.x3;
return ListView(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding, vertical: Spacing.x3),
//...
)}));
This approach uses again the ScreenSize
enum to remain consistent.
Why do we use LayoutBuilder
instead of MediaQuery
?
Since we are showing the NavigationRail
on larger screens, the MediaQuery
would ignore the constrain from the NavigationRail
and the SafeArea
. So we use LayoutBuilder
to get the actual constraints of the screen.
Read more in this section: Builder vs MediaQuery
Widgets
With our layout in place, it's time to explore the widgets that will enable us to construct a responsive UI. Flutter offers a rich set of widgets which are essential for creating responsive layouts and widgets. I strongly recommend reading documentation provided by the Flutter Team, which presents an extensive array of widgets in various formats. Here are some resources to get you started:
-
Flutter Docs:
- Adaptive Desgin - Layout Widgets
- Widget Catalog - Layout widgets
- Youtube Flutter - Widget of the Week Most of them are also showcased in the Flutter Widget of the Week series.
I will list a few important widgets that you should know when building a responsive app. Some are copied from the Flutter documentation and some are my own recommendations:
Single
-
Align a child within itself. It takes a double value between -1 and 1, for both the vertical and horizontal alignment.
- AspectRatio Attempts to size the child to a specific aspect ratio.
- ConstrainedBox Imposes size constraints on its child, offering control over the minimum or maximum size.
- CustomSingleChildLayout Uses a delegate function to position a single child. The delegate can determine the layout constraints and positioning for the child.
- Expanded and Flexible Allows a child of a Row or Column to shrink or grow to fill any available space.
- FractionallySizedBox Sizes its child to a fraction of the available space.
- LayoutBuilder Builds a widget that can reflow itself based on its parents size.
- SingleChildScrollView Adds scrolling to a single child. Often used with a Row or Column. ### Multi
- Column, Row, Flex Lays out children in a single horizontal or vertical run. Both Column and Row extend the Flex widget.
- CustomMultiChildLayoutUses a delegate function to position multiple children during the layout phase.
- Flow Similar to CustomMultiChildLayout, but more efficient because it’s performed during the paint phase rather than the layout phase.
- ListView, GridView and CustomScrollView Provides scrollable lists of children
- Stack Layers and positions multiple children relative to the edges of the Stack. Functions similarly to position-fixed in CSS.
- Table Uses a classic table layout algorithm for its children, combining multiple rows and columns.
- Wrap Displays its children in multiple horizontal or vertical runs.
Slivers
Slivers are a whole different topic, which I won't cover in this guide. But they are essential for building complex and responsive scrollable layouts. I will cover them in the future more extensively. Here are some important Sliver Widgets:
- CustomScrollView A ScrollView that creates custom scroll effects using slivers.
- NestedScrollView A ScrollView that creates custom scroll effects using slivers, with a flexible header and body.
- SliverCrossAxisGroup A sliver that lays out multiple box children in a cross-axis group.
- SliverFillRemaining A sliver that fills the remaining space in the viewport.
- SliverFillViewport A sliver that fills the viewport with a single box child, regardless of the child's dimensions.
- SliverPadding A sliver that adds padding to its sliver child.
- sliver_tools A package that provides a set of sliver tools that Flutter currently lacks.
Extras
- SafeArea Creates a widget that avoids system intrusions.
- Spacer A widget that takes up space proportional to the space between its siblings.
- SizedBox A box with a specified size. I often use it to add space between widgets.
and many more...
Responsive Text
Text are often the cause for overflowing Layouts especially when using small devices. Here are some tips to make your text responsive:
- Use the
overflow
property of the Text widget to handle overflow. - Wrap your Text widget with a
Flexible
orExpanded
widget to make it responsive. - Set the
softWrap
property to false to prevent the text from wrapping - Keep in mind that many OSs have accessibility features to scale the font size dynamically so Text can easily overflow
Adapting to different devices and platforms
Building apps with Flutter allows you to use a single codebase for multiple platforms. However, adapting to the nuances of different devices and operating systems is crucial for a polished user experience. Here are some tips to ensure your app looks the same on all platforms.
Notches and OS System Interfaces
Flutter's support extends to a variety of platforms, each with its own handling of notches and system interfaces. The key differences lie between Android and iOS, which we'll explore below.
Android
By default, Flutter apps on Android display a black background behind the navigation pill, and when in landscape mode, a black bar appears if the phone has a notch.
To modernize the look and eliminate these black bars add following line to your root widget's initState()
:
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
This adjustment extends ListView
and bottomNavigationBar
to fill the screen, including behind the navigation pill. Avoid using SafeArea if you aim for a fully expanded view.
To address the landscape mode's black bar behind the notch, modify your Android project's main styles.xml file:
// ...
<resources>
<style>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
// ...
To ensure system interfaces do not obstruct your app's UI on older devices, add:
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
));
With these tweaks, your app will offer an uninterrupted UI experience on Android devices.
iOS
iOS devices generally handle notches and system interfaces out of the box, requiring no specific alterations as mentioned for Android. However, when using iOS in landscape mode, note that padding for the notch is symmetrically applied to both sides of the screen, a design requirement by iOS that can't be easily overridden.
Orientation
How Orientation gets retrieved
The orientation of the device can be determined using MediaQuery.orientationOf(context)
. On mobile devices equipped with a gyroscope, orientation is directly obtained. In contrast, for desktops or web platforms lacking a gyroscope, orientation is inferred from the screen dimensions: Orientation.portrait
if the height exceeds the width, and Orientation.landscape
otherwise.
Device Orientation
To lock or set specific orientations for your app, include the following in your root widget's initState
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
For Apple devices, additional configuration in Xcode is required. Navigate to your project's settings under Project Runner > Target Runner > Deployment Info and select the appropriate orientation boxes.
Debugging
To effectively debug your layouts, use the Widget Inspector in DevTools. This tool allows you to thoroughly inspect each widget and its properties, understanding how it adjusts within your layout. Here are a few key features:
Select Widget Mode: This feature enables you to interact directly with your app's UI. Simply click on a widget in the UI, and it will be highlighted in the Inspector and the UI. It also jumps to the code, where the widget is implemented. This immediate feedback loop is invaluable for pinpointing how specific widgets are rendered and behave in your layout.
Layout Explorer: For an in-depth analysis, the Layout Explorer reveals how the selected widget fits into your layout. It's especially useful for visualizing and tweaking widget properties on the fly.
Widget Details Tree: Dive deeper into the widget's structure with the Widget Details Tree. This view exposes the internal composition of a widget, including the widgets it utilizes internally and their respective properties.
Flex Properties: When selecting a Flex widget (such as a Row or Column), you have the ability to manipulate its Flex Properties and of the children. This feature lets you experiment with different property values to see real-time changes and understand how the widgets adapts.
Show Guidelines: Visualize the overall layout structure of your app with the Show Guidelines Button. It helps identify how widgets align with each other and where Scrollable widgets are, providing a clearer understanding of the layout's architecture.
By integrating these tools and features into your development workflow, you can significantly enhance the debugging process of your Flutter layouts.
Testing
Ensuring your app delivers a consistent user experience across different screen sizes is essential. You can achieve this by conducting tests for various screen dimensions. Here’s how you can do it:
dart
group('Test Responsive', () {
testWidgets('should have only CountersPage', (WidgetTester tester) async {
tester.view.devicePixelRatio = 1.0; // Not necessary but makes it easier to use the same values from our ScreenSizes
tester.view.physicalSize =
const Size(500, 800); // to test layout on smaller devices
await tester.pumpWidget(const App());
expect(find.byType(CountersPage), findsOneWidget);
expect(find.byType(CounterDetailPage), findsNothing);
tester.view.reset(); // Don't forget to reset view
});
testWidgets('should have CountersPage and Detail',
(WidgetTester tester) async {
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize =
const Size(800, 1200); // to test layout on larger devices
await tester.pumpWidget(const App());
expect(find.byType(CountersPage), findsOneWidget);
expect(find.byType(CounterDetailPage), findsOneWidget);
tester.view.reset(); // Don't forget to reset view
});
});
By default, Flutter Widget Test assumes a physicalSize of Size(2400.0, 1800.0)
and a devicePixelRatio of 3.0, calculating the actual display size as physicalSize / devicePixelRatio. To test layouts under different conditions, we manually adjust these values and ensure we reset the test view after each test case to avoid unintended carry-over effects. For more details on managing test window settings, refer to this Flutter issue.
Tips and Tricks
Here are some tips, tricks, and misconceptions that you should keep in mind when building responsive apps.
Adaptive vs Responsive (Copied from Flutter Docs)
Responsive
Typically, a responsive app has had its layout tuned for the available screen size. Often this means (for example), re-laying out the UI if the user resizes the window, or changes the device’s orientation. This is especially necessary when the same app can run on a variety of devices, from a watch, phone, tablet, to a laptop or desktop computer.
Adaptive
Adapting an app to run on different device types, such as mobile and desktop, requires dealing with mouse and keyboard input, as well as touch input. It also means there are different expectations about the app’s visual density, how component selection works (cascading menus vs bottom sheets, for example), using platform-specific features (such as top-level windows), and more.
There is a great article how Google achieved this with Google Earth from Craig Labenz about adaptive Design: How Google Earth supports every use case on earth.
Helpful Libraries
Here are some libraries that can help you build responsive apps. I couldn't try them out yet extensively, but they look promising:
- flutter_adaptive_scaffold: A Flutter package that provides adaptive/responsive scaffold widgets for different platforms.
- device_preview: A Flutter package that allows you to preview your app on different devices and test your responsive UI.
- wolt_modal_sheet: A Flutter package that provides a responsive modal sheet widget.
Use a State Management Library
Using a state management library like Riverpod, Provider, or Bloc will help you manage your app's state effectively when the app is being resized. This will help you in managing your app's state across different screen sizes and devices. It will also help you in testing your app.
Design a Prototype first
Using Tools like Figma can help you in designing your app for different screen sizes and devices. It will also help you in understanding how your app will look on different devices. Iterating on your design will help you and save you a lot of time.
Hardcoded Sizes and Values
Avoid relying on hardcoded sizes and values within your app, as they frequently lead to UI overflow issues. If the use of hardcoded values is unavoidable, ensure that your widgets, such as Text
widgets, can overflow rightfully. Refer to the dedicated section in this guide for more details on managing overflow with Text
widgets.
Additionally, consider utilizing ConstrainedBox to introduce a degree of flexibility to your widgets. This approach allows you to set minimum and maximum constraints, providing your layout with the adaptability it needs to accommodate different screen sizes and orientations without compromising on design integrity.
Builder vs MediaQuery
⚠️Warning MediaQuery
should be used with caution:
-
MediaQuery
comes with different methods likesizeOf
ororientationOf
which you should use instead ofMediaQuery.of(context).size
orMediaQuery.of(context).orientation
. The reason is you only want rebuilds whenever that specific property changes. - Using
MediaQuery
can have unwanted rebuilds. So make sure to only use it in the very top of your Widget Tree to define the whole Layout. -
MediaQuery
ignores paddings, SafeArea and other constraints because you get the size of the app window itself - For child widgets you should use
LayoutBuilder
orOrientationBuilder
to get the actual constraints for the widget
Why not just use LayoutBuilder
and OrientationBuilder
?
Using LayoutBuilder
and OrientationBuilder
can sometimes get a bit hacky to use especially when using them in combination for complex layouts. For that reason I prefer to use MediaQuery
. But you could use LayoutBuilder
and OrientationBuilder
to get the same results.
Conclusion
Our app now dynamically responds to every screen size. The beauty of Flutter is that it provides all the tools necessary for responsive design across any device.
Keen to see these methods in action? Check out my app, Yawa: Weather & Radar, on Android and iOS and if you like my App Yawa, please leave a review. This would help me a lot :) ❤️
If you have any questions or feedback, feel free to reach out on Twitter/X!
Top comments (0)