Manage your dependencies in the widget tree, access them from the BuildContext, and replace them with test doubles for testing.
ioc_container is a dependency injection and service location library for Dart. You can use it in Flutter as a service locator like the GetIt package. flutter_ioc_container is an extension for ioc_container that exposes the library throughout the widget tree so you can use it like Provider. It provides extension methods on BuildContext to allow you to get instances of your dependencies anywhere in the widget tree.
This accesses the CounterController to increment and grab the current value:
FloatingActionButton.extended(
icon: const Icon(Icons.add),
// Increment the value
onPressed: context<CounterController>().increment,
label: Text(
// Display the value
context<CounterController>().value.toString(),
style: Theme.of(context).textTheme.headlineMedium,
),
),See the ioc_container documentation for a more comprehensive guide.
Add the following line to your pubspec.yaml file under the dependencies section:
dependencies:
flutter_ioc_container: <latest version>Run flutter pub get to download the dependencies.
Or, you can install the package from the command line:
flutter pub add flutter_ioc_container- Put a
CompositionRootwidget at the base of your widget tree. This propagates the container throughout the widget tree as an inherited widget. - Specify the
configureBuildparameter to configure the container. - Access the dependencies throughout the widget tree via the
BuildContext.
import 'package:flutter/material.dart';
import 'package:flutter_ioc_container/flutter_ioc_container.dart';
void main() {
runApp(
CompositionRoot.configureBuild(
MaterialApp(
home: Scaffold(
body: Builder(builder: (context) => const BasicWidget()),
),
),
(builder) => builder.addSingleton((container) => 'test'),
),
);
}
class BasicWidget extends StatelessWidget {
const BasicWidget({super.key});
@override
Widget build(BuildContext context) => Text(context<String>());
}If you need a set of dependencies that have a short life and you need to dispose of them afterward, something in the widget tree needs to hold onto a scoped container. Get a scoped container by calling context.scoped(). One approach is to put the scoped container in the State of a StatefulWidget and dispose of the contents in the dispose() method of the State.
This example creates a scoped container on didChangeDependencies. It exists for the lifespan of the state, and the resources get disposed when the widget tree disposes of this widget.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_ioc_container/flutter_ioc_container.dart';
import 'package:ioc_container/ioc_container.dart';
class DisposableResources {
String display = 'hello world';
void dispose() {
// ignore: avoid_print
print('Disposed');
}
}
void main() {
runApp(
CompositionRoot.configureBuild(
MaterialApp(
home: Scaffold(
body: Builder(builder: (context) => const BasicWidget()),
),
),
(builder) => builder.add(
(container) => DisposableResources(),
dispose: (service) => service.dispose(),
),
),
);
}
class BasicWidget extends StatefulWidget {
const BasicWidget({super.key});
@override
State<BasicWidget> createState() => _BasicWidgetState();
}
class _BasicWidgetState extends State<BasicWidget> {
late final IocContainer scopedContainer;
@override
void didChangeDependencies() {
super.didChangeDependencies();
scopedContainer = context.scoped();
}
@override
void dispose() {
super.dispose();
unawaited(scopedContainer.dispose());
}
@override
Widget build(BuildContext context) =>
Text(scopedContainer<DisposableResources>().display);
}See more on scoping here.
If your dependency requires async initialization, you can do this using addAsync. You can use the FutureBuilder widget to render the object when it is available. Async singletons come with a guarantee of no reentrancy(async locking), so it is safe to grab the singleton anywhere in the app and the future will resolve once.
Here's an example:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_ioc_container/flutter_ioc_container.dart';
void main() {
runApp(
CompositionRoot(
compose: BuildCompose(
(builder) => builder.addSingletonAsync(
(container) async => Future<String>.delayed(
const Duration(seconds: 5),
() => 'Hello world!',
),
),
),
child: MaterialApp(
home: Scaffold(
body: Builder(builder: (context) => const BasicAsyncWidget()),
),
),
),
);
}
class BasicAsyncWidget extends StatefulWidget {
const BasicAsyncWidget({super.key});
@override
State<BasicAsyncWidget> createState() => _BasicAsyncWidgetState();
}
class _BasicAsyncWidgetState extends State<BasicAsyncWidget> {
late final Future<String> future;
@override
void didChangeDependencies() {
// ignore: discarded_futures
future = context.getAsync<String>();
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) => FutureBuilder(
// ignore: discarded_futures
future: future,
builder: (ctx, ss) => ss.connectionState == ConnectionState.done
? Text(ss.data!)
: const CircularProgressIndicator(),
);
}See more on async injection here.
Pass a configureOverrides function into your root widget. This allows you to replace dependencies with test doubles for testing. See the example widget tests for a full example.
class MyApp extends StatelessWidget {
const MyApp({
super.key,
this.configureOverrides,
});
// This allows us to override the dependencies for testing. Take a look at
// the widget tests
final ConfigureBuild? configureOverrides;
@override
Widget build(BuildContext context) => CompositionRoot(
compose: BuildCompose(
configureOverrides: configureOverrides,
(builder) => builder
// Adds a singleton CounterController to the container
..addSingleton(
(container) => CounterController(),
),
// [...] See the example folder of this package for a full example
),
);
}This example overrides the dependency with a MockValueNotifier:
testWidgets('Basic Smoke Test', (tester) async {
final mockValueNotifier = MockValueNotifier();
await tester.pumpWidget(
MyApp(
// This is how you substitute dependencies with test doubles
configureOverrides: (builder) => builder
.addSingleton<CounterController>((container) => mockValueNotifier),
),
);
// Initial value
expect(find.text('0'), findsOneWidget);
// Tap the button
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
// Verify value
expect(find.text('1'), findsOneWidget);
expect(find.text('0'), findsNothing);
// Ensure we're using the mock dependency
expect(mockValueNotifier.hasCalls, isTrue);
});See more on testing here.
