feature_discovery 0.8.0

  • Readme
  • Changelog
  • Example
  • Installing
  • 93

feature_discovery #

This Flutter package implements Feature Discovery following the Material Design guidelines.

With Feature Discovery, you can add context to any UI element, i.e. any Widget in your Flutter app.
Here is a small demo of the example app:

Installing #

To use this package, follow the installing guide.

Usage #

FeatureDiscovery #

To be able to work with any of the global functions provided by the feature_discovery package, you will have to wrap your widget tree in a FeatureDiscovery widget.
There are many places where you can add FeatureDiscovery in your build tree, but the easiest to assure that it sits on top is to wrap your MaterialApp with it:

const FeatureDiscovery(
  child: MaterialApp(
   ...
  )
)

DescribedFeatureOverlay #

For every UI element (Widget) that you want to describe using Feature Discovery, you will need to add a DescribedFeatureOverlay.
This widget takes all the parameters for the overlay that will be displayed during Feature Discovery and takes the Widget you want to display the overlay about as its child.

Feature ids

Every feature you describe should have a unique identifier, which is a String passed to the featureId parameter. You will also need to provide these ids when starting the discovery.

DescribedFeatureOverlay(
  featureId: 'add_item_feature_id', // Unique id that identifies this overlay.
  tapTarget: const Icon(Icons.add), // The widget that will be displayed as the tap target.
  title: Text('Add item'),
  description: Text('Tap the plus icon to add an item to your list.'),
  backgroundColor: Theme.of(context).primaryColor,
  targetColor: Colors.white,
  textColor: Colors.white,
  child: IconButton( // Your widget that is actually part of the UI.
    icon: cons Icon(Icons.add),
    onPressed: addItem,
  ),
);
Additional parameters

contentLocation

This is ContentLocation.trivial by default, however, the package cannot always determine the correct placement for the overlay. In those cases, you can provide either of these two:

  • ContentLocation.below: Text is displayed below the target.

  • ContentLocation.above: Text is displayed above the target.

onComplete

   onComplete: () async {
    // Executed when the tap target is tapped. The overlay will not close before
    // this function returns and after that, the next step will be opened.
    print('Target tapped.');
    // You can prevent completion by returning false.
    return true;
  },

onDismiss

  onDismiss: () async {
    // Called when the user taps outside of the overlay, trying to dismiss it.
    print('Overlay dismissed.');
    // You can prevent dismissal by returning false.
    return true;
  },

onOpen

  onOpen: () async {
    // This callback is called before the overlay is displayed.
    print('The overlay is about to be displayed.');
    // If you return false, the overlay will not be opened and the next step
    // will be attempted to open.
    return true;
  },

enablePulsingAnimation

This is set to true by default, but you can disable the pulsing animation about the tap target by setting this to false.

allowShowingDuplicate

If multiple DescribedFeatureOverlays have the same featureId, they will interfere with each other during discovery and if you want to display multiple overlays at the same time, you will have to set allowShowingDuplicate to true for all of them.

overflowMode

This is OverflowMode.ignore by default, which will simply render the content you pass to title and description, even if it overflows the background area, i.e. the circle of the overlay. Alternatively, you can specify any of the following if you desire different behavior:

  • OverflowMode.clipContent will clip any content that is outside of the inner area (the background's circle).

  • OverflowMode.extendBackground will expand the background circle if necessary.

  • OverflowMode.wrapBackground will expand the background circle if necessary, but also shrink it if the content is smaller than the default background size.

FeatureDiscovery.discoverFeatures #

When you want to showcase your features, you can call FeatureDiscovery.discoverFeatures with the applicable feature ids. The features will be displayed as steps in order if the user does not dismiss them.
By tapping the tap target, the user will be sent on to the next step and by tapping outside of the overlay, the user will dismiss all queued steps.

FeatureDiscovery.discoverFeatures(
  context,
  const <String>{ // Feature ids for every feature that you want to showcase in order.
    'add_item_feature_id',
  },
);

If you want to display Feature Discovery for a page right after it has been opened, you can use SchedulerBinding.addPostFrameCallback in the initState method of your StatefulWidget:

@override
void initState() {
  // ...
  SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
    FeatureDiscovery.discoverFeatures(
      context,
      const <String>{ // Feature ids for every feature that you want to showcase in order.
        'add_item_feature_id',
      },
    ); 
  });
  super.initState();
}

Other methods

You can view the API reference for FeatureDiscovery to find other useful methods for controlling the Feature Discovery process programmatically.

EnsureVisible #

You can use the EnsureVisible widget to automatically scroll to widgets that are inside of scrollable viewports when they are described during Feature Discovery:

// You need to save an instance of a GlobalKey in order to call ensureVisible in onOpen.
GlobalKey<EnsureVisibleState> ensureVisibleGlobalKey = GlobalKey<EnsureVisibleState>();

// The widget in your build tree:
DescribedFeatureOverlay(
  featureId: 'list_item_feature_id',
  tapTarget: const Icon(Icons.cake),
  onOpen: () async {
    WidgetsBinding.instance.addPostFrameCallback((Duration duration) {
      ensureVisibleGlobalKey.currentState.ensureVisible();
      return true;
    });
  },
  title: Text('Cake'),
  description: Text('This is your reward for making it this far.'),
  child: EnsureVisible(
    key: ensureVisibleGlobalKey,
    child: const Icon(Icons.cake),
  ),
)

Notes #

In DescribedFeatureOverlay, tapTarget, title, and description can take any widget, but it is recommended to use an Icon for the tap target and simple Text widgets for the title and description. The package takes care of styling these widgets and having these as Widgets allows you to pass Keys, Semantics, etc.

Contributing #

If you want to start contributing to this package, it is recommended to refer to the contributing guide.

Gratitude #

Thanks to mattcarroll for their Flutter challenge about Feature Discovery on Fluttery.

0.8.0 #

  • Breaking change: FeatureDiscovery.completeCurrentStep is async know.
  • Added preferences for each feature to show is understood by user or not
  • Library will not show understood feature
  • Added method for reset all preferences of features FeatureDiscovery.clearPreferences

0.7.0 #

  • Breaking change: removed deprecated static methods in FeatureDiscovery.
  • Breaking change: removed deprecated parameters in the EnsureVisible constructor.
  • Breaking change: overlays will always be dismissed when calling FeatureDiscovery.dismissAll.
  • Deprecated activeFeatureId; replaced by currentFeatureIdOf to emphasize that this is a getter.
  • Deprecated FeatureDiscovery.dismiss; replaced by dismissAll to indicate that no next step will be shown.
  • Added assert to require at least one step to be passed to FeatureDiscovery.discoverFeatures.
  • Incorrect documentation of some static methods in FeatureDiscovery has been updated.
  • Error messages have been improved : the error thrown when the widget tree isn't wrapped in a FeatureDiscovery widget is clearer.
  • Incorrect behavior when onDismiss returned Future<false> has been fixed.

0.6.1 #

  • Update version constraint to ^4.0.1 for provider dependency.

0.6.0 #

  • Breaking change: Renamed ContentOrientation to ContentLocation.
  • Breaking change: Made onComplete of type Future<bool> Function() to match onOpen and onDismiss.
  • Methods completeStep and markStepComplete have been deprecated and completeCurrentStep should now be used.
  • Method clear is deprecated and dismiss should now be used.
  • Added an OverflowMode enum and the overflowMode parameter to DescribedFeatureOverlay to control how the overlay should handle content that exceeds the background's boundaries.
  • Added FeatureDiscovery.activeFeatureId, which allows you to get the feature id of the current feature discovery step.
  • Added duration, curve, and preciseAligment parameters to EnsureVisibleState.ensureVisible.
  • Deprecated EnsureVisible.duration and EnsureVisible.curve as parameters because they should be passed when calling EnsureVisibleState.ensureVisible. This is not a breaking change.
  • Made the return type of EnsureVisibleState.ensureVisible be Future<void>. This is not a breaking change because the previous return type was void, which you cannot work with.
  • Made the enablePulsingAnimation respond to rebuilds, allowing to change it after the overlay has been shown.
  • Added GIF demo of the package to the README.md file of the package and the example.
  • Updated example.
  • Added OverflowMode to README.md.
  • Added CONTRIBUTING.md and mentioned it in README.md.

0.5.0 #

  • Breaking change: Instead of the icon parameter, you now need to use the tapTarget parameter, which takes a Widget instead of IconData. Before: DescribedFeatureOverlay(icon: Icons.add, ..) After: DescribedFeatureOverlay(tapTarget: const Icon(Icons.add), ..)
  • Breaking change: title and description parameters now take a Widget.
  • Breaking change: Callbacks are now onOpen, onDismiss, and onComplete. onOpen and onDismiss need to return Future<bool> when specified to decide if the step should be open or dismissed respectively.
  • Fixed DescribedFeatureOverlay's constantly rebuilding even if they were never displayed.
  • Fixed DescribedFeatureOverlay's rebuilding after dismissing them.
  • Warning: Theme.of(context) is now used to determine text styles
  • Title and description can now be null.
  • Added option to disable pulsing animation.
  • Added parameter that is called when the overlay is dismissed.
  • Added parameters to change text color, target color, and icon color.
  • Added possibility to pass any Iterable for the steps to FeatureDiscovery.discoverFeatures.
  • Added the @required annotation to parameters that cannot be null.
  • Ensured that overlay for each step is only shown once at a time.
  • Removed unnecessary files.
  • Formatted files.
  • Updated the plugin description.

0.4.1 #

  • Fixed animation bugs.

0.4.0 #

  • Added ContentOrientation.

0.3.0 #

  • Consider landscape and portrait orientation in DescribedFeatureDiscoveryWidget.

0.2.0 #

  • Add EnsureVisible widget to scroll to target when it is in a scrollable container.

0.1.1 #

  • Applied Pub health suggestions.

0.1.0 #

  • Applied Pub health suggestions.

0.0.1 #

  • Initial release.

example/lib/main.dart

import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

const String feature1 = 'feature1',
    feature2 = 'feature2',
    feature3 = 'feature3',
    feature4 = 'feature4',
    feature5 = 'feature5',
    feature6 = 'feature6',
    feature7 = 'feature7';

void main() {
  // You can increase the timeDilation value if you want to see
  // the animations more slowly.
  timeDilation = 1.0;

  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'Feature Discovery',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        // Required: this widget works like an inherited widget.
        home: const FeatureDiscovery(
          child: MyHomePage(title: 'Flutter Feature Discovery'),
        ),
      );
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    final action = () async {
      print('IconButton of $feature7 tapped.');
      return true;
    };
    const icon1 = Icon(Icons.drive_eta);
    const icon2 = Icon(Icons.menu);
    const icon3 = Icon(Icons.search);
    const icon4 = Icon(Icons.add);

    var feature1OverflowMode = OverflowMode.clipContent;
    var feature1EnablePulsingAnimation = false;

    var feature3ItemCount = 15;

    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(80),
          child: Column(
            children: <Widget>[
              DescribedFeatureOverlay(
                featureId: feature7,
                tapTarget: icon1,
                backgroundColor: Colors.blue,
                contentLocation: ContentLocation.below,
                title: const Text('Find the fastest route'),
                description: const Text(
                    'Get car, walking, cycling, or public transit directions to this place'),
                onComplete: action,
                onOpen: () async {
                  print('The $feature7 overlay is about to be displayed.');
                  return true;
                },
                child: IconButton(
                  icon: icon1,
                  onPressed: action,
                ),
              ),
            ],
          ),
        ),
        leading: StatefulBuilder(
          builder:
              (BuildContext context, void Function(void Function()) setState) =>
                  DescribedFeatureOverlay(
            featureId: feature1,
            tapTarget: icon2,
            backgroundColor: Colors.teal,
            title: const Text(
                'This is overly long on purpose to test OverflowMode.clip!'),
            overflowMode: feature1OverflowMode,
            enablePulsingAnimation: feature1EnablePulsingAnimation,
            description: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                const Text(
                    'Also, notice how the pulsing animation is not playing because it is deactivated for this feature.'),
                FlatButton(
                    child: Text('Toggle enablePulsingAnimation',
                        style: Theme.of(context)
                            .textTheme
                            .button
                            .copyWith(color: Colors.white)),
                    onPressed: () => setState(() {
                          feature1EnablePulsingAnimation =
                              !feature1EnablePulsingAnimation;
                        })),
                const Text(
                    'Ignore the items below or tap the button to toggle between OverflowMode.clip and OverflowMode.doNothing!'),
                FlatButton(
                    child: Text('Toggle overflowMode',
                        style: Theme.of(context)
                            .textTheme
                            .button
                            .copyWith(color: Colors.white)),
                    onPressed: () => setState(() {
                          feature1OverflowMode =
                              feature1OverflowMode == OverflowMode.clipContent
                                  ? OverflowMode.ignore
                                  : OverflowMode.clipContent;
                        })),
                for (int n = 42; n > 0; n--)
                  const Text('Testing clipping (ignore or toggle)',
                      style: TextStyle(backgroundColor: Colors.black)),
              ],
            ),
            child: IconButton(
              icon: icon2,
              onPressed: () {},
            ),
          ),
        ),
        actions: <Widget>[
          DescribedFeatureOverlay(
            featureId: feature2,
            tapTarget: icon3,
            backgroundColor: Colors.green,
            title: const Text('Search your compounds'),
            description: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                const Text(
                    'Tap the magnifying glass to quickly scan your compounds'),
                FlatButton(
                  padding: const EdgeInsets.all(0),
                  child: Text('Understood',
                      style: Theme.of(context)
                          .textTheme
                          .button
                          .copyWith(color: Colors.white)),
                  onPressed: () async =>
                      FeatureDiscovery.completeCurrentStep(context),
                ),
                FlatButton(
                  padding: const EdgeInsets.all(0),
                  child: Text('Dismiss',
                      style: Theme.of(context)
                          .textTheme
                          .button
                          .copyWith(color: Colors.white)),
                  onPressed: () => FeatureDiscovery.dismissAll(context),
                ),
              ],
            ),
            child: IconButton(
              icon: icon3,
              onPressed: () {},
            ),
          ),
        ],
      ),
      body: const Content(),
      floatingActionButton: StatefulBuilder(
        builder:
            (BuildContext context, void Function(void Function()) setState) =>
                DescribedFeatureOverlay(
          featureId: feature3,
          tapTarget: icon4,
          backgroundColor: Colors.green,
          overflowMode: OverflowMode.extendBackground,
          title: const Text('FAB feature'),
          description: Column(children: <Widget>[
            const Text(
                'This is overly long to test OverflowMode.extendBackground. The green circle should be large enough to cover all of the text.'),
            FlatButton(
                child: Text('Add another item',
                    style: Theme.of(context)
                        .textTheme
                        .button
                        .copyWith(color: Colors.white)),
                onPressed: () => setState(() {
                      feature3ItemCount++;
                    })),
            for (int n = feature3ItemCount; n > 0; n--)
              const Text('Testing OverflowMode.extendBackground'),
          ]),
          child: FloatingActionButton(
            onPressed: () {},
            tooltip: 'Increment',
            child: icon4,
          ),
        ),
      ),
    );
  }
}

class Content extends StatefulWidget {
  const Content({Key key}) : super(key: key);

  @override
  _ContentState createState() => _ContentState();
}

class _ContentState extends State<Content> {
  GlobalKey<EnsureVisibleState> ensureKey;
  GlobalKey<EnsureVisibleState> ensureKey2;

  @override
  void initState() {
    ensureKey = GlobalKey<EnsureVisibleState>();
    ensureKey2 = GlobalKey<EnsureVisibleState>();

    WidgetsBinding.instance.addPostFrameCallback((_) {
      FeatureDiscovery.discoverFeatures(
        context,
        const <String>{
          feature7,
          feature1,
          feature2,
          feature3,
          feature4,
          feature6,
          feature5
        },
      );
    });
    super.initState();
  }

  @override
  void dispose() {
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    var feature6ItemCount = 0;

    return Stack(
      children: <Widget>[
        SingleChildScrollView(
          child: Column(
            children: <Widget>[
              Container(
                height: 200,
                width: double.infinity,
                child: const Text(
                    'Imagine there would be a beautiful picture here.'),
              ),
              RaisedButton.icon(
                onPressed: () {
                  FeatureDiscovery.clearPreferences(context, const <String>{
                    feature1,
                    feature2,
                    feature3,
                    feature4,
                    feature6,
                    feature5
                  });
                },
                icon: Icon(Icons.clear),
                label: const Text('Reset Preferences'),
              ),
              Container(
                width: double.infinity,
                padding: const EdgeInsets.all(16.0),
                color: Colors.blue,
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    const Padding(
                      padding: EdgeInsets.only(bottom: 8.0),
                      child: Text(
                        'DISH REPUBLIC',
                        style: TextStyle(
                          color: Colors.white,
                          fontSize: 24.0,
                        ),
                      ),
                    ),
                    const Text(
                      'Eat',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 16.0,
                      ),
                    ),
                  ],
                ),
              ),
              Container(
                height: 600.0,
                color: Colors.orangeAccent,
              ),
              Container(
                width: double.infinity,
                padding: const EdgeInsets.all(16.0),
                child: DescribedFeatureOverlay(
                  featureId: feature5,
                  tapTarget: const Icon(Icons.drive_eta),
                  backgroundColor: Colors.green,
                  onComplete: () async {
                    print('Tapped tap target of $feature5.');
                    return true;
                  },
                  onOpen: () async {
                    WidgetsBinding.instance.addPostFrameCallback((_) {
                      ensureKey.currentState.ensureVisible(
                        preciseAlignment: 0.5,
                        duration: const Duration(milliseconds: 400),
                      );
                    });
                    return true;
                  },
                  title: const Text('Discover Features'),
                  description: const Text(
                      'Find all available features in this application with this button.'),
                  contentLocation: ContentLocation.below,
                  child: EnsureVisible(
                    key: ensureKey,
                    child: RaisedButton(
                      child: const Text('Start Feature Discovery'),
                      onPressed: () {
                        FeatureDiscovery.discoverFeatures(
                          context,
                          const <String>{
                            feature1,
                            feature2,
                            feature3,
                            feature4,
                            feature6,
                            feature5
                          },
                        );
                      },
                    ),
                  ),
                ),
              ),
              Container(
                height: 1500,
                color: Colors.blueAccent,
              ),
              StatefulBuilder(
                builder: (BuildContext context,
                        void Function(void Function()) setState) =>
                    DescribedFeatureOverlay(
                  featureId: feature6,
                  tapTarget: const Icon(Icons.drive_eta),
                  backgroundColor: Colors.green,
                  onComplete: () async {
                    print('Tapped tap target of $feature6.');
                    return true;
                  },
                  onOpen: () async {
                    WidgetsBinding.instance.addPostFrameCallback((_) {
                      ensureKey2.currentState.ensureVisible(
                          duration: const Duration(milliseconds: 600));
                    });
                    return true;
                  },
                  description: Column(children: <Widget>[
                    const Text(
                        'You can test OverflowMode.wrapBackground here.'),
                    FlatButton(
                        padding: const EdgeInsets.all(0),
                        child: Text('Add item',
                            style: Theme.of(context)
                                .textTheme
                                .button
                                .copyWith(color: Colors.white)),
                        onPressed: () => setState(() {
                              feature6ItemCount++;
                            })),
                    for (int n = feature6ItemCount; n > 0; n--)
                      const Text('Testing OverflowMode.wrapBackground'),
                  ]),
                  overflowMode: OverflowMode.wrapBackground,
                  child: EnsureVisible(
                    key: ensureKey2,
                    child: const Text(
                      'Custom text',
                    ),
                  ),
                ),
              ),
              Container(
                height: 300,
                color: Colors.red,
              ),
            ],
          ),
        ),
        Positioned(
          top: 200.0,
          right: 0.0,
          child: FractionalTranslation(
            translation: const Offset(-.5, -0.5),
            child: DescribedFeatureOverlay(
              featureId: feature4,
              tapTarget: const Icon(Icons.drive_eta),
              backgroundColor: Colors.green,
              onOpen: () async {
                print('Tapped tap target of $feature4.');
                return true;
              },
              title: const Text('Find the fastest route'),
              description: const Text(
                  'Get car, walking, cycling or public transit directions to this place.'),
              child: FloatingActionButton(
                backgroundColor: Colors.white,
                foregroundColor: Colors.blue,
                child: const Icon(Icons.drive_eta),
                onPressed: () {
                  print('Floating action button tapped.');
                },
              ),
            ),
          ),
        ),
      ],
    );
  }
}

Use this package as a library

1. Depend on it

Add this to your package's pubspec.yaml file:


dependencies:
  feature_discovery: ^0.8.0

2. Install it

You can install packages from the command line:

with Flutter:


$ flutter pub get

Alternatively, your editor might support flutter pub get. Check the docs for your editor to learn more.

3. Import it

Now in your Dart code, you can use:


import 'package:feature_discovery/feature_discovery.dart';
  
Popularity:
Describes how popular the package is relative to other packages. [more]
86
Health:
Code health derived from static analysis. [more]
100
Maintenance:
Reflects how tidy and up-to-date the package is. [more]
100
Overall:
Weighted score of the above. [more]
93
Learn more about scoring.

We analyzed this package on Apr 3, 2020, and provided a score, details, and suggestions below. Analysis was completed with status completed using:

  • Dart: 2.7.1
  • pana: 0.13.6
  • Flutter: 1.12.13+hotfix.8

Health suggestions

Fix lib/src/foundation/bloc.dart. (-0.50 points)

Analysis of lib/src/foundation/bloc.dart reported 1 hint:

line 141 col 7: 'await' applied to 'bool', which is not a 'Future'.

Format lib/src/foundation/feature_discovery.dart.

Run flutter format to format lib/src/foundation/feature_discovery.dart.

Dependencies

Package Constraint Resolved Available
Direct dependencies
Dart SDK >=2.2.2 <3.0.0
flutter 0.0.0
provider ^4.0.4 4.0.4 4.1.0-dev+2
shared_preferences ^0.5.6+1 0.5.6+3
Transitive dependencies
collection 1.14.11 1.14.12
flutter_web_plugins 0.0.0
meta 1.1.8
nested 0.0.4
shared_preferences_macos 0.0.1+6
shared_preferences_platform_interface 1.0.3
shared_preferences_web 0.1.2+4
sky_engine 0.0.99
typed_data 1.1.6
vector_math 2.0.8
Dev dependencies
flutter_test
pedantic 1.9.0