blob: 6c2281dc29a9a7879f44bedb2705f1a542d786a7 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/semantics_tester.dart';
void main() {
// Pumps and ensures that the BottomSheet animates non-linearly.
Future<void> checkNonLinearAnimation(WidgetTester tester) async {
final Offset firstPosition = tester.getCenter(find.text('BottomSheet'));
await tester.pump(const Duration(milliseconds: 30));
final Offset secondPosition = tester.getCenter(find.text('BottomSheet'));
await tester.pump(const Duration(milliseconds: 30));
final Offset thirdPosition = tester.getCenter(find.text('BottomSheet'));
final double dyDelta1 = secondPosition.dy - firstPosition.dy;
final double dyDelta2 = thirdPosition.dy - secondPosition.dy;
// If the animation were linear, these two values would be the same.
expect(dyDelta1, isNot(moreOrLessEquals(dyDelta2, epsilon: 0.1)));
}
testWidgets('Throw if enable drag without an animation controller', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/89168
await tester.pumpWidget(
MaterialApp(
home: BottomSheet(
onClosing: () {},
builder: (_) =>
Container(height: 200, color: Colors.red, child: const Text('BottomSheet')),
),
),
);
final FlutterExceptionHandler? handler = FlutterError.onError;
FlutterErrorDetails? error;
FlutterError.onError = (FlutterErrorDetails details) {
error = details;
};
await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0));
expect(error, isNotNull);
FlutterError.onError = handler;
});
testWidgets('Disposing app while bottom sheet is disappearing does not crash', (
WidgetTester tester,
) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
await tester.pump();
expect(find.text('BottomSheet'), findsNothing);
// Bring up bottom sheet.
var showBottomSheetThenCalled = false;
showModalBottomSheet<void>(
context: savedContext,
builder: (BuildContext context) => const Text('BottomSheet'),
).then<void>((void value) {
showBottomSheetThenCalled = true;
});
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
expect(showBottomSheetThenCalled, isFalse);
// Start closing animation of Bottom sheet.
tester.state<NavigatorState>(find.byType(Navigator)).pop();
await tester.pump();
// Dispose app by replacing it with a container. This shouldn't crash.
await tester.pumpWidget(Container());
});
testWidgets('Swiping down a BottomSheet should dismiss it by default', (
WidgetTester tester,
) async {
final scaffoldKey = GlobalKey<ScaffoldState>();
var showBottomSheetThenCalled = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
await tester.pump();
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsNothing);
scaffoldKey.currentState!
.showBottomSheet((BuildContext context) {
return const SizedBox(height: 200.0, child: Text('BottomSheet'));
})
.closed
.whenComplete(() {
showBottomSheetThenCalled = true;
});
await tester.pumpAndSettle();
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsOneWidget);
// Swipe the bottom sheet to dismiss it.
await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0));
await tester.pumpAndSettle(); // Bottom sheet dismiss animation.
expect(showBottomSheetThenCalled, isTrue);
expect(find.text('BottomSheet'), findsNothing);
});
testWidgets('Swiping down a BottomSheet should not dismiss it when enableDrag is false', (
WidgetTester tester,
) async {
final scaffoldKey = GlobalKey<ScaffoldState>();
var showBottomSheetThenCalled = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
await tester.pump();
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsNothing);
scaffoldKey.currentState!
.showBottomSheet((BuildContext context) {
return const SizedBox(height: 200.0, child: Text('BottomSheet'));
}, enableDrag: false)
.closed
.whenComplete(() {
showBottomSheetThenCalled = true;
});
await tester.pumpAndSettle();
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsOneWidget);
// Swipe the bottom sheet, attempting to dismiss it.
await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0));
await tester.pumpAndSettle(); // Bottom sheet should not dismiss.
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsOneWidget);
});
testWidgets('Swiping down a BottomSheet should dismiss it when enableDrag is true', (
WidgetTester tester,
) async {
final scaffoldKey = GlobalKey<ScaffoldState>();
var showBottomSheetThenCalled = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
await tester.pump();
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsNothing);
scaffoldKey.currentState!
.showBottomSheet((BuildContext context) {
return const SizedBox(height: 200.0, child: Text('BottomSheet'));
}, enableDrag: true)
.closed
.whenComplete(() {
showBottomSheetThenCalled = true;
});
await tester.pumpAndSettle();
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsOneWidget);
// Swipe the bottom sheet to dismiss it.
await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0));
await tester.pumpAndSettle(); // Bottom sheet dismiss animation.
expect(showBottomSheetThenCalled, isTrue);
expect(find.text('BottomSheet'), findsNothing);
});
testWidgets('Tapping on a BottomSheet should not trigger a rebuild when enableDrag is true', (
WidgetTester tester,
) async {
// Regression test for https://github.com/flutter/flutter/issues/126833.
final scaffoldKey = GlobalKey<ScaffoldState>();
var buildCount = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
await tester.pump();
expect(buildCount, 0);
expect(find.text('BottomSheet'), findsNothing);
scaffoldKey.currentState!.showBottomSheet((BuildContext context) {
buildCount++;
return const SizedBox(height: 200.0, child: Text('BottomSheet'));
}, enableDrag: true);
await tester.pumpAndSettle();
expect(buildCount, 1);
expect(find.text('BottomSheet'), findsOneWidget);
// Tap on bottom sheet should not trigger a rebuild.
await tester.tap(find.text('BottomSheet'));
await tester.pumpAndSettle();
expect(buildCount, 1);
expect(find.text('BottomSheet'), findsOneWidget);
});
testWidgets('Modal BottomSheet builder should only be called once', (WidgetTester tester) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
var numBuilderCalls = 0;
showModalBottomSheet<void>(
context: savedContext,
isDismissible: false,
builder: (BuildContext context) {
numBuilderCalls++;
return const Text('BottomSheet');
},
);
await tester.pumpAndSettle();
expect(numBuilderCalls, 1);
// Swipe the bottom sheet to dismiss it.
await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0));
await tester.pumpAndSettle(); // Bottom sheet dismiss animation.
expect(numBuilderCalls, 1);
});
testWidgets('Tapping on a modal BottomSheet should not dismiss it', (WidgetTester tester) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
await tester.pump();
expect(find.text('BottomSheet'), findsNothing);
var showBottomSheetThenCalled = false;
showModalBottomSheet<void>(
context: savedContext,
builder: (BuildContext context) => const Text('BottomSheet'),
).then<void>((void value) {
showBottomSheetThenCalled = true;
});
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
expect(showBottomSheetThenCalled, isFalse);
// Tap on the bottom sheet itself, it should not be dismissed
await tester.tap(find.text('BottomSheet'));
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
expect(showBottomSheetThenCalled, isFalse);
});
testWidgets('Tapping outside a modal BottomSheet should dismiss it by default', (
WidgetTester tester,
) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
await tester.pump();
expect(find.text('BottomSheet'), findsNothing);
var showBottomSheetThenCalled = false;
showModalBottomSheet<void>(
context: savedContext,
builder: (BuildContext context) => const Text('BottomSheet'),
).then<void>((void value) {
showBottomSheetThenCalled = true;
});
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
expect(showBottomSheetThenCalled, isFalse);
// Tap above the bottom sheet to dismiss it.
await tester.tapAt(const Offset(20.0, 20.0));
await tester.pumpAndSettle(); // Bottom sheet dismiss animation.
expect(showBottomSheetThenCalled, isTrue);
expect(find.text('BottomSheet'), findsNothing);
});
testWidgets('Tapping outside a modal BottomSheet should dismiss it when isDismissible=true', (
WidgetTester tester,
) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
await tester.pump();
expect(find.text('BottomSheet'), findsNothing);
var showBottomSheetThenCalled = false;
showModalBottomSheet<void>(
context: savedContext,
builder: (BuildContext context) => const Text('BottomSheet'),
).then<void>((void value) {
showBottomSheetThenCalled = true;
});
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
expect(showBottomSheetThenCalled, isFalse);
// Tap above the bottom sheet to dismiss it.
await tester.tapAt(const Offset(20.0, 20.0));
await tester.pumpAndSettle(); // Bottom sheet dismiss animation.
expect(showBottomSheetThenCalled, isTrue);
expect(find.text('BottomSheet'), findsNothing);
});
testWidgets('Verify that the BottomSheet animates non-linearly', (WidgetTester tester) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
await tester.pump();
expect(find.text('BottomSheet'), findsNothing);
showModalBottomSheet<void>(
context: savedContext,
builder: (BuildContext context) => const Text('BottomSheet'),
);
await tester.pump();
await checkNonLinearAnimation(tester);
await tester.pumpAndSettle();
// Tap above the bottom sheet to dismiss it.
await tester.tapAt(const Offset(20.0, 20.0));
await tester.pump();
await checkNonLinearAnimation(tester);
await tester.pumpAndSettle(); // Bottom sheet dismiss animation.
expect(find.text('BottomSheet'), findsNothing);
});
// Regression test for https://github.com/flutter/flutter/issues/121098
testWidgets('Verify that accessibleNavigation has no impact on the BottomSheet animation', (
WidgetTester tester,
) async {
await tester.pumpWidget(
MaterialApp(
builder: (BuildContext context, Widget? child) {
return MediaQuery(data: const MediaQueryData(accessibleNavigation: true), child: child!);
},
home: const Center(child: Text('Test')),
),
);
await tester.pump();
expect(find.text('BottomSheet'), findsNothing);
final BuildContext homeContext = tester.element(find.text('Test'));
showModalBottomSheet<void>(
context: homeContext,
builder: (BuildContext context) => const Text('BottomSheet'),
);
await tester.pump();
await checkNonLinearAnimation(tester);
await tester.pumpAndSettle();
});
testWidgets(
'Tapping outside a modal BottomSheet should not dismiss it when isDismissible=false',
(WidgetTester tester) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
await tester.pump();
expect(find.text('BottomSheet'), findsNothing);
var showBottomSheetThenCalled = false;
showModalBottomSheet<void>(
context: savedContext,
builder: (BuildContext context) => const Text('BottomSheet'),
isDismissible: false,
).then<void>((void value) {
showBottomSheetThenCalled = true;
});
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
expect(showBottomSheetThenCalled, isFalse);
// Tap above the bottom sheet, attempting to dismiss it.
await tester.tapAt(const Offset(20.0, 20.0));
await tester.pumpAndSettle(); // Bottom sheet should not dismiss.
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsOneWidget);
},
);
testWidgets('Swiping down a modal BottomSheet should dismiss it by default', (
WidgetTester tester,
) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
await tester.pump();
expect(find.text('BottomSheet'), findsNothing);
var showBottomSheetThenCalled = false;
showModalBottomSheet<void>(
context: savedContext,
isDismissible: false,
builder: (BuildContext context) => const Text('BottomSheet'),
).then<void>((void value) {
showBottomSheetThenCalled = true;
});
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
expect(showBottomSheetThenCalled, isFalse);
// Swipe the bottom sheet to dismiss it.
await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0));
await tester.pumpAndSettle(); // Bottom sheet dismiss animation.
expect(showBottomSheetThenCalled, isTrue);
expect(find.text('BottomSheet'), findsNothing);
});
testWidgets('Swiping down a modal BottomSheet should not dismiss it when enableDrag is false', (
WidgetTester tester,
) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
await tester.pump();
expect(find.text('BottomSheet'), findsNothing);
var showBottomSheetThenCalled = false;
showModalBottomSheet<void>(
context: savedContext,
isDismissible: false,
enableDrag: false,
builder: (BuildContext context) => const Text('BottomSheet'),
).then<void>((void value) {
showBottomSheetThenCalled = true;
});
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
expect(showBottomSheetThenCalled, isFalse);
// Swipe the bottom sheet, attempting to dismiss it.
await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0));
await tester.pumpAndSettle(); // Bottom sheet should not dismiss.
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsOneWidget);
});
testWidgets('Swiping down a modal BottomSheet should dismiss it when enableDrag is true', (
WidgetTester tester,
) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
await tester.pump();
expect(find.text('BottomSheet'), findsNothing);
var showBottomSheetThenCalled = false;
showModalBottomSheet<void>(
context: savedContext,
isDismissible: false,
builder: (BuildContext context) => const Text('BottomSheet'),
).then<void>((void value) {
showBottomSheetThenCalled = true;
});
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
expect(showBottomSheetThenCalled, isFalse);
// Swipe the bottom sheet to dismiss it.
await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0));
await tester.pumpAndSettle(); // Bottom sheet dismiss animation.
expect(showBottomSheetThenCalled, isTrue);
expect(find.text('BottomSheet'), findsNothing);
});
testWidgets('Modal BottomSheet builder should only be called once', (WidgetTester tester) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
var numBuilderCalls = 0;
showModalBottomSheet<void>(
context: savedContext,
isDismissible: false,
builder: (BuildContext context) {
numBuilderCalls++;
return const Text('BottomSheet');
},
);
await tester.pumpAndSettle();
expect(numBuilderCalls, 1);
// Swipe the bottom sheet to dismiss it.
await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0));
await tester.pumpAndSettle(); // Bottom sheet dismiss animation.
expect(numBuilderCalls, 1);
});
testWidgets('Verify that a downwards fling dismisses a persistent BottomSheet', (
WidgetTester tester,
) async {
final scaffoldKey = GlobalKey<ScaffoldState>();
var showBottomSheetThenCalled = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsNothing);
scaffoldKey.currentState!
.showBottomSheet((BuildContext context) {
return Container(margin: const EdgeInsets.all(40.0), child: const Text('BottomSheet'));
})
.closed
.whenComplete(() {
showBottomSheetThenCalled = true;
});
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsNothing);
await tester.pump(); // bottom sheet show animation starts
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(const Duration(seconds: 1)); // animation done
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsOneWidget);
// The fling below must be such that the velocity estimation examines an
// offset greater than the kTouchSlop. Too slow or too short a distance, and
// it won't trigger. Also, it must not be so much that it drags the bottom
// sheet off the screen, or we won't see it after we pump!
await tester.fling(find.text('BottomSheet'), const Offset(0.0, 50.0), 2000.0);
await tester.pump(); // drain the microtask queue (Future completion callback)
expect(showBottomSheetThenCalled, isTrue);
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(); // bottom sheet dismiss animation starts
expect(showBottomSheetThenCalled, isTrue);
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(const Duration(seconds: 1)); // animation done
expect(showBottomSheetThenCalled, isTrue);
expect(find.text('BottomSheet'), findsNothing);
});
testWidgets('Verify that dragging past the bottom dismisses a persistent BottomSheet', (
WidgetTester tester,
) async {
// This is a regression test for https://github.com/flutter/flutter/issues/5528
final scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
scaffoldKey.currentState!.showBottomSheet((BuildContext context) {
return Container(margin: const EdgeInsets.all(40.0), child: const Text('BottomSheet'));
});
await tester.pump(); // bottom sheet show animation starts
await tester.pump(const Duration(seconds: 1)); // animation done
expect(find.text('BottomSheet'), findsOneWidget);
await tester.fling(find.text('BottomSheet'), const Offset(0.0, 400.0), 1000.0);
await tester.pump(); // drain the microtask queue (Future completion callback)
await tester.pump(); // bottom sheet dismiss animation starts
await tester.pump(const Duration(seconds: 1)); // animation done
expect(find.text('BottomSheet'), findsNothing);
});
testWidgets('modal BottomSheet has no top MediaQuery', (WidgetTester tester) async {
late BuildContext outerContext;
late BuildContext innerContext;
await tester.pumpWidget(
Localizations(
locale: const Locale('en', 'US'),
delegates: const <LocalizationsDelegate<dynamic>>[
DefaultWidgetsLocalizations.delegate,
DefaultMaterialLocalizations.delegate,
],
child: Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(padding: EdgeInsets.all(50.0), size: Size(400.0, 600.0)),
child: Navigator(
onGenerateRoute: (_) {
return PageRouteBuilder<void>(
pageBuilder:
(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
outerContext = context;
return Container();
},
);
},
),
),
),
),
);
showModalBottomSheet<void>(
context: outerContext,
builder: (BuildContext context) {
innerContext = context;
return Container();
},
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(MediaQuery.of(outerContext).padding, const EdgeInsets.all(50.0));
expect(
MediaQuery.of(innerContext).padding,
const EdgeInsets.only(left: 50.0, right: 50.0, bottom: 50.0),
);
});
testWidgets('modal BottomSheet can insert a SafeArea', (WidgetTester tester) async {
late BuildContext outerContext;
late BuildContext innerContext;
await tester.pumpWidget(
Localizations(
locale: const Locale('en', 'US'),
delegates: const <LocalizationsDelegate<dynamic>>[
DefaultWidgetsLocalizations.delegate,
DefaultMaterialLocalizations.delegate,
],
child: Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(padding: EdgeInsets.all(50.0), size: Size(400.0, 600.0)),
child: Navigator(
onGenerateRoute: (_) {
return PageRouteBuilder<void>(
pageBuilder:
(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
outerContext = context;
return Container();
},
);
},
),
),
),
),
);
// Without a SafeArea (useSafeArea is false by default)
showModalBottomSheet<void>(
context: outerContext,
builder: (BuildContext context) {
innerContext = context;
return Container();
},
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Top padding is consumed and there is no SafeArea
expect(MediaQuery.of(innerContext).padding.top, 0);
expect(find.byType(SafeArea), findsNothing);
// With a SafeArea
showModalBottomSheet<void>(
context: outerContext,
useSafeArea: true,
builder: (BuildContext context) {
innerContext = context;
return Container();
},
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// A SafeArea is inserted, with left / top / right true but bottom false.
final Finder safeAreaWidgetFinder = find.byType(SafeArea);
expect(safeAreaWidgetFinder, findsOneWidget);
final safeAreaWidget = safeAreaWidgetFinder.evaluate().single.widget as SafeArea;
expect(safeAreaWidget.left, true);
expect(safeAreaWidget.top, true);
expect(safeAreaWidget.right, true);
expect(safeAreaWidget.bottom, false);
// Because that SafeArea is inserted, no left / top / right padding remains
// for `builder` to consume. Bottom padding does remain.
expect(MediaQuery.of(innerContext).padding, const EdgeInsets.fromLTRB(0, 0, 0, 50.0));
});
testWidgets('modal BottomSheet has semantics', (WidgetTester tester) async {
final semantics = SemanticsTester(tester);
final scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
showModalBottomSheet<void>(
context: scaffoldKey.currentContext!,
builder: (BuildContext context) {
return const Text('BottomSheet');
},
);
await tester.pump(); // bottom sheet show animation starts
await tester.pump(const Duration(seconds: 1)); // animation done
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
children: <TestSemantics>[
TestSemantics(
children: <TestSemantics>[
TestSemantics(
children: <TestSemantics>[
TestSemantics(
label: 'Dialog',
textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[
SemanticsFlag.scopesRoute,
SemanticsFlag.namesRoute,
],
children: <TestSemantics>[
TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr),
],
),
],
),
],
),
TestSemantics(
children: <TestSemantics>[
TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss],
label: 'Scrim',
hintOverrides: const SemanticsHintOverrides(onTapHint: 'Close Bottom Sheet'),
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
ignoreTransform: true,
ignoreRect: true,
ignoreId: true,
),
);
semantics.dispose();
});
testWidgets('Verify that visual properties are passed through', (WidgetTester tester) async {
final scaffoldKey = GlobalKey<ScaffoldState>();
const Color color = Colors.pink;
const elevation = 9.0;
const ShapeBorder shape = BeveledRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
);
const Clip clipBehavior = Clip.antiAlias;
const Color barrierColor = Colors.red;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
showModalBottomSheet<void>(
context: scaffoldKey.currentContext!,
backgroundColor: color,
barrierColor: barrierColor,
elevation: elevation,
shape: shape,
clipBehavior: clipBehavior,
builder: (BuildContext context) {
return const Text('BottomSheet');
},
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final BottomSheet bottomSheet = tester.widget(find.byType(BottomSheet));
expect(bottomSheet.backgroundColor, color);
expect(bottomSheet.elevation, elevation);
expect(bottomSheet.shape, shape);
expect(bottomSheet.clipBehavior, clipBehavior);
final ModalBarrier modalBarrier = tester.widget(find.byType(ModalBarrier).last);
expect(modalBarrier.color, barrierColor);
});
testWidgets('Material3 - BottomSheet uses fallback values', (WidgetTester tester) async {
const Color surfaceColor = Colors.pink;
const Color surfaceTintColor = Colors.blue;
const ShapeBorder defaultShape = RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28.0)),
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
colorScheme: const ColorScheme.light(
surface: surfaceColor,
surfaceTint: surfaceTintColor,
),
),
home: Scaffold(
body: BottomSheet(
onClosing: () {},
builder: (BuildContext context) {
return Container();
},
),
),
),
);
final Finder finder = find.descendant(
of: find.byType(BottomSheet),
matching: find.byType(Material),
);
final Material material = tester.widget<Material>(finder);
expect(material.color, surfaceColor);
// Surface tint is no longer used by default.
expect(material.surfaceTintColor, Colors.transparent);
expect(material.elevation, 1.0);
expect(material.shape, defaultShape);
expect(tester.getSize(finder).width, 640);
});
testWidgets('Material3 - BottomSheet has transparent shadow', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: BottomSheet(
onClosing: () {},
builder: (BuildContext context) {
return Container();
},
),
),
),
);
final Material material = tester.widget<Material>(
find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)),
);
expect(material.shadowColor, Colors.transparent);
});
testWidgets('Material2 - Modal BottomSheet with ScrollController has semantics', (
WidgetTester tester,
) async {
final semantics = SemanticsTester(tester);
final scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(useMaterial3: false),
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
showModalBottomSheet<void>(
context: scaffoldKey.currentContext!,
builder: (BuildContext context) {
return DraggableScrollableSheet(
expand: false,
builder: (_, ScrollController controller) {
return SingleChildScrollView(controller: controller, child: const Text('BottomSheet'));
},
);
},
);
await tester.pump(); // bottom sheet show animation starts
await tester.pump(const Duration(seconds: 1)); // animation done
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
children: <TestSemantics>[
TestSemantics(
children: <TestSemantics>[
TestSemantics(
children: <TestSemantics>[
TestSemantics(
label: 'Dialog',
textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[
SemanticsFlag.scopesRoute,
SemanticsFlag.namesRoute,
],
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling],
children: <TestSemantics>[
TestSemantics(
label: 'BottomSheet',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
],
),
TestSemantics(
children: <TestSemantics>[
TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss],
label: 'Scrim',
hintOverrides: const SemanticsHintOverrides(onTapHint: 'Close Bottom Sheet'),
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
ignoreTransform: true,
ignoreRect: true,
ignoreId: true,
),
);
semantics.dispose();
});
testWidgets('Material3 - Modal BottomSheet with ScrollController has semantics', (
WidgetTester tester,
) async {
final semantics = SemanticsTester(tester);
final scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
showModalBottomSheet<void>(
context: scaffoldKey.currentContext!,
builder: (BuildContext context) {
return DraggableScrollableSheet(
expand: false,
builder: (_, ScrollController controller) {
return SingleChildScrollView(controller: controller, child: const Text('BottomSheet'));
},
);
},
);
await tester.pump(); // bottom sheet show animation starts
await tester.pump(const Duration(seconds: 1)); // animation done
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
children: <TestSemantics>[
TestSemantics(
children: <TestSemantics>[
TestSemantics(
children: <TestSemantics>[
TestSemantics(
label: 'Dialog',
textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[
SemanticsFlag.scopesRoute,
SemanticsFlag.namesRoute,
],
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling],
children: <TestSemantics>[
TestSemantics(
label: 'BottomSheet',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
],
),
TestSemantics(
children: <TestSemantics>[
TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss],
label: 'Scrim',
hintOverrides: const SemanticsHintOverrides(onTapHint: 'Close Bottom Sheet'),
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
ignoreTransform: true,
ignoreRect: true,
ignoreId: true,
),
);
semantics.dispose();
});
testWidgets('Material3 - Modal BottomSheet with drag handle has semantics', (
WidgetTester tester,
) async {
final semantics = SemanticsTester(tester);
final scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
showModalBottomSheet<void>(
context: scaffoldKey.currentContext!,
showDragHandle: true,
builder: (BuildContext context) {
return const Text('BottomSheet');
},
);
await tester.pump(); // bottom sheet show animation starts
await tester.pump(const Duration(seconds: 1)); // animation done
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
children: <TestSemantics>[
TestSemantics(
children: <TestSemantics>[
TestSemantics(
children: <TestSemantics>[
TestSemantics(
label: 'Dialog',
textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[
SemanticsFlag.scopesRoute,
SemanticsFlag.namesRoute,
],
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isButton],
actions: <SemanticsAction>[SemanticsAction.tap],
label: 'Dismiss',
textDirection: TextDirection.ltr,
),
TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr),
],
),
],
),
],
),
TestSemantics(
children: <TestSemantics>[
TestSemantics(
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss],
label: 'Scrim',
hintOverrides: const SemanticsHintOverrides(onTapHint: 'Close Bottom Sheet'),
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
ignoreTransform: true,
ignoreRect: true,
ignoreId: true,
),
);
semantics.dispose();
});
testWidgets('Drag handle color can take WidgetStateProperty', (WidgetTester tester) async {
const Color defaultColor = Colors.blue;
const Color hoveringColor = Colors.green;
Future<void> checkDragHandleAndColors() async {
await tester.pump(); // bottom sheet show animation starts
await tester.pump(const Duration(seconds: 1)); // animation done
final Finder dragHandle = find.bySemanticsLabel('Dismiss');
expect(tester.getSize(dragHandle), const Size(48, 48));
final Offset center = tester.getCenter(dragHandle);
final Offset edge = tester.getTopLeft(dragHandle) - const Offset(1, 1);
// Shows default drag handle color
final TestGesture gesture = await tester.createGesture(
kind: PointerDeviceKind.mouse,
pointer: 1,
);
await gesture.addPointer(location: edge);
await tester.pump();
var boxDecoration =
tester
.widget<Container>(
find.descendant(
of: dragHandle,
matching: find.byWidgetPredicate(
(Widget widget) => widget is Container && widget.decoration != null,
),
),
)
.decoration!
as BoxDecoration;
expect(boxDecoration.color, defaultColor);
// Shows hovering drag handle color
await gesture.moveTo(center);
await tester.pump();
boxDecoration =
tester
.widget<Container>(
find.descendant(
of: dragHandle,
matching: find.byWidgetPredicate(
(Widget widget) => widget is Container && widget.decoration != null,
),
),
)
.decoration!
as BoxDecoration;
expect(boxDecoration.color, hoveringColor);
await gesture.removePointer();
}
Widget buildScaffold(GlobalKey scaffoldKey) {
return MaterialApp(
theme: ThemeData(
bottomSheetTheme: BottomSheetThemeData(
dragHandleColor: WidgetStateColor.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.hovered)) {
return hoveringColor;
}
return defaultColor;
}),
),
),
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
);
}
var scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(buildScaffold(scaffoldKey));
showModalBottomSheet<void>(
context: scaffoldKey.currentContext!,
showDragHandle: true,
builder: (BuildContext context) {
return const Text('BottomSheet');
},
);
await checkDragHandleAndColors();
await tester.pumpWidget(Container()); // Reset
scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(buildScaffold(scaffoldKey));
scaffoldKey.currentState!.showBottomSheet((_) {
return Builder(
builder: (BuildContext context) {
return const SizedBox(height: 200.0, child: Text('Bottom Sheet'));
},
);
}, showDragHandle: true);
await checkDragHandleAndColors();
});
testWidgets('Drag handle interactive area size at minimum possible size', (
WidgetTester tester,
) async {
Widget buildScaffold(GlobalKey scaffoldKey, {Size? dragHandleSize}) {
return MaterialApp(
theme: ThemeData(bottomSheetTheme: BottomSheetThemeData(dragHandleSize: dragHandleSize)),
home: Scaffold(key: scaffoldKey),
);
}
const smallerDragHandleSize = Size(20, 20);
final scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(buildScaffold(scaffoldKey, dragHandleSize: smallerDragHandleSize));
showModalBottomSheet<void>(
context: scaffoldKey.currentContext!,
showDragHandle: true,
builder: (BuildContext context) {
return const Text('BottomSheet');
},
);
await tester.pump(); // Bottom sheet show animation starts.
await tester.pump(const Duration(seconds: 1)); // Animation done.
final Finder dragHandle = find.bySemanticsLabel('Dismiss');
expect(
tester.getSize(dragHandle),
const Size(kMinInteractiveDimension, kMinInteractiveDimension),
);
});
testWidgets('Drag handle interactive area size at given dragHandleSize', (
WidgetTester tester,
) async {
Widget buildScaffold(GlobalKey scaffoldKey, {Size? dragHandleSize}) {
return MaterialApp(
theme: ThemeData(bottomSheetTheme: BottomSheetThemeData(dragHandleSize: dragHandleSize)),
home: Scaffold(key: scaffoldKey),
);
}
const extendedDragHandleSize = Size(100, 50);
final scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(buildScaffold(scaffoldKey, dragHandleSize: extendedDragHandleSize));
showModalBottomSheet<void>(
context: scaffoldKey.currentContext!,
showDragHandle: true,
builder: (BuildContext context) {
return const Text('BottomSheet');
},
);
await tester.pump(); // Bottom sheet show animation starts.
await tester.pump(const Duration(seconds: 1)); // Animation done.
final Finder dragHandle = find.bySemanticsLabel('Dismiss');
expect(tester.getSize(dragHandle), extendedDragHandleSize);
});
testWidgets('showModalBottomSheet does not use root Navigator by default', (
WidgetTester tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Navigator(
onGenerateRoute: (RouteSettings settings) => MaterialPageRoute<void>(
builder: (_) {
return const _TestPage();
},
),
),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'Item 1'),
BottomNavigationBarItem(icon: Icon(Icons.style), label: 'Item 2'),
],
),
),
),
);
await tester.tap(find.text('Show bottom sheet'));
await tester.pumpAndSettle();
// Bottom sheet is displayed in correct position within the inner navigator
// and above the BottomNavigationBar.
final double tabBarHeight = tester.getSize(find.byType(BottomNavigationBar)).height;
expect(tester.getBottomLeft(find.byType(BottomSheet)).dy, 600 - tabBarHeight);
});
testWidgets('showModalBottomSheet uses root Navigator when specified', (
WidgetTester tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Navigator(
onGenerateRoute: (RouteSettings settings) => MaterialPageRoute<void>(
builder: (_) {
return const _TestPage(useRootNavigator: true);
},
),
),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'Item 1'),
BottomNavigationBarItem(icon: Icon(Icons.style), label: 'Item 2'),
],
),
),
),
);
await tester.tap(find.text('Show bottom sheet'));
await tester.pumpAndSettle();
// Bottom sheet is displayed in correct position above all content including
// the BottomNavigationBar.
expect(tester.getBottomLeft(find.byType(BottomSheet)).dy, 600.0);
});
testWidgets('Verify that route settings can be set in the showModalBottomSheet', (
WidgetTester tester,
) async {
final scaffoldKey = GlobalKey<ScaffoldState>();
const routeSettings = RouteSettings(name: 'route_name', arguments: 'route_argument');
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
late RouteSettings retrievedRouteSettings;
showModalBottomSheet<void>(
context: scaffoldKey.currentContext!,
routeSettings: routeSettings,
builder: (BuildContext context) {
retrievedRouteSettings = ModalRoute.settingsOf(context)!;
return const Text('BottomSheet');
},
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(retrievedRouteSettings, routeSettings);
});
testWidgets('Verify showModalBottomSheet use AnimationController if provided.', (
WidgetTester tester,
) async {
const tapTarget = Key('tap-target');
final controller = AnimationController(
vsync: const TestVSync(),
duration: const Duration(seconds: 2),
reverseDuration: const Duration(seconds: 2),
);
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return GestureDetector(
key: tapTarget,
onTap: () {
showModalBottomSheet<void>(
context: context,
// The default duration and reverseDuration is 1 second
transitionAnimationController: controller,
builder: (BuildContext context) {
return const Text('BottomSheet');
},
);
},
behavior: HitTestBehavior.opaque,
child: const SizedBox(height: 100.0, width: 100.0),
);
},
),
),
),
);
expect(find.text('BottomSheet'), findsNothing);
await tester.tap(find.byKey(tapTarget)); // Opening animation will start after tapping
await tester.pump();
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2000));
expect(find.text('BottomSheet'), findsOneWidget);
// Tapping above the bottom sheet to dismiss it.
await tester.tapAt(const Offset(20.0, 20.0)); // Closing animation will start after tapping
await tester.pump();
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2000));
// The bottom sheet should still be present at the very end of the animation.
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 1));
// The bottom sheet should not be showing any longer.
expect(find.text('BottomSheet'), findsNothing);
});
// Regression test for https://github.com/flutter/flutter/issues/87592
testWidgets('the framework do not dispose the transitionAnimationController provided by user.', (
WidgetTester tester,
) async {
const tapTarget = Key('tap-target');
final controller = AnimationController(
vsync: const TestVSync(),
duration: const Duration(seconds: 2),
reverseDuration: const Duration(seconds: 2),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return GestureDetector(
key: tapTarget,
onTap: () {
showModalBottomSheet<void>(
context: context,
// The default duration and reverseDuration is 1 second
transitionAnimationController: controller,
builder: (BuildContext context) {
return const Text('BottomSheet');
},
);
},
behavior: HitTestBehavior.opaque,
child: const SizedBox(height: 100.0, width: 100.0),
);
},
),
),
),
);
expect(find.text('BottomSheet'), findsNothing);
await tester.tap(find.byKey(tapTarget)); // Opening animation will start after tapping
await tester.pump();
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2000));
expect(find.text('BottomSheet'), findsOneWidget);
// Tapping above the bottom sheet to dismiss it.
await tester.tapAt(const Offset(20.0, 20.0)); // Closing animation will start after tapping
await tester.pump();
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2000));
// The bottom sheet should still be present at the very end of the animation.
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 1));
// The bottom sheet should not be showing any longer.
expect(find.text('BottomSheet'), findsNothing);
controller.dispose();
// Double disposal will throw.
expect(tester.takeException(), isNull);
});
testWidgets('Verify persistence BottomSheet use AnimationController if provided.', (
WidgetTester tester,
) async {
const tapTarget = Key('tap-target');
const tapTargetToClose = Key('tap-target-to-close');
final controller = AnimationController(
vsync: const TestVSync(),
duration: const Duration(seconds: 2),
reverseDuration: const Duration(seconds: 2),
);
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return GestureDetector(
key: tapTarget,
onTap: () {
showBottomSheet(
context: context,
// The default duration and reverseDuration is 1 second
transitionAnimationController: controller,
builder: (BuildContext context) {
return ElevatedButton(
key: tapTargetToClose,
onPressed: () => Navigator.pop(context),
child: const Text('BottomSheet'),
);
},
);
},
behavior: HitTestBehavior.opaque,
child: const SizedBox(height: 100.0, width: 100.0),
);
},
),
),
),
);
expect(find.text('BottomSheet'), findsNothing);
await tester.tap(find.byKey(tapTarget)); // Opening animation will start after tapping
await tester.pump();
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2000));
expect(find.text('BottomSheet'), findsOneWidget);
// Tapping button on the bottom sheet to dismiss it.
await tester.tap(find.byKey(tapTargetToClose)); // Closing animation will start after tapping
await tester.pump();
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2000));
// The bottom sheet should still be present at the very end of the animation.
expect(find.text('BottomSheet'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 1));
// The bottom sheet should not be showing any longer.
expect(find.text('BottomSheet'), findsNothing);
});
// Regression test for https://github.com/flutter/flutter/issues/87708
testWidgets('Each of the internal animation controllers should be disposed by the framework.', (
WidgetTester tester,
) async {
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
scaffoldKey.currentState!.showBottomSheet((_) {
return Builder(
builder: (BuildContext context) {
return Container(height: 200.0);
},
);
});
await tester.pump();
expect(find.byType(BottomSheet), findsOneWidget);
// The first sheet's animation is still running.
// Trigger the second sheet will remove the first sheet from tree.
scaffoldKey.currentState!.showBottomSheet((_) {
return Builder(
builder: (BuildContext context) {
return Container(height: 200.0);
},
);
});
await tester.pump();
expect(find.byType(BottomSheet), findsOneWidget);
// Remove the Scaffold from the tree.
await tester.pumpWidget(const SizedBox.shrink());
// If the internal animation controller do not dispose will throw
// FlutterError:<ScaffoldState#1981a(tickers: tracking 1 ticker) was disposed with an active
// Ticker.
expect(tester.takeException(), isNull);
});
// Regression test for https://github.com/flutter/flutter/issues/99627
testWidgets('The old route entry should be removed when a new sheet popup', (
WidgetTester tester,
) async {
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey();
PersistentBottomSheetController? sheetController;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
final ModalRoute<dynamic> route = ModalRoute.of(scaffoldKey.currentContext!)!;
expect(route.canPop, false);
scaffoldKey.currentState!.showBottomSheet((_) {
return Builder(
builder: (BuildContext context) {
return Container(height: 200.0);
},
);
});
await tester.pump();
expect(find.byType(BottomSheet), findsOneWidget);
expect(route.canPop, true);
// Trigger the second sheet will remove the first sheet from tree.
sheetController = scaffoldKey.currentState!.showBottomSheet((_) {
return Builder(
builder: (BuildContext context) {
return Container(height: 200.0);
},
);
});
await tester.pump();
expect(find.byType(BottomSheet), findsOneWidget);
expect(route.canPop, true);
sheetController.close();
expect(route.canPop, false);
});
// Regression test for https://github.com/flutter/flutter/issues/87708
testWidgets(
'The framework does not dispose of the transitionAnimationController provided by user.',
(WidgetTester tester) async {
const tapTarget = Key('tap-target');
const tapTargetToClose = Key('tap-target-to-close');
final controller = AnimationController(
vsync: const TestVSync(),
duration: const Duration(seconds: 2),
reverseDuration: const Duration(seconds: 2),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return GestureDetector(
key: tapTarget,
onTap: () {
showBottomSheet(
context: context,
transitionAnimationController: controller,
builder: (BuildContext context) {
return ElevatedButton(
key: tapTargetToClose,
onPressed: () => Navigator.pop(context),
child: const Text('BottomSheet'),
);
},
);
},
behavior: HitTestBehavior.opaque,
child: const SizedBox(height: 100.0, width: 100.0),
);
},
),
),
),
);
expect(find.text('BottomSheet'), findsNothing);
await tester.tap(find.byKey(tapTarget)); // Open the sheet.
await tester.pumpAndSettle(); // Finish the animation.
expect(find.text('BottomSheet'), findsOneWidget);
// Tapping button on the bottom sheet to dismiss it.
await tester.tap(find.byKey(tapTargetToClose)); // Closing the sheet.
await tester.pumpAndSettle(); // Finish the animation.
expect(find.text('BottomSheet'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
controller.dispose();
// Double dispose will throw.
expect(tester.takeException(), isNull);
},
);
testWidgets(
'The framework removes all animation listeners from foreign controllers when disposing.',
(WidgetTester tester) async {
final scaffoldKey = GlobalKey<ScaffoldState>();
final controller = _StatusTestAnimationController(
vsync: const TestVSync(),
duration: const Duration(seconds: 2),
reverseDuration: const Duration(seconds: 2),
);
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
await tester.pump();
expect(controller.isListening, isFalse);
scaffoldKey.currentState!.showBottomSheet((BuildContext context) {
return const SizedBox(height: 200.0, child: Text('BottomSheet'));
}, transitionAnimationController: controller);
await tester.pumpAndSettle();
expect(controller.isListening, isTrue);
expect(find.text('BottomSheet'), findsOneWidget);
// Swipe the bottom sheet to dismiss it.
await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0));
await tester.pumpAndSettle(); // Bottom sheet dismiss animation.
expect(controller.isListening, isFalse);
expect(find.text('BottomSheet'), findsNothing);
},
);
testWidgets(
'Calling PersistentBottomSheetController.close does not crash when it is not the current bottom sheet',
(WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/93717
PersistentBottomSheetController? sheetController1;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return SafeArea(
child: Column(
children: <Widget>[
ElevatedButton(
child: const Text('show 1'),
onPressed: () {
sheetController1 = Scaffold.of(
context,
).showBottomSheet((BuildContext context) => const Text('BottomSheet 1'));
},
),
ElevatedButton(
child: const Text('show 2'),
onPressed: () {
Scaffold.of(
context,
).showBottomSheet((BuildContext context) => const Text('BottomSheet 2'));
},
),
ElevatedButton(
child: const Text('close 1'),
onPressed: () {
sheetController1!.close();
},
),
],
),
);
},
),
),
),
);
await tester.tap(find.text('show 1'));
await tester.pumpAndSettle();
expect(find.text('BottomSheet 1'), findsOneWidget);
await tester.tap(find.text('show 2'));
await tester.pumpAndSettle();
expect(find.text('BottomSheet 2'), findsOneWidget);
// This will throw an assertion if regressed
await tester.tap(find.text('close 1'));
await tester.pumpAndSettle();
expect(find.text('BottomSheet 2'), findsOneWidget);
},
);
testWidgets('ModalBottomSheetRoute shows BottomSheet correctly', (WidgetTester tester) async {
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
await tester.pump();
expect(find.byType(BottomSheet), findsNothing);
// Bring up bottom sheet.
final NavigatorState navigator = Navigator.of(savedContext);
navigator.push(
ModalBottomSheetRoute<void>(
isScrollControlled: false,
builder: (BuildContext context) => Container(),
),
);
await tester.pumpAndSettle();
expect(find.byType(BottomSheet), findsOneWidget);
});
group('Modal BottomSheet avoids overlapping display features', () {
testWidgets('positioning using anchorPoint', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
builder: (BuildContext context, Widget? child) {
return MediaQuery(
// Display has a vertical hinge down the middle
data: const MediaQueryData(
size: Size(800, 600),
displayFeatures: <DisplayFeature>[
DisplayFeature(
bounds: Rect.fromLTRB(390, 0, 410, 600),
type: DisplayFeatureType.hinge,
state: DisplayFeatureState.unknown,
),
],
),
child: child!,
);
},
home: const Center(child: Text('Test')),
),
);
final BuildContext context = tester.element(find.text('Test'));
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
return const Placeholder();
},
anchorPoint: const Offset(1000, 0),
);
await tester.pumpAndSettle();
// Should take the right side of the screen
expect(tester.getTopLeft(find.byType(Placeholder)).dx, 410);
expect(tester.getBottomRight(find.byType(Placeholder)).dx, 800);
});
testWidgets('positioning using Directionality', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
builder: (BuildContext context, Widget? child) {
return MediaQuery(
// Display has a vertical hinge down the middle
data: const MediaQueryData(
size: Size(800, 600),
displayFeatures: <DisplayFeature>[
DisplayFeature(
bounds: Rect.fromLTRB(390, 0, 410, 600),
type: DisplayFeatureType.hinge,
state: DisplayFeatureState.unknown,
),
],
),
child: Directionality(textDirection: TextDirection.rtl, child: child!),
);
},
home: const Center(child: Text('Test')),
),
);
final BuildContext context = tester.element(find.text('Test'));
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
return const Placeholder();
},
);
await tester.pumpAndSettle();
// This is RTL, so it should place the dialog on the right screen
expect(tester.getTopLeft(find.byType(Placeholder)).dx, 410);
expect(tester.getBottomRight(find.byType(Placeholder)).dx, 800);
});
testWidgets('default positioning', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
builder: (BuildContext context, Widget? child) {
return MediaQuery(
// Display has a vertical hinge down the middle
data: const MediaQueryData(
size: Size(800, 600),
displayFeatures: <DisplayFeature>[
DisplayFeature(
bounds: Rect.fromLTRB(390, 0, 410, 600),
type: DisplayFeatureType.hinge,
state: DisplayFeatureState.unknown,
),
],
),
child: child!,
);
},
home: const Center(child: Text('Test')),
),
);
final BuildContext context = tester.element(find.text('Test'));
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
return const Placeholder();
},
);
await tester.pumpAndSettle();
// By default it should place the dialog on the left screen
expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0);
expect(tester.getBottomRight(find.byType(Placeholder)).dx, 390.0);
});
});
group('constraints', () {
testWidgets('Material3 - Default constraints are max width 640', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: MediaQuery(
data: MediaQueryData(size: Size(1000, 1000)),
child: Scaffold(
body: Center(child: Text('body')),
bottomSheet: Placeholder(fallbackWidth: 800),
),
),
),
);
expect(tester.getSize(find.byType(Placeholder)).width, 640);
});
testWidgets('Material2 - No constraints by default for bottomSheet property', (
WidgetTester tester,
) async {
await tester.pumpWidget(
MaterialApp(
// This test is specific to Material2 because Material3 sets constraints by default for BottomSheet.
theme: ThemeData(useMaterial3: false),
home: const Scaffold(
body: Center(child: Text('body')),
bottomSheet: Text('BottomSheet'),
),
),
);
expect(find.text('BottomSheet'), findsOneWidget);
expect(tester.getRect(find.text('BottomSheet')), const Rect.fromLTRB(0, 586, 154, 600));
});
testWidgets('No constraints by default for showBottomSheet', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
// This test is specific to Material2 because Material3 sets constraints by default for BottomSheet.
theme: ThemeData(useMaterial3: false),
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return Center(
child: ElevatedButton(
child: const Text('Press me'),
onPressed: () {
Scaffold.of(
context,
).showBottomSheet((BuildContext context) => const Text('BottomSheet'));
},
),
);
},
),
),
),
);
expect(find.text('BottomSheet'), findsNothing);
await tester.tap(find.text('Press me'));
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
expect(tester.getRect(find.text('BottomSheet')), const Rect.fromLTRB(0, 586, 154, 600));
});
testWidgets('No constraints by default for showModalBottomSheet', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
// This test is specific to Material2 because Material3 sets constraints by default for BottomSheet.
theme: ThemeData(useMaterial3: false),
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return Center(
child: ElevatedButton(
child: const Text('Press me'),
onPressed: () {
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) => const Text('BottomSheet'),
);
},
),
);
},
),
),
),
);
expect(find.text('BottomSheet'), findsNothing);
await tester.tap(find.text('Press me'));
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
expect(tester.getRect(find.text('BottomSheet')), const Rect.fromLTRB(0, 586, 800, 600));
});
testWidgets('Material3 - Theme constraints used for bottomSheet property', (
WidgetTester tester,
) async {
const sheetMaxWidth = 80.0;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
bottomSheetTheme: const BottomSheetThemeData(
constraints: BoxConstraints(maxWidth: sheetMaxWidth),
),
),
home: Scaffold(
body: const Center(child: Text('body')),
bottomSheet: const Text('BottomSheet'),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
),
),
);
expect(find.text('BottomSheet'), findsOneWidget);
// Should be centered and only 80dp wide.
final Rect bottomSheetRect = tester.getRect(find.text('BottomSheet'));
expect(bottomSheetRect.left, 800 / 2 - sheetMaxWidth / 2);
expect(bottomSheetRect.width, sheetMaxWidth);
// Ensure the FAB is overlapping the top of the sheet.
expect(find.byIcon(Icons.add), findsOneWidget);
final Rect iconRect = tester.getRect(find.byIcon(Icons.add));
expect(iconRect.top, bottomSheetRect.top - iconRect.height / 2);
});
testWidgets('Material2 - Theme constraints used for bottomSheet property', (
WidgetTester tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
useMaterial3: false,
bottomSheetTheme: const BottomSheetThemeData(constraints: BoxConstraints(maxWidth: 80)),
),
home: Scaffold(
body: const Center(child: Text('body')),
bottomSheet: const Text('BottomSheet'),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
),
),
);
expect(find.text('BottomSheet'), findsOneWidget);
// Should be centered and only 80dp wide
expect(tester.getRect(find.text('BottomSheet')), const Rect.fromLTRB(360, 558, 440, 600));
// Ensure the FAB is overlapping the top of the sheet
expect(find.byIcon(Icons.add), findsOneWidget);
expect(tester.getRect(find.byIcon(Icons.add)), const Rect.fromLTRB(744, 544, 768, 568));
});
testWidgets('Theme constraints used for showBottomSheet', (WidgetTester tester) async {
const sheetMaxWidth = 80.0;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
bottomSheetTheme: const BottomSheetThemeData(
constraints: BoxConstraints(maxWidth: sheetMaxWidth),
),
),
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return Center(
child: ElevatedButton(
child: const Text('Press me'),
onPressed: () {
Scaffold.of(
context,
).showBottomSheet((BuildContext context) => const Text('BottomSheet'));
},
),
);
},
),
),
),
);
expect(find.text('BottomSheet'), findsNothing);
await tester.tap(find.text('Press me'));
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
// Should be centered and only 80dp wide.
final Rect bottomSheetRect = tester.getRect(find.text('BottomSheet'));
expect(bottomSheetRect.left, 800 / 2 - sheetMaxWidth / 2);
expect(bottomSheetRect.width, sheetMaxWidth);
});
testWidgets('Theme constraints used for showModalBottomSheet', (WidgetTester tester) async {
const sheetMaxWidth = 80.0;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
bottomSheetTheme: const BottomSheetThemeData(
constraints: BoxConstraints(maxWidth: sheetMaxWidth),
),
),
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return Center(
child: ElevatedButton(
child: const Text('Press me'),
onPressed: () {
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) => const Text('BottomSheet'),
);
},
),
);
},
),
),
),
);
expect(find.text('BottomSheet'), findsNothing);
await tester.tap(find.text('Press me'));
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
// Should be centered and only 80dp wide.
final Rect bottomSheetRect = tester.getRect(find.text('BottomSheet'));
expect(bottomSheetRect.left, 800 / 2 - sheetMaxWidth / 2);
expect(bottomSheetRect.width, sheetMaxWidth);
});
testWidgets('constraints param overrides theme for showBottomSheet', (
WidgetTester tester,
) async {
const sheetMaxWidth = 100.0;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
bottomSheetTheme: const BottomSheetThemeData(constraints: BoxConstraints(maxWidth: 80)),
),
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return Center(
child: ElevatedButton(
child: const Text('Press me'),
onPressed: () {
Scaffold.of(context).showBottomSheet(
(BuildContext context) => const Text('BottomSheet'),
constraints: const BoxConstraints(maxWidth: sheetMaxWidth),
);
},
),
);
},
),
),
),
);
expect(find.text('BottomSheet'), findsNothing);
await tester.tap(find.text('Press me'));
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
// Should be centered and only 80dp wide.
final Rect bottomSheetRect = tester.getRect(find.text('BottomSheet'));
expect(bottomSheetRect.left, 800 / 2 - sheetMaxWidth / 2);
expect(bottomSheetRect.width, sheetMaxWidth);
});
testWidgets('constraints param overrides theme for showModalBottomSheet', (
WidgetTester tester,
) async {
const sheetMaxWidth = 100.0;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
bottomSheetTheme: const BottomSheetThemeData(constraints: BoxConstraints(maxWidth: 80)),
),
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return Center(
child: ElevatedButton(
child: const Text('Press me'),
onPressed: () {
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) => const Text('BottomSheet'),
constraints: const BoxConstraints(maxWidth: sheetMaxWidth),
);
},
),
);
},
),
),
),
);
expect(find.text('BottomSheet'), findsNothing);
await tester.tap(find.text('Press me'));
await tester.pumpAndSettle();
expect(find.text('BottomSheet'), findsOneWidget);
// Should be centered and only 80dp wide.
final Rect bottomSheetRect = tester.getRect(find.text('BottomSheet'));
expect(bottomSheetRect.left, 800 / 2 - sheetMaxWidth / 2);
expect(bottomSheetRect.width, sheetMaxWidth);
});
group('scrollControlDisabledMaxHeightRatio', () {
Future<void> test(
WidgetTester tester,
bool isScrollControlled,
double scrollControlDisabledMaxHeightRatio,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return Center(
child: ElevatedButton(
child: const Text('Press me'),
onPressed: () {
showModalBottomSheet<void>(
context: context,
isScrollControlled: isScrollControlled,
scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio,
builder: (BuildContext context) =>
const SizedBox.expand(child: Text('BottomSheet')),
);
},
),
);
},
),
),
),
);
await tester.tap(find.text('Press me'));
await tester.pumpAndSettle();
expect(
tester.getRect(find.text('BottomSheet')),
Rect.fromLTRB(
80,
600 * (isScrollControlled ? 0 : (1 - scrollControlDisabledMaxHeightRatio)),
720,
600,
),
);
}
testWidgets('works at 9 / 16', (WidgetTester tester) {
return test(tester, false, 9.0 / 16.0);
});
testWidgets('works at 8 / 16', (WidgetTester tester) {
return test(tester, false, 8.0 / 16.0);
});
testWidgets('works at isScrollControlled', (WidgetTester tester) {
return test(tester, true, 8.0 / 16.0);
});
});
});
group('showModalBottomSheet modalBarrierDismissLabel', () {
testWidgets('Verify that modalBarrierDismissLabel is used if provided', (
WidgetTester tester,
) async {
final scaffoldKey = GlobalKey<ScaffoldState>();
const customLabel = 'custom label';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
showModalBottomSheet<void>(
barrierLabel: 'custom label',
context: scaffoldKey.currentContext!,
builder: (BuildContext context) {
return const Text('BottomSheet');
},
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final ModalBarrier modalBarrier = tester.widget(find.byType(ModalBarrier).last);
expect(modalBarrier.semanticsLabel, customLabel);
});
testWidgets(
'Verify that modalBarrierDismissLabel from context is used if barrierLabel is not provided',
(WidgetTester tester) async {
final scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
),
),
);
showModalBottomSheet<void>(
context: scaffoldKey.currentContext!,
builder: (BuildContext context) {
return const Text('BottomSheet');
},
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final ModalBarrier modalBarrier = tester.widget(find.byType(ModalBarrier).last);
expect(
modalBarrier.semanticsLabel,
MaterialLocalizations.of(scaffoldKey.currentContext!).scrimLabel,
);
},
);
});
testWidgets('Bottom sheet animation can be customized', (WidgetTester tester) async {
final Key sheetKey = UniqueKey();
Widget buildWidget({AnimationStyle? sheetAnimationStyle}) {
return MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return GestureDetector(
onTap: () {
showBottomSheet(
context: context,
sheetAnimationStyle: sheetAnimationStyle,
builder: (BuildContext context) {
return SizedBox.expand(
child: ColoredBox(
key: sheetKey,
color: Theme.of(context).colorScheme.primary,
child: FilledButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Close'),
),
),
);
},
);
},
child: const Text('X'),
);
},
),
),
);
}
// Test custom animation style.
await tester.pumpWidget(
buildWidget(
sheetAnimationStyle: const AnimationStyle(
duration: Duration(milliseconds: 800),
reverseDuration: Duration(milliseconds: 400),
),
),
);
await tester.tap(find.text('X'));
await tester.pump();
// Advance the animation by 1/2 of the custom forward duration.
await tester.pump(const Duration(milliseconds: 400));
// The bottom sheet is partially visible.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(134.6, 0.1));
// Advance the animation by 1/2 of the custom forward duration.
await tester.pump(const Duration(milliseconds: 400));
// The bottom sheet is fully visible.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(0.0));
// Dismiss the bottom sheet.
await tester.tap(find.widgetWithText(FilledButton, 'Close'));
await tester.pump();
// Advance the animation by 1/2 of the custom reverse duration.
await tester.pump(const Duration(milliseconds: 200));
// The bottom sheet is partially visible.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(134.6, 0.1));
// Advance the animation by 1/2 of the custom reverse duration.
await tester.pump(const Duration(milliseconds: 200));
// The bottom sheet is dismissed.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(600.0));
// Test no animation style.
await tester.pumpWidget(buildWidget(sheetAnimationStyle: AnimationStyle.noAnimation));
await tester.pumpAndSettle();
await tester.tap(find.text('X'));
await tester.pump();
// The bottom sheet is fully visible.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(0.0));
// Dismiss the bottom sheet.
await tester.tap(find.widgetWithText(FilledButton, 'Close'));
await tester.pump();
// The bottom sheet is dismissed.
expect(find.byKey(sheetKey), findsNothing);
});
testWidgets('Modal bottom sheet default animation', (WidgetTester tester) async {
final Key sheetKey = UniqueKey();
// Test default modal bottom sheet animation.
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return GestureDetector(
onTap: () {
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
return SizedBox.expand(
child: ColoredBox(
key: sheetKey,
color: Theme.of(context).colorScheme.primary,
child: FilledButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Close'),
),
),
);
},
);
},
child: const Text('X'),
);
},
),
),
),
);
// Tap the 'X' to show the bottom sheet.
await tester.tap(find.text('X'));
await tester.pump();
// Advance the animation by 1/2 of the default forward duration.
await tester.pump(const Duration(milliseconds: 125));
// The modal bottom sheet is partially visible.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(316.7, 0.1));
// Advance the animation by 1/2 of the default forward duration.
await tester.pump(const Duration(milliseconds: 125));
// The modal bottom sheet is fully visible.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(262.5));
// Dismiss the bottom sheet.
await tester.tap(find.widgetWithText(FilledButton, 'Close'));
await tester.pump();
// Advance the animation by 1/2 of the default reverse duration.
await tester.pump(const Duration(milliseconds: 100));
// The modal bottom sheet is partially visible.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(316.7, 0.1));
// Advance the animation by 1/2 of the default reverse duration.
await tester.pump(const Duration(milliseconds: 100));
// The modal bottom sheet is dismissed.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(600.0));
});
testWidgets('Modal bottom sheet animation can be customized', (WidgetTester tester) async {
final Key sheetKey = UniqueKey();
Widget buildWidget({AnimationStyle? sheetAnimationStyle}) {
return MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return GestureDetector(
onTap: () {
showModalBottomSheet<void>(
context: context,
sheetAnimationStyle: sheetAnimationStyle,
builder: (BuildContext context) {
return SizedBox.expand(
child: ColoredBox(
key: sheetKey,
color: Theme.of(context).colorScheme.primary,
child: FilledButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Close'),
),
),
);
},
);
},
child: const Text('X'),
);
},
),
),
);
}
// Test custom animation style.
await tester.pumpWidget(
buildWidget(
sheetAnimationStyle: const AnimationStyle(
duration: Duration(milliseconds: 800),
reverseDuration: Duration(milliseconds: 400),
),
),
);
await tester.tap(find.text('X'));
await tester.pump();
// Advance the animation by 1/2 of the custom forward duration.
await tester.pump(const Duration(milliseconds: 400));
// The bottom sheet is partially visible.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(316.7, 0.1));
// Advance the animation by 1/2 of the custom forward duration.
await tester.pump(const Duration(milliseconds: 400));
// The bottom sheet is fully visible.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(262.5));
// Dismiss the bottom sheet.
await tester.tap(find.widgetWithText(FilledButton, 'Close'));
await tester.pump();
// Advance the animation by 1/2 of the custom reverse duration.
await tester.pump(const Duration(milliseconds: 200));
// The bottom sheet is partially visible.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(316.7, 0.1));
// Advance the animation by 1/2 of the custom reverse duration.
await tester.pump(const Duration(milliseconds: 200));
// The bottom sheet is dismissed.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(600.0));
// Test no animation style.
await tester.pumpWidget(buildWidget(sheetAnimationStyle: AnimationStyle.noAnimation));
await tester.pumpAndSettle();
await tester.tap(find.text('X'));
await tester.pump();
// The bottom sheet is fully visible.
expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(262.5));
// Dismiss the bottom sheet.
await tester.tap(find.widgetWithText(FilledButton, 'Close'));
await tester.pump();
// The bottom sheet is dismissed.
expect(find.byKey(sheetKey), findsNothing);
});
testWidgets(
'Setting ModalBottomSheetRoute.requestFocus to false does not request focus on the bottom sheet',
(WidgetTester tester) async {
late BuildContext savedContext;
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Builder(
builder: (BuildContext context) {
savedContext = context;
return TextField(focusNode: focusNode);
},
),
),
),
);
await tester.pump();
FocusNode? getTextFieldFocusNode() {
return tester
.widget<Focus>(
find.descendant(of: find.byType(TextField), matching: find.byType(Focus)),
)
.focusNode;
}
// Initially, there is no bottom sheet and the text field has no focus.
expect(find.byType(BottomSheet), findsNothing);
expect(getTextFieldFocusNode()?.hasFocus, false);
// Request focus on the text field.
focusNode.requestFocus();
await tester.pump();
expect(getTextFieldFocusNode()?.hasFocus, true);
// Bring up bottom sheet.
final NavigatorState navigator = Navigator.of(savedContext);
navigator.push(
ModalBottomSheetRoute<void>(
isScrollControlled: false,
builder: (BuildContext context) => Container(),
),
);
await tester.pump();
// The bottom sheet is showing and the text field has lost focus.
expect(find.byType(BottomSheet), findsOneWidget);
expect(getTextFieldFocusNode()?.hasFocus, false);
// Dismiss the bottom sheet.
navigator.pop();
await tester.pump();
// The bottom sheet is dismissed and the focus is shifted back to the text field.
expect(find.byType(BottomSheet), findsNothing);
expect(getTextFieldFocusNode()?.hasFocus, true);
// Bring up bottom sheet again with requestFocus to false.
navigator.push(
ModalBottomSheetRoute<void>(
requestFocus: false,
isScrollControlled: false,
builder: (BuildContext context) => Container(),
),
);
await tester.pump();
// The bottom sheet is showing and the text field still has focus.
expect(find.byType(BottomSheet), findsOneWidget);
expect(getTextFieldFocusNode()?.hasFocus, true);
},
);
testWidgets('requestFocus works correctly in showModalBottomSheet.', (WidgetTester tester) async {
final navigatorKey = GlobalKey<NavigatorState>();
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
home: Scaffold(body: TextField(focusNode: focusNode)),
),
);
focusNode.requestFocus();
await tester.pump();
expect(focusNode.hasFocus, true);
showModalBottomSheet<void>(
context: navigatorKey.currentContext!,
requestFocus: true,
builder: (BuildContext context) => const Text('BottomSheet'),
);
await tester.pumpAndSettle();
expect(FocusScope.of(tester.element(find.text('BottomSheet'))).hasFocus, true);
expect(focusNode.hasFocus, false);
navigatorKey.currentState!.pop();
await tester.pumpAndSettle();
expect(focusNode.hasFocus, true);
showModalBottomSheet<void>(
context: navigatorKey.currentContext!,
requestFocus: false,
builder: (BuildContext context) => const Text('BottomSheet'),
);
await tester.pumpAndSettle();
expect(FocusScope.of(tester.element(find.text('BottomSheet'))).hasFocus, false);
expect(focusNode.hasFocus, true);
});
testWidgets('BottomSheet does not crash at zero area', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox.shrink(
child: BottomSheet(
onClosing: () {},
builder: (BuildContext context) => const Text('X'),
),
),
),
),
),
);
expect(tester.getSize(find.byType(BottomSheet)), Size.zero);
});
// Regression test for https://github.com/flutter/flutter/issues/177004
testWidgets('ModalBottomSheet semantics for mismatched platforms', (WidgetTester tester) async {
const localizations = DefaultMaterialLocalizations();
Future<void> pumpModalBottomSheetWithTheme(TargetPlatform themePlatform) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: themePlatform),
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return OutlinedButton(
onPressed: () {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (BuildContext context) {
return const Text('BottomSheet');
},
);
},
child: const Text('open'),
);
},
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
final Finder popupFinder = find.bySemanticsLabel(localizations.dialogLabel);
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
expect(popupFinder, findsNothing); // Apple platforms don't show label.
case _:
expect(popupFinder, findsOneWidget); // Non-Apple platforms show label.
}
}
// Test with theme.platform = Android on different real platforms.
await pumpModalBottomSheetWithTheme(TargetPlatform.android);
// Dismiss the first bottom sheet.
Navigator.of(tester.element(find.text('BottomSheet'))).pop();
await tester.pumpAndSettle();
// Test with theme.platform = iOS on different real platforms.
await pumpModalBottomSheetWithTheme(TargetPlatform.iOS);
}, variant: TargetPlatformVariant.all());
testWidgets('Modal bottom sheet has hitTestBehavior.opaque to prevent dismissal on empty areas', (
WidgetTester tester,
) async {
final semantics = SemanticsTester(tester);
late BuildContext savedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
savedContext = context;
return Container();
},
),
),
);
await tester.pump();
showModalBottomSheet<void>(
context: savedContext,
builder: (BuildContext context) => Container(
height: 200,
color: Colors.blue,
child: const Center(child: Text('Modal Bottom Sheet')),
),
);
await tester.pumpAndSettle();
expect(find.text('Modal Bottom Sheet'), findsOneWidget);
// Verify the route-level Semantics has opaque hitTestBehavior
// This prevents clicks inside the bottom sheet from passing through to the barrier
final List<Semantics> allSemantics = tester
.widgetList<Semantics>(
find.ancestor(of: find.text('Modal Bottom Sheet'), matching: find.byType(Semantics)),
)
.toList();
final Semantics routeSemantics = allSemantics.firstWhere(
(Semantics s) => s.properties.hitTestBehavior == SemanticsHitTestBehavior.opaque,
);
expect(routeSemantics.properties.hitTestBehavior, SemanticsHitTestBehavior.opaque);
final Semantics widgetSemantics = allSemantics.firstWhere(
(Semantics s) => s.properties.scopesRoute ?? false,
);
expect(widgetSemantics.properties.scopesRoute, true);
semantics.dispose();
});
}
class _TestPage extends StatelessWidget {
const _TestPage({this.useRootNavigator});
final bool? useRootNavigator;
@override
Widget build(BuildContext context) {
return Center(
child: TextButton(
child: const Text('Show bottom sheet'),
onPressed: () {
if (useRootNavigator != null) {
showModalBottomSheet<void>(
useRootNavigator: useRootNavigator!,
context: context,
builder: (_) => const Text('Modal bottom sheet'),
);
} else {
showModalBottomSheet<void>(
context: context,
builder: (_) => const Text('Modal bottom sheet'),
);
}
},
),
);
}
}
class _StatusTestAnimationController extends AnimationController with AnimationLazyListenerMixin {
_StatusTestAnimationController({super.duration, super.reverseDuration, required super.vsync});
@override
void didStartListening() {}
@override
void didStopListening() {}
}