Flutter offers the ability to create apps that can be used on multiple platforms, including mobile, desktop, and web, using a single codebase. However, this presents new challenges in creating apps that are not just multiplatform, but also adapt to each platform to provide a familiar and seamless user experience.
As we get into this subject you can check out the original demo code for adaptive app development techniques from flutter-adaptive-demo by the gskinner team.
Creating an Adaptive Flutter app
Developing platform-adaptive apps involves taking into account various factors, which can be grouped into three main categories.:
Layout
Input
Idioms and norms
1. Building adaptive layouts
When developing a multiplatform app, one of the initial considerations should be how to adjust the app to fit the varying screen sizes and dimensions it will be used on.
a) Types of Layout Widgets
b) Contextual Layout
c) Visual Density
d) Single Source of Truth for Styling
a) Types of Layout Widgets
(i) Single Child Widgets
Align
—Aligns 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
andFlexible
—Allows a child of aRow
orColumn
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 aRow
orColumn
.
(ii) Multi-Child Widgets
Column
,Row
, andFlex
—Lays out children in a single horizontal or vertical run. BothColumn
andRow
extend theFlex
widget.CustomMultiChildLayout
—Uses a delegate function to position multiple children during the layout phase.Flow
—Similar toCustomMultiChildLayout
, but more efficient because it’s performed during the paint phase rather than the layout phase.ListView
,GridView
, andCustomScrollView
—Provides scrollable lists of children.Stack
—Layers and positions multiple children relative to the edges of theStack
. 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.
To see more available widgets and example code, see Layout widgets.
b) Visual density
The precision of various input devices varies, leading to the need for different hit area sizes. Flutter's VisualDensity class simplifies adjusting view density throughout the app, such as making a button bigger on touch devices for easier tapping.
With a change in VisualDensity for the MaterialApp, the densities of supporting MaterialComponents will animate to match. The default for both horizontal and vertical densities is 0.0, but they can be set to any negative or positive value desired. Changing between densities allows easy UI adjustments:
To set a custom visual density, inject the density into your MaterialApp
theme:
double densityAmt = touchMode ? 0.0 : -1.0;
VisualDensity density =
VisualDensity(horizontal: densityAmt, vertical: densityAmt);
return MaterialApp(
theme: ThemeData(visualDensity: density),
home: MainAppScaffold(),
debugShowCheckedModeBanner: false,
);
To use VisualDensity
inside your own views, you can look it up:
VisualDensity density = Theme.of(context).visualDensity;
Not only does the container react automatically to changes in density, it also animates when it changes. This ties together your custom components, along with the built-in components, for a smooth transition effect across the app.
As shown, VisualDensity
is unit-less, so it can mean different things to different views. In this example, 1 density unit equals 6 pixels, but this is totally up to your views to decide. The fact that it is unit-less makes it quite versatile, and it should work in most contexts.
It’s worth noting that the Material Components generally use a value of around 4 logical pixels for each visual density unit. For more information about the supported components, see VisualDensity
API. For more information about density principles in general, see the Material Design guide.
c) Contextual layout
If you need more than density changes and can’t find a widget that does what you need, you can take a more procedural approach to adjust parameters, calculate sizes, swap widgets, or completely restructure your UI to suit a particular form factor.
Screen-based breakpoints
The simplest form of procedural layouts uses screen-based breakpoints. In Flutter, this can be done with the MediaQuery
API. There are no hard and fast rules for the sizes to use here, but these are general values:
class FormFactor {
static double desktop = 900;
static double tablet = 600;
static double handset = 300;
}
Using breakpoints, you can set up a simple system to determine the device type:
content_copy
ScreenType getFormFactor(BuildContext context) {
// Use .shortestSide to detect device type regardless of orientation
double deviceWidth = MediaQuery.of(context).size.shortestSide;
if (deviceWidth > FormFactor.desktop) return ScreenType.Desktop;
if (deviceWidth > FormFactor.tablet) return ScreenType.Tablet;
if (deviceWidth > FormFactor.handset) return ScreenType.Handset;
return ScreenType.Watch;
}
As an alternative, you could abstract it more and define it in terms of small to large:
enum ScreenSize { Small, Normal, Large, ExtraLarge }
ScreenSize getSize(BuildContext context) {
double deviceWidth = MediaQuery.of(context).size.shortestSide;
if (deviceWidth > 900) return ScreenSize.ExtraLarge;
if (deviceWidth > 600) return ScreenSize.Large;
if (deviceWidth > 300) return ScreenSize.Normal;
return ScreenSize.Small;
}
Screen-based breakpoints are best used for making top-level decisions in your app. Changing things like visual density, paddings, or font-sizes are best when defined on a global basis.
You can also use screen-based breakpoints to reflow your top-level widget trees. For example, you could switch from a vertical to a horizontal layout when the user isn’t on a handset:
bool isHandset = MediaQuery.of(context).size.width < 600;
return Flex(
children: [Text('Text 1'), Text('Text 2'), Text('Text 3')],
direction: isHandset ? Axis.vertical : Axis.horizontal);
In another widget, you might swap some of the children completely:
Widget foo = Row(
children: [
...isHandset ? _getHandsetChildren() : _getNormalChildren(),
],
);
Use LayoutBuilder for extra flexibility
Even though checking total screen size is great for full-screen pages or making global layout decisions, it’s often not ideal for nested subviews. Often, subviews have their internal breakpoints and care only about the space that they have available to render.
The simplest way to handle this in Flutter is using the LayoutBuilder
class. LayoutBuilder
allows a widget to respond to incoming local size constraints, which can make the widget more versatile than if it depended on a global value.
The previous example could be rewritten using LayoutBuilder
:
Widget foo = LayoutBuilder(
builder: (context, constraints) {
bool useVerticalLayout = constraints.maxWidth < 400.0;
return Flex(
children: [
Text('Good'),
Text('Morning'),
],
direction: useVerticalLayout ? Axis.vertical : Axis.horizontal,
);
});
This widget can now be composed within a side panel, dialog, or even a full-screen view, and adapt its layout to whatever space is provided.
Device segmentation
There are times when you want to make layout decisions based on the actual platform you’re running on, regardless of size. For example, when building a custom title bar, you might need to check the operating system type and tweak the layout of your title bar, so it doesn’t get covered by the native window buttons.
To determine which combination of platforms you’re on, you can use the Platform
API along with the kIsWeb
value:
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;
The Platform
API can’t be accessed from web builds without throwing an exception, because the dart.io
package is not supported on the web target. As a result, this code checks for web first, and because of short-circuiting, Dart will never call Platform
on web targets.
d) Single source of truth for styling
You’ll probably find it easier to maintain your views if you create a single source of truth for styling values like padding, spacing, corner shape, font sizes, and so on. This can be done easily with some helper classes:
class Insets {
static const double xsmall = 3;
static const double small = 4;
static const double medium = 5;
static const double large = 10;
static const double extraLarge = 20;
// etc
}
class Fonts {
static const String raleway = 'Raleway';
// etc
}
class TextStyles {
static const TextStyle raleway = const TextStyle(
fontFamily: Fonts.raleway,
);
static TextStyle buttonText1 =
TextStyle(fontWeight: FontWeight.bold, fontSize: 14);
static TextStyle buttonText2 =
TextStyle(fontWeight: FontWeight.normal, fontSize: 11);
static TextStyle h1 = TextStyle(fontWeight: FontWeight.bold, fontSize: 22);
static TextStyle h2 = TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
static late TextStyle body1 = raleway.copyWith(color: Color(0xFF42A5F5));
// etc
}
These constants can then be used in place of hard-coded numeric values:
return Padding(
padding: EdgeInsets.all(Insets.small),
child: Text('Hello!', style: TextStyles.body1),
);
With all views referencing the same shared-design system rules, they tend to look better and more consistent. Making a change or adjusting a value for a specific platform can be done in a single place, instead of using an error-prone search and replace. Using shared rules has the added benefit of helping enforce consistency on the design side.
Some common design system categories that can be represented this way are:
Animation timings
Sizes and breakpoints
Insets and paddings
Corner radius
Shadows
Strokes
Font families, sizes, and styles
Like most rules, there are exceptions: one-off values that are used nowhere else in the app. There is little point in cluttering up the styling rules with these values, but it’s worth considering if they should be derived from an existing value (for example, padding + 1.0
). You should also watch for the reuse or duplication of the same semantic values. Those values should likely be added to the global styling ruleset.
Tips and Tricks for Designing Your Adaptive Flutter App
Design to the strengths of each form factor
Use desktop build targets for rapid testing
Solve touch first
1. Design to the strengths of each form factor
When developing a multiplatform app, consider not only screen size but also the strengths and weaknesses of each form factor. It may not be ideal for the app to have identical functionality across all devices. Consider focusing on specific capabilities or even removing certain features for specific device categories.
For example, mobile devices are portable and have cameras, but not suitable for intricate creative work. Thus, a mobile UI should prioritize capturing and tagging content with location data, while a tablet or desktop UI should focus on organizing or manipulating the content.
For web apps, take advantage of the easy sharing capability of the web by deciding on deep links to support and designing navigation routes accordingly.
The goal is to take into account each platform's strengths and leverage unique capabilities.
2. Use desktop build targets for rapid testing
An efficient way to test adaptive interfaces is to use desktop build targets. Resizing the app's window while running on a desktop allows for quick previews of different screen sizes, and when combined with hot reload, it speeds up the development of a responsive UI.
3. Solve touch first
Creating a top-notch touch UI can be more challenging than building a desktop UI due to the absence of input accelerators like right-click, scroll wheel, or keyboard shortcuts.
One solution is to first focus on a touch-oriented UI. You can still do most of your testing using the desktop target for its faster iteration, but remember to switch to a mobile device frequently to ensure a good feel.
Once the touch interface is polished, adjust the visual density for mouse users and add additional inputs. Consider these inputs as accelerators to make tasks faster. What's important is to understand what the user expects when using a specific input device and to reflect that in the app.
You can check out the original demo code for adaptive app development techniques from flutter-adaptive-demo by the gskinner team.
Please proceed to my next article, where I have talked about Input and Idioms & Norms.
Don't fail to Follow me here and On
Twitter @JacksiroKe
Linked In Jack Siro
Github @JacksiroKe