Flutter Accessibility: Getting Started | Kodeco

[ad_1]

Building a high-quality app is not only about its features and looks but also about how accessible it is to users, including people with disability.

In this tutorial, you’ll add accessibility features to a Flutter food recipe app. In the process, you’ll:

  • Learn about accessibility and its importance in mobile apps.
  • Differentiate between various accessibility needs.
  • Understand the built-in Flutter accessibility features.
  • Run through an accessibility checklist suggested by Flutter’s documentation.
  • Add accessibility support to a production-ready app called Mealize.

Are you ready to dive in?

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.

Then, open the starter project in VS Code 1.70 or later. You can also use Android Studio, but you’ll have to adapt the instructions below.

Use Flutter version 3 or above. VS Code will prompt you to get dependencies. Click to do so.

If VS Code doesn’t get the dependencies automatically, open pubspec.yaml and click Get Packages in the top right corner or run flutter pub get from the integrated terminal.

In this article, you’ll add accessibility features to Mealize, a Flutter app that allows you to get a random recipe for cooking. You can also save recipes for later.

Exploring the Starter Project

Here’s a quick rundown of the project setup:

  • main.dart: Standard main file required for Flutter projects.
  • domain.dart: Contains the business logic and corresponding class definitions.
  • data.dart: Contains the classes that interact with storage and allows for better data handling.
  • app: A folder with the app widget and also a helper file with colors defined by the brand guidelines.
  • presentation: Contains different folders that build the app’s UI:

    • cubit defines a cubit that handles saved meals.
    • pages contains the two pages — meal_detail_page.dart and saved_meals_page.dart.
    • widgets contains custom widgets.

Build and run the project.

Here’s what you’ll see:

Screenshot of Mealize first run without Flutter accessibility

Note: Because accessibility features are only available on physical devices, you’ll need to use a physical Android or iOS device. The tutorial mostly showcases a Pixel 4 Android phone.

Because Flutter has accessibility built in, the app has some accessibility support. But, there’s room for improvement — and that’s what you’ll do in this tutorial.

But before you start your modifications, it’s important to understand why your Flutter app should be accessibile.

Why Accessibility is Important

Sometimes, it might seem inconvenient to add accessibility to your app. For example, if your app is already published, or if you want to get it to market as soon as possible, accessibility might not be at the top of your list.

But, there are several convincing reasons for making your app accessible. Here’s a short list you can refer to the next time you have to make a decision:

  1. Moral reasons: Developing apps without accessibility limits your app to only people without any form of disability. That means you’re excluding certain people from your product even though your intentions might not be malevolent. So, you must design and develop your apps so that anyone can use them, regardless of physical or cognitive abilities.
  2. Legal reasons: Since the United Nations established the Convention on the Rights of Persons with Disabilities in 2007, a few countries have put regulations in place to ensure that individuals with disabilities have equal access to infrastructure, jobs, education and digital services. In Norway, for instance, commercial websites cannot deny those with impairments equal access. Customer protection laws requiring most public websites to meet accessibility standards were put into effect in Austria in 2006. 10,982 ADA Title III lawsuits were filed in the US in 2020 alone. So, it’s probably best to ensure your mobile app is accessible to avoid the risk of legal action.
  3. Business reasons: More than 1 billion people live with some form of disability. Adding accessibility support to your app will increase your reach and improve your brand’s reputation. Also, there are about $6.9 trillion reasons from a business sense.
  4. Quality reasons: Since general usability relates to accessibility, you get more human-centered, natural and contextual interactions with your app. That results in higher product quality and a greater, much richer user experience.

The Built-in Flutter Accessibility

Flutter has great built-in accessibility support. By making an app with Flutter, you get:

  • Compatibility with large fonts.
  • Response to scale factor changes.
  • Support for screen readers.
  • Great color contrast defaults: Both material and cupertino have widgets with colors that have enough contrast when rendered.

In addition, the Flutter Team compiled an accessibility release checklist for you to consider as you prepare your release. In the next sections, you’ll review this checklist for Mealize.

Making Interactions Visible

It’s time to start adding accessibility to Mealize.

Build and run. Tap Random Meal to open a meal. Then, tap the Bookmark+ icon button to save it for later. Next, tap the Bookmark icon:

Animated GIF showing there are missing active interactions in Mealize

Did the actions complete? If so, when did each one finish? Could you tell if an action ended even if you could not see the screen?

See the problem yet?

These types of interactions can be invisible to people with visual problems. In fact, the only way to notice that you saved a meal for later is to pay close attention to the bookmark icons.

The app should tell the user what happened when they tapped the button. You’ll work on this first.

Open lib/presentation/widgets/meal_appbar.dart and replace the code in _onSaveMealForLater with the following:


final messenger = ScaffoldMessenger.maybeOf(context);
// TODO add directionality and semanticsLabel fields
await context.read<DetailCubit>().bookmarkMeal();

messenger?.clearSnackBars();
messenger?.showSnackBar(SnackBar(
  behavior: SnackBarBehavior.floating,
  content: Text('Saved $mealName for later.'),
));
// TODO: Add Semantics for iOS.

With the code above, you display a floating Snackbar when saving a meal for later. This improves the interaction for impaired users and the user experience.

You also added two TODO remarks you’ll address later in the tutorial. For now, ignore them.

Now do the same for _onRemoveMeal — replace the code inside it with the following lines:


final messenger = ScaffoldMessenger.maybeOf(context);
// TODO add directionality and semanticsLabel fields
await context.read<DetailCubit>().removeBookmark();

messenger?.clearSnackBars();
messenger?.showSnackBar(SnackBar(
  behavior: SnackBarBehavior.floating,
  content: Text('Removed $mealName from Saved Meals list.'),
// TODO: Add undo dangerous actions.
));

// TODO: Add Semantics for iOS.

Like the previous code, the code shows a snackbar when you remove a meal from the saved meals list.

Restart the app. Notice both snackbars when you save a meal for later or remove it from the saved list:

Animated GIF showing active interactions via SnackBars, improving the Flutter app's accessibility

Great job! You added your first bit of accessibility features to your Flutter app.

Testing With a Screen Reader

The next step is to do screen reader testing. To get an idea of how your app might feel for someone with vision impairments, you need to enable your phone’s accessibility features. If enabled, you’ll get spoken feedback about the screen’s contents and interact with the UI via gestures.

Flutter takes care of the heavy load since it enables the screen reader to understand most of the widgets on the screen. But, certain widgets need context so the screen reader can accurately interpret them.

Introducing The Semantics Widget

The Semantics widget provides context to widgets and describes its child widget tree. This allows you to provide descriptions of widgets so that Flutter’s accessibility tools can get the meaning of your app.

The framework already implements Semantics in the material and cupertino libraries. It also exposes properties you can use to provide custom semantics for a widget or a widget subtree.

But, there are times when you’ll need to add your own semantics to provide the correct context for screen readers. For example, when you want to merge or exclude semantics in a widget subtree, or when the framework’s implementation isn’t enough.

Enabling the Screen Reader

To enable your device’s screen reader, go to your phone’s settings and navigate to Accessibility. Then, enable TalkBack or VoiceOver if you’re using an iOS device.

Give the screen reader permission to take over the device’s screen.

By enabling TalkBack/VoiceOver, your navigation and interaction with the mobile phone will change. Here’s a quick rundown of how to use the screen reader:

  • Tap once to select an item.
  • Double-tap to activate an item.
  • Drag with one finger to move between items.
  • Drag with two fingers to scroll (use three fingers if you’re using VoiceOver).

Hot reload the app. Try using the app by opening a random meal and saving a couple of meals for later. Close your eyes if you want to experience total blindness while using the app. Here’s a preview:

Here’s what you may have experienced:

  • When in the Saved Meals For Later screen, it’s not clear what Random Meal does.
  • When in the Meal Detail screen, the screen reader refers to Save Meal for Later and Remove From Saved List icons as button. This is confusing.
  • When in the Meal Detail screen, after tapping the Save Meal for Later icon button, VoiceOver (iOS) doesn’t read the snackbar.
  • When in Meal Detail screen, after tapping the Remove From Saved List icon button, VoiceOver (iOS) doesn’t read the snackbar.

Adding Support for Screen Readers

OK, it’s time to add some Semantics. Open lib/presentation/widgets/random_meal_button.dart and
in build wrap FloatingActionButton in Semantics like below:


return Semantics(
  // 1
  button: true,
  enabled: true,
  // 2
  label: 'Random Meal',
  // 3
  onTapHint: 'View a random meal.',
  onTap: () => _openRandomMealDetail(context),
  // 4
  excludeSemantics: true,
  child: FloatingActionButton.extended(
    onPressed: () => _openRandomMealDetail(context),
    icon: const Icon(Icons.shuffle),
    label: const Text(
      'Random Meal',
    ),
  ),
);

Here’s what’s happening in the code above:

  1. This tells screen readers that the child is a button and is enabled.
  2. label is what screen readers read.
  3. onTapHint and onTap allows screen readers to know what happens when you tap Random Meal.
  4. excludeSemantics excludes all semantics provided in the child widget.

Note: you have to provide onTap when implementing onTapHint. Otherwise, the framework will ignore it.

If you’re using VoiceOver (iOS), you’ll notice there’s no change. That is because iOS doesn’t provide a way to override those values and thus it’s ignored for iOS devices. Also, onTap supersedes onPressed from FloatingActionButton. So, you don’t have to worry about _openRandomMealDetail executing twice.

Restart the app. Then, use the screen reader to focus on Random Meal and notice how the screen reader interprets the app:

You need to do something similar with MealCard. See if you can implement Semantics by yourself this time. You can find MealCard in lib/presentation/widgets/meal_card.dart.

Need help? Open the spoiler below to find out how.

[spoiler title=”Solution”]


return Semantics(
  button: true,
  label: meal.name,
  onTapHint: 'View recipe.',
  onTap: onTap,
  excludeSemantics: true,
  child: Material(...),
);

[/spoiler]

Using Semantics With Custom Widgets

Sometimes, you use Semantics to provide information about what role a widget plays, allowing screen readers to understand and behave accordingly.

You’ll use that for the meal heading. So, open lib/presentation/widgets/meal_header.dart, wrap Column with Semantics and set header to true like so:


return Semantics(
  header: true,
  child: Column(
    ...
  ),
);

This tells screen readers that the contents inside Column is a header.

Hot reload. Navigate to MealDetailPage using the screen reader. Confirm that the screen reader identifies it as a header. Here’s how the app is coming together:

Note: While you’re developing, it’s helpful to activate Flutter’s Semantics Debugger. Set showSemanticsDebugger to true in the app’s top-level MaterialApp. Semantics Debugger shows the screen reader’s interpretation of your app.

Using SemanticsService to Fill the Gaps

Now, to finish adding screen reader support, open lib/presentation/widgets/meal_appbar.dart. Replace // TODO: Add Tooltip for Remove Meal with this line of code:


tooltip: 'Remove from Saved Meals',

Do the same with // TODO: Add Tooltip for Save Meal for Later — replace it with this:


tooltip: 'Save for Later',

As you might’ve known, tooltips in IconButtons serve as semantic labels for screen readers. They also pop up when tapped or hovered over to give a visual description of the icon button — significantly improving your app’s accessibility and user experience.

Hot reload. Navigate to a meal and select the Save Meal for Later icon. Here’s what you’ll experience:

Now the screen reader knows appropriate labels for those buttons.

Making SnackBars Accessible on iOS

There’s still one issue you need to address, and it’s a platform-specific problem. For VoiceOver users, the snackbars aren’t read when they appear on the screen.

There is an issue about this on Flutter’s Github explaining reasons behind this behavior for iOS devices. For now, it’s safe to say you’ll need to use an alternative: SemanticsService.

SemanticsService belongs to Flutter’s semantics package, and you’ll use it to access the platform accessibility services. You shouldn’t use this service all the time because Semantics is preferable, but for this specific case, it’s OK.

First, replace // TODO add directionality and semanticsLabel fields in _onRemoveMeal with the following:


final textDirectionality = Directionality.of(context);
final semanticsLabel="Removed $mealName from Saved Meals list.";

Second, replace // TODO add directionality and semanticsLabel fields in _onSaveMealForLater with:


final textDirectionality = Directionality.of(context);
final semanticsLabel="Saved $mealName for later.";

Don’t forget to add the corresponding imports at the top of the file:


import 'package:flutter/foundation.dart';
import 'package:flutter/semantics.dart';

Flutter’s accessibility bridge needs context about the device’s textDirectionality. That’s why you obtained it from the current context.

Next, change Texts in both _onRemoveMeal and _onSaveMealForLater snackbars to the following, respectively:


Text(
  'Removed $mealName from Saved Meals list.',
  semanticsLabel: semanticsLabel,
),

and


Text(semanticsLabel),

Then, replace // TODO: Add Semantics for iOS. at the bottom of _onRemoveMeal with the code below:


if (defaultTargetPlatform == TargetPlatform.iOS) {
  SemanticsService.announce(
    semanticsLabel,
    textDirectionality,
  );
}

Lastly, do the same with _onSaveMealForLater:


if (defaultTargetPlatform == TargetPlatform.iOS) {
  SemanticsService.announce(
    semanticsLabel,
    textDirectionality,
  );
}

SemanticsService.announce will use the platform-specific accessibility bridge in Flutter to read out semanticsLabel. Then, you provide the device’s textDirectionality to it since the bridge needs that. This ensures the screen reader announces the snackbar message on iOS in both cases.

Hot reload the app. If you’re using VoiceOver, you’ll now hear both snackbars when saving or removing a meal:

If you’re using TalkBack, you won’t notice any differences.

Great job! Android and iOS screen readers can now interpret Mealize. It was a challenge, but you knocked it out of the park. Congratulations!

Considering Contrast Ratio and Color Deficiency

People with vision impairments often have difficulty reading text that doesn’t contrast with its background. This can be worse if the person has a color vision deficiency that further lowers the contrast.

Your responsibility is to provide enough contrast between the text and its background, making the text more readable even if the user doesn’t see the full range of colors. It also works for individuals who see no color.

The WCAG standard suggests that visual presentation of text and images of text have a contrast ratio of at least 4.5:1. Large-scale text and images can have a contrast ratio of at least 3:1.

When you open the meal detail page, you see the meal’s name and its image in the header. There’s no contrast management. So depending on the meal, it might be difficult to see the meal’s name. To make matters worse, the font isn’t that legible:

Screenshot showing contrast issues in Meal Card

You’ll fix contrast issues now.

Open lib/presentation/widgets/meal_card.dart and replace // TODO: Improve color contrast. with the following code:


colorFilter: const ColorFilter.mode(
  Colors.black45,
  BlendMode.darken,
),

In the code above, you added a black45 filter to the photographs, significantly improving contrast and making the text readable.

Hot reload. Do you see your changes? It’s now easier to read the meal’s name:

Screenshot showing better contrast in meal card

Responding to Scale Factor Changes

Most Android and iOS smartphones allow you to enlarge the text and display. This feature is excellent for persons who have trouble reading small fonts or identifying items on the screen.

But that presents a challenge for you as the developer since you need to ensure the UI remains legible and usable at very large scale factors for text size and display scaling.

Now, it might be tempting to add overflow: TextOverflow.ellipsis, or maxLines: 2, to your Texts all throughout your app, but remember that this will only hide information from the user and prevent them from accessing the text.

Allowing horizontal scrolling is another option. This allows the user to read the text but does not address the issue of hidden information. According to James Edwards, a solid rule of thumb is to never use text truncation. With regard to accessibility, vertical scrolling without a fixed header is a good solution.

You must prepare your app’s layout and widgets to grow in size when needed. This ensures that texts remain visible to all and that users can interact with them.

Testing Mealize’s Responsiveness

OK, time to test Mealize’s responsiveness to scale changes. Go to your device’s settings and max out the font scale and display scale (if there is one):

  • In Android, open Settings, then go to Accessibility, find and tap on Text and display. Find Font Size and tap on it. Then set the slider to the highest. Now go back to the previous menu and do the same with Display Size.
  • In iOS, go to Settings, then tap Accessibility. Find Larger Text and tap on it. Then set the slider at the bottom of the screen to the most.

Reload the app and check if it’s still usable:

Animated GIF showcasing text scale changes to increase the Flutter app's accessibility

Note: If you want to see the dynamic font size in action, use the Accessibilty Inspector on iOS or the Accessibility Scanner on Android. You can test the font scaling with the iOS simulator or Android emulator. But beware that large font sizes can completely ruin the layout of your app.

Mealize uses a ConstrainedBox with a minimum height to make MealCard‘s height adjustable. It also uses a ListView when showing the meal’s recipe to allow scrolling without worrying about text size.

Notifying Users on Context Switching

Next, in the accessibility checklist, ensure nothing changes the user’s context without a confirmation action, especially if the user is typing information. Examples you might run into include opening a different app via a deep link or changing screens when typing information.

In Mealize, there’s one example of context switching without confirmation. When you tap Watch Video, you exit the app and open via deep link a separate app with the video (like YouTube or a web browser). This happens without giving a warning to the user, which is not an ideal experience. Take a look:

Animated GIF showing context switch to YouTube without warning

To fix this, open lib/presentation/widgets/watch_video_button.dart and find the definition for _openVideoTutorial. Change the body to the following:


showDialog<void>(
  context: context,
  builder: (dContext) => AlertDialog(
    title: const Text('Are you sure?'),
    content: const Text('You will exit Mealize and open an external link.'
        'Are you sure you want to continue?'),
    actions: [
      TextButton(
        onPressed: () => Navigator.pop(dContext),
        child: const Text('Cancel'),
      ),
      TextButton(
        onPressed: () => launchUrl(
          Uri.parse(videoUrl),
        ),
        child: const Text('See Video'),
      )
    ],
  ),
);

Reload. Use the app to open the detail page of any recipe, then tap Watch Video under the header. If the meal doesn’t have a Watch Video button, find another recipe by going back to the list and tapping Random Meal.

You should see this dialog:

Animated GIF showing confirmation dialog before switch to YouTube

Undoing Important Actions

Unsteady fingers and visual disabilities can affect users in a big way. Imagine the frustration of tapping a delete button without knowing it’s there. That’s why users should be able to undo important actions.

In the app, removing a meal from the saved meals list displays a snackbar, but it doesn’t allow the user to undo the action. You’re going to fix that now.

Open lib/presentation/widgets/meal_appbar.dart and locate _onRemoveMeal. Change the SnackBar to include action by replacing // TODO: Add undo dangerous actions. with the following code:


action: SnackBarAction(
  label: 'Undo',
  onPressed: () => context.read<DetailCubit>().bookmarkMeal(),
),

This will add an Undo button to the snackbar, which will resave the meal for later.

Hot reload. Check that tapping Undo works as expected. Enable the screen reader again and notice that it announces the snackbar for removing a meal. Here’s what it looks like:

You’ll notice two things:

  1. The snackbar no longer automatically dismisses.
  2. The screen reader doesn’t read the Undo button.

The first issue is by design — users with visual impairments might not notice an action. So, the user has to dismiss snackbars with actions.

The second problem is a much more complex topic. Suffice it to say that Flutter ignores SnackBarAction when defining Semantics. So, you’ll need to provide an alternative solution.

Change semanticsLabel on _onRemoveMeal to this:


final semanticsLabel="Removed $mealName from Saved Meals list."
 'Undo Button. Double tap to undo this action.';

Since the framework ignores SnackBarAction, by providing a custom semanticsLabel you’re overriding what the screen reader will announce. Also, since SnackBar and SemanticsService use semanticsLabel, TalkBack and VoiceOver correctly read it aloud.

Hot restart. While using the screen reader, observe that the Undo button is now mentioned when the snackbar shows.

You’ve added extra features to support accessibility in your Flutter app and ensured it stays compatible with screen readers. Great work!

Where to Go From Here

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

Flutter already has great documentation on accessibility and learning resources for you to check out. It would also be great for you to take a look at the Flutter Accessibility Widgets Catalog.

You can also see this oldie but goodie video recorded during the Flutter Interact a while back. In it, you’ll see real user accessibility issues that the Flutter team had to address in its showcase app.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!

[ad_2]

Source link