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 navigating the world of Flutter to ensure your app looks and works great, whether on mobile, desktop, or web.
Side Note: This guide sticks to the basics of Flutter for responsiveness, avoiding additional packages.
The Tools I’m Using: For this, I’m working with Dart 3.1.3, Flutter 3.13.9 and optional Riverpod 2.4.5.
Setting Up and Key Insights
Testing your layouts on different devices and platforms is crucial. Choose a development environment that allows easy resizing. Each platform has its nuances, so let’s explore them:
Understanding Platform and Device Differences
Windows/MacOS/Web: These are the easiest for development. They offer flexible window resizing and support HotReload. However, Flutter Web doesn’t support HotReload. Also, MediaQuery.orientationOf()
here is based on window size, not device orientation.
MacOS Í(Designed for iPad): Optimize your app for both iOS and MacOS with the ‘My Mac (Designed for iPad)’ target in Xcode. This special build allows you to create an iOS-optimized app that functions seamlessly on MacOS. It behaves like a native MacOS app while maintaining compatibility with libraries used in the iOS version. This approach offers a unified development process for both platforms, ensuring that your app delivers a consistent experience across Apple devices. There is still some work to set it up properly. See this issue.
iPad with Stage Manager: If your dependencies limit your platform choice, an iPad with Stage Manager is a good option. It allows resizing within predefined screen sizes.
Android: The Android Resizable Emulator, still in its experimental phase, lets you switch between phone, foldable, or tablet modes. It’s a bit buggy, but useful. Android’s split-screen feature is also worth exploring.
iOS: iOS is more restrictive, lacking a split-screen feature and offering limited screen sizes. The presence of the notch on modern iPhones adds to the responsiveness challenge, especially with the behavior of SafeArea
. iOS adds an additional Padding to the other side of the Notch. Read more in this issue.
Mobile-First Approach
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.
Fonts
Pay attention to font sizes and language directions (LTR vs. RTL). These can mess up your layout so make sure to test your layouts properly.
Using the Composite Pattern
The Composite Pattern is effective for organizing widgets, making it easier to reuse and adjust your code. While I’m using Riverpod for state management, you can choose any library that suits your project. This pattern also aids in adapting widgets inside your layouts to different screen sizes.
Implementation: Bringing It All Together
Now, let’s dive into the practical side of things. 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.
REPOSITORY: GitHub
Breakpoints: The Foundation
First things first, we establish our breakpoints. These are key in determining how our app will flex and adapt to different screen sizes. Let’s take a look at our foundation:
enum ScreenSize {
small(300),
normal(400),
large(600),
extraLarge(1000);
final double size;
const ScreenSize(this.size);
}
ScreenSize getScreenSize(BuildContext context) {
double deviceWidth = MediaQuery.sizeOf(context).shortestSide; // Gives us the shortest side of the device
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;
}
This setup uses enums to categorize screen sizes, making it easier for us to tailor the app’s layout to the device.
Building the Responsive Layout
Now, onto the layout. We’re keeping things modular and adaptable, using Dart Patterns to easily switch layouts. Here’s how our main widget shapes up:
class CounterAppContent extends StatelessWidget {
const CounterAppContent({
super.key,
});
@override
Widget build(BuildContext context) {
final screenSize = getScreenSize(context);
return Scaffold(
bottomNavigationBar: switch (screenSize) {
(ScreenSize.large) => null, // For larger screens, we might not need a bottom nav
(_) => const CounterNavigationBar(), // The standard bottom nav
},
body: SafeArea(
child: switch (screenSize) {
(ScreenSize.large) => const Row( // A layout for larger screens
children: [
CounterNavigationRail(),
VerticalDivider(thickness: 1, width: 1),
Expanded(
child: CountersPage(isFullPage: false), // isFullPage is being used to define if the CounterTiles should navigate
),
Expanded(
child: CounterDetailPage(),
),
],
),
(_) => const CountersPage(),// The default layout for smaller screens
},
),
);
}
}
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.
Adapting to Orientation Changes
What about when users flip their phones to landscape? We’ve got that covered too:
class CounterAppContent extends StatelessWidget {
const CounterAppContent({
super.key,
});
@override
Widget build(BuildContext context) {
final screenSize = getScreenSize(context);
final orientation = MediaQuery.orientationOf(context);
return Scaffold(
bottomNavigationBar: switch ((screenSize, orientation)) {
(_, Orientation.landscape) => null, // we will show NavigationRail when ever the app is being used in landscape
(ScreenSize.large, _) => null,
(_, _) => const CounterNavigationBar(),
},
body: SafeArea(
child: switch ((screenSize, orientation)) {
(ScreenSize.large, _) => const Row(
children: [
CounterNavigationRail(),
VerticalDivider(thickness: 1, width: 1),
Expanded(
child: CountersPage(isFullPage: false),
),
Expanded(
child: CounterDetailPage(),
),
],
),
(_, Orientation.landscape) => const Row( // the same here
children: [
CounterNavigationRail(),
VerticalDivider(thickness: 1, width: 1),
Expanded(
child: CountersPage(),
)
],
),
(_, _) => const CountersPage(),
},
),
);
}
}
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 the Record.
Wrapping Up
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. Don’t forget to check the source code on GitHub for more insights.
Keen to see these methods in action? Check out my app, Yawa: Weather & Radar, on Android and iOS. And if you have any questions or feedback, feel free to reach out on Twitter!
Top comments (2)
What would be your recommendation for adapting the font size, padding, etc?
This is handled mostly by the OS's accessibility features. You just need to scale your UI and Text and see that your app layout does not break. Btw I released a new more sophisticated guide which you can check out. :)