DEV Community

Cover image for Mastering Responsive UIs in Flutter: The Full Guide
Dario Digregorio
Dario Digregorio

Posted on • Edited on

Mastering Responsive UIs in Flutter: The Full Guide

💡 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

Responsive

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

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.


Enter fullscreen mode Exit fullscreen mode

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:

iPad Responsive

Android Resizable Emulator:

Android 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;
}


Enter fullscreen mode Exit fullscreen mode

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;


Enter fullscreen mode Exit fullscreen mode

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),
        },
      );
  }
}


Enter fullscreen mode Exit fullscreen mode

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),
        },
      );
  }
}


Enter fullscreen mode Exit fullscreen mode

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(),
      );
  });
},


Enter fullscreen mode Exit fullscreen mode

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);
  });
}


Enter fullscreen mode Exit fullscreen mode

Responsive Navigation

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),
//...
)}));


Enter fullscreen mode Exit fullscreen mode

Responsive List View

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:

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 or Expanded 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.

Android Landscape

To modernize the look and eliminate these black bars add following line to your root widget's initState():



   SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);


Enter fullscreen mode Exit fullscreen mode

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> 
// ...


Enter fullscreen mode Exit fullscreen mode

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,
    ));


Enter fullscreen mode Exit fullscreen mode

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.

iOS Landscape

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,
    ]);


Enter fullscreen mode Exit fullscreen mode

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
    });
  });


Enter fullscreen mode Exit fullscreen mode

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 like sizeOf or orientationOf which you should use instead of MediaQuery.of(context).size or MediaQuery.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 or OrientationBuilder 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 :) ❤️

Yawa

If you have any questions or feedback, feel free to reach out on Twitter/X!

Top comments (0)