Jack Siro
Jack Siro

Follow

Jack Siro

Follow
Developing a Flutter App for Every Screen: Part 2/3

Photo by Glenn Carstens-Peters on Unsplash

Developing a Flutter App for Every Screen: Part 2/3

Jack Siro's photo
Jack Siro
·Jan 30, 2023·

10 min read

Table of contents

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

  1. Layout

  2. Input

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

(ii) Multi-Child Widgets

  • Column, Row, and Flex—Lays out children in a single horizontal or vertical run. Both Column and Row extend the Flex widget.

  • CustomMultiChildLayout—Uses 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.

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

  1. Design to the strengths of each form factor

  2. Use desktop build targets for rapid testing

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

 
Share this