From b9bf455da0a980d8d1d9057dde224daaf2a85ede Mon Sep 17 00:00:00 2001 From: Daichi Fujita <68946713+fujidaiti@users.noreply.github.com> Date: Thu, 11 Jul 2024 11:43:56 +0900 Subject: [PATCH] Fix: Opening keyboard interrupts sheet animation (#189) Fixes #14. The sheet position adjustment strategy of `AnimatedSheetActivity`, which is used when either the sheet content size or the viewport size changes, was modified to: 1. append the delta of `MediaQueryData.viewInsets.bottom` (the keyboard height) to keep the visual sheet position unchanged, and 3. if the animation is still running, start a new linear animation to bring the sheet position to the recalculated final position in the remaining duration. We use a linear curve here because starting a curved animation in the middle of another curved animation tends to look jerky. --- package/CHANGELOG.md | 4 + .../lib/src/foundation/sheet_activity.dart | 42 +++++- package/lib/src/foundation/sheet_extent.dart | 6 +- .../navigation/navigation_sheet_extent.dart | 14 ++ package/pubspec.yaml | 2 +- .../test/draggable/draggable_sheet_test.dart | 124 +++++++++++++--- .../navigation/navigation_sheet_test.dart | 78 +++++++++- .../scrollable/scrollable_sheet_test.dart | 139 ++++++++++++++---- .../test/src/keyboard_inset_simulation.dart | 73 +++++++++ 9 files changed, 419 insertions(+), 63 deletions(-) create mode 100644 package/test/src/keyboard_inset_simulation.dart diff --git a/package/CHANGELOG.md b/package/CHANGELOG.md index cb0307f7..248ce0b0 100644 --- a/package/CHANGELOG.md +++ b/package/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.8.2 Jul 11, 2024 + +- Fix: Opening keyboard interrupts sheet animation (#189) + ## 0.8.1 Jun 23, 2024 - Fix: Cupertino style modal transition not working with NavigationSheet (#182) diff --git a/package/lib/src/foundation/sheet_activity.dart b/package/lib/src/foundation/sheet_activity.dart index c7b27df3..8cbe1d0b 100644 --- a/package/lib/src/foundation/sheet_activity.dart +++ b/package/lib/src/foundation/sheet_activity.dart @@ -131,32 +131,62 @@ abstract class SheetActivity { class AnimatedSheetActivity extends SheetActivity with ControlledSheetActivityMixin { AnimatedSheetActivity({ - required this.from, - required this.to, + required this.destination, required this.duration, required this.curve, }) : assert(duration > Duration.zero); - final double from; - final double to; + final Extent destination; final Duration duration; final Curve curve; @override AnimationController createAnimationController() { return AnimationController.unbounded( - value: from, vsync: owner.context.vsync); + value: owner.metrics.pixels, + vsync: owner.context.vsync, + ); } @override TickerFuture onAnimationStart() { - return controller.animateTo(to, duration: duration, curve: curve); + return controller.animateTo( + destination.resolve(owner.metrics.contentSize), + duration: duration, + curve: curve, + ); } @override void onAnimationEnd() { owner.goBallistic(0); } + + @override + void didFinalizeDimensions( + Size? oldContentSize, + Size? oldViewportSize, + EdgeInsets? oldViewportInsets, + ) { + // 1. Appends the delta of the bottom inset (typically the keyboard height) + // to keep the visual sheet position unchanged. + final newInsets = owner.metrics.viewportInsets; + final oldInsets = oldViewportInsets ?? newInsets; + final deltaInsetBottom = newInsets.bottom - oldInsets.bottom; + owner.setPixels(owner.metrics.pixels - deltaInsetBottom); + + // 2. If the animation is still running, we start a new linear animation + // to bring the sheet position to the recalculated final position in the + // remaining duration. We use a linear curve here because starting a curved + // animation in the middle of another curved animation tends to look jerky. + final newDestination = destination.resolve(owner.metrics.contentSize); + final elapsedDuration = controller.lastElapsedDuration ?? duration; + if (newDestination != controller.upperBound && elapsedDuration < duration) { + final carriedDuration = duration - elapsedDuration; + owner.animateTo(destination, + duration: carriedDuration, curve: Curves.linear); + } + } } @internal diff --git a/package/lib/src/foundation/sheet_extent.dart b/package/lib/src/foundation/sheet_extent.dart index 97fab5ae..bc73b31f 100644 --- a/package/lib/src/foundation/sheet_extent.dart +++ b/package/lib/src/foundation/sheet_extent.dart @@ -507,13 +507,11 @@ abstract class SheetExtent extends ChangeNotifier Duration duration = const Duration(milliseconds: 300), }) { assert(metrics.hasDimensions); - final destination = newExtent.resolve(metrics.contentSize); - if (metrics.pixels == destination) { + if (metrics.pixels == newExtent.resolve(metrics.contentSize)) { return Future.value(); } else { final activity = AnimatedSheetActivity( - from: metrics.pixels, - to: destination, + destination: newExtent, duration: duration, curve: curve, ); diff --git a/package/lib/src/navigation/navigation_sheet_extent.dart b/package/lib/src/navigation/navigation_sheet_extent.dart index 9a4c42a0..bdf38a50 100644 --- a/package/lib/src/navigation/navigation_sheet_extent.dart +++ b/package/lib/src/navigation/navigation_sheet_extent.dart @@ -87,6 +87,20 @@ class NavigationSheetExtent extends SheetExtent { } } + @override + Future animateTo( + Extent newExtent, { + Curve curve = Curves.easeInOut, + Duration duration = const Duration(milliseconds: 300), + }) { + if (activity case ProxySheetActivity(:final route)) { + return route.scopeKey.currentExtent + .animateTo(newExtent, curve: curve, duration: duration); + } else { + return super.animateTo(newExtent, curve: curve, duration: duration); + } + } + @override void dispatchUpdateNotification() { // Do not dispatch a notifications if a local extent is active. diff --git a/package/pubspec.yaml b/package/pubspec.yaml index d485ead3..3dbfd208 100644 --- a/package/pubspec.yaml +++ b/package/pubspec.yaml @@ -1,6 +1,6 @@ name: smooth_sheets description: Sheet widgets with smooth motion and great flexibility. Also supports nested navigation in both imperative and declarative ways. -version: 0.8.1 +version: 0.8.2 repository: https://github.com/fujidaiti/smooth_sheets screenshots: - description: Practical examples of smooth_sheets. diff --git a/package/test/draggable/draggable_sheet_test.dart b/package/test/draggable/draggable_sheet_test.dart index 87b83fe9..e984f806 100644 --- a/package/test/draggable/draggable_sheet_test.dart +++ b/package/test/draggable/draggable_sheet_test.dart @@ -1,34 +1,56 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:smooth_sheets/smooth_sheets.dart'; import 'package:smooth_sheets/src/foundation/sheet_controller.dart'; -class _TestWidget extends StatelessWidget { - const _TestWidget({ - this.contentKey, - this.contentHeight = 500, +import '../src/keyboard_inset_simulation.dart'; + +class _TestApp extends StatelessWidget { + const _TestApp({ + this.useMaterial = false, + required this.child, }); - final Key? contentKey; - final double contentHeight; + final bool useMaterial; + final Widget child; @override Widget build(BuildContext context) { - final content = Container( - key: contentKey, - color: Colors.white, - height: contentHeight, - width: double.infinity, - ); - - return Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: const MediaQueryData(), - child: DraggableSheet( - child: content, + if (useMaterial) { + return MaterialApp( + home: child, + ); + } else { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: child, ), - ), + ); + } + } +} + +class _TestSheetContent extends StatelessWidget { + const _TestSheetContent({ + super.key, + this.height = 500, + this.child, + }); + + final double? height; + final Widget? child; + + @override + Widget build(BuildContext context) { + return Container( + height: height, + width: double.infinity, + color: Colors.white, + child: child, ); } } @@ -39,11 +61,71 @@ void main() { await tester.pumpWidget( SheetControllerScope( controller: controller, - child: const _TestWidget(), + child: const _TestApp( + child: DraggableSheet( + child: _TestSheetContent(), + ), + ), ), ); expect(controller.hasClient, isTrue, reason: 'The controller should have a client.'); }); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/14 + testWidgets('Opening keyboard does not interrupt sheet animation', + (tester) async { + final controller = SheetController(); + final sheetKey = GlobalKey(); + final keyboardSimulationKey = GlobalKey(); + + await tester.pumpWidget( + _TestApp( + useMaterial: true, + child: KeyboardInsetSimulation( + key: keyboardSimulationKey, + keyboardHeight: 200, + child: DraggableSheet( + key: sheetKey, + controller: controller, + minExtent: const Extent.pixels(200), + initialExtent: const Extent.pixels(200), + child: const Material( + child: _TestSheetContent( + height: 500, + ), + ), + ), + ), + ), + ); + + expect(controller.value.pixels, 200, + reason: 'The sheet should be at the initial extent.'); + expect(controller.value.minPixels < controller.value.maxPixels, isTrue, + reason: 'The sheet should be draggable.'); + + // Start animating the sheet to the max extent. + unawaited( + controller.animateTo( + const Extent.proportional(1), + duration: const Duration(milliseconds: 250), + ), + ); + // Then, show the keyboard while the animation is running. + unawaited( + keyboardSimulationKey.currentState! + .showKeyboard(const Duration(milliseconds: 250)), + ); + await tester.pumpAndSettle(); + expect(MediaQuery.viewInsetsOf(sheetKey.currentContext!).bottom, 200, + reason: 'The keyboard should be fully shown.'); + expect( + controller.value.pixels, + controller.value.maxPixels, + reason: 'After the keyboard is fully shown, ' + 'the entire sheet should also be visible.', + ); + }); } diff --git a/package/test/navigation/navigation_sheet_test.dart b/package/test/navigation/navigation_sheet_test.dart index 54a776be..193829bb 100644 --- a/package/test/navigation/navigation_sheet_test.dart +++ b/package/test/navigation/navigation_sheet_test.dart @@ -1,14 +1,20 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:smooth_sheets/smooth_sheets.dart'; import 'package:smooth_sheets/src/foundation/sheet_controller.dart'; +import '../src/keyboard_inset_simulation.dart'; + class _TestWidget extends StatelessWidget { const _TestWidget( this.sheetTransitionObserver, { required this.initialRoute, required this.routes, this.onTapBackgroundText, + this.sheetKey, + this.contentBuilder, this.sheetController, this.useMaterialApp = false, }); @@ -16,13 +22,16 @@ class _TestWidget extends StatelessWidget { final String initialRoute; final Map>> routes; final VoidCallback? onTapBackgroundText; + final Widget Function(BuildContext, Widget)? contentBuilder; final SheetController? sheetController; final NavigationSheetTransitionObserver sheetTransitionObserver; + final Key? sheetKey; final bool useMaterialApp; @override Widget build(BuildContext context) { final navigationSheet = NavigationSheet( + key: sheetKey, controller: sheetController, transitionObserver: sheetTransitionObserver, child: ColoredBox( @@ -35,7 +44,7 @@ class _TestWidget extends StatelessWidget { ), ); - final content = Stack( + Widget content = Stack( children: [ TextButton( onPressed: onTapBackgroundText, @@ -45,6 +54,10 @@ class _TestWidget extends StatelessWidget { ], ); + if (contentBuilder case final builder?) { + content = builder(context, content); + } + return switch (useMaterialApp) { true => MaterialApp(home: content), false => Directionality( @@ -106,12 +119,14 @@ class _TestDraggablePageWidget extends StatelessWidget { required String label, required double height, String? nextRoute, + Extent initialExtent = const Extent.proportional(1), Extent minExtent = const Extent.proportional(1), Duration transitionDuration = const Duration(milliseconds: 300), SheetPhysics? physics, }) { return DraggableNavigationSheetRoute( physics: physics, + initialExtent: initialExtent, minExtent: minExtent, transitionDuration: transitionDuration, builder: (context) => _TestDraggablePageWidget( @@ -338,4 +353,65 @@ void main() { expect(find.text('Option 3'), findsNothing); }, ); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/14 + testWidgets( + 'Opening keyboard does not interrupts sheet animation', + (tester) async { + final controller = SheetController(); + final sheetKey = GlobalKey(); + final keyboardSimulationKey = GlobalKey(); + + await tester.pumpWidget( + _TestWidget( + transitionObserver, + sheetController: controller, + sheetKey: sheetKey, + initialRoute: 'first', + routes: { + 'first': () => _TestDraggablePageWidget.createRoute( + label: 'First', + height: 500, + minExtent: const Extent.pixels(200), + initialExtent: const Extent.pixels(200), + ), + }, + contentBuilder: (context, child) { + return KeyboardInsetSimulation( + key: keyboardSimulationKey, + keyboardHeight: 200, + child: child, + ); + }, + ), + ); + + expect(controller.value.pixels, 200, + reason: 'The sheet should be at the initial extent.'); + expect(controller.value.minPixels < controller.value.maxPixels, isTrue, + reason: 'The sheet should be draggable.'); + + // Start animating the sheet to the max extent. + unawaited( + controller.animateTo( + const Extent.proportional(1), + duration: const Duration(milliseconds: 250), + ), + ); + // Then, show the keyboard while the sheet is animating. + unawaited( + keyboardSimulationKey.currentState! + .showKeyboard(const Duration(milliseconds: 250)), + ); + await tester.pumpAndSettle(); + expect(MediaQuery.viewInsetsOf(sheetKey.currentContext!).bottom, 200, + reason: 'The keyboard should be fully shown.'); + expect( + controller.value.pixels, + controller.value.maxPixels, + reason: 'After the keyboard is fully shown, ' + 'the entire sheet should also be visible.', + ); + }, + ); } diff --git a/package/test/scrollable/scrollable_sheet_test.dart b/package/test/scrollable/scrollable_sheet_test.dart index 2360b05b..9fb0d7f3 100644 --- a/package/test/scrollable/scrollable_sheet_test.dart +++ b/package/test/scrollable/scrollable_sheet_test.dart @@ -1,45 +1,64 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:smooth_sheets/smooth_sheets.dart'; import 'package:smooth_sheets/src/foundation/sheet_controller.dart'; -class _TestWidget extends StatelessWidget { - const _TestWidget({ - this.contentKey, - this.contentHeight, - this.scrollPhysics = const ClampingScrollPhysics(), +import '../src/keyboard_inset_simulation.dart'; + +class _TestApp extends StatelessWidget { + const _TestApp({ + this.useMaterial = false, + required this.child, }); - final Key? contentKey; - final double? contentHeight; - final ScrollPhysics scrollPhysics; + final bool useMaterial; + final Widget child; @override Widget build(BuildContext context) { - Widget content = Material( - color: Colors.white, - child: ListView( - key: contentKey, - physics: scrollPhysics, - children: List.generate( - 30, - (index) => ListTile( - title: Text('Item $index'), - ), + if (useMaterial) { + return MaterialApp( + home: child, + ); + } else { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: child, ), - ), - ); - - if (contentHeight case final height?) { - content = SizedBox(height: height, child: content); + ); } + } +} - return Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: const MediaQueryData(), - child: ScrollableSheet( - child: content, +class _TestSheetContent extends StatelessWidget { + const _TestSheetContent({ + super.key, + this.height = 500, + // Disable the snapping effect by default in tests. + this.scrollPhysics = const ClampingScrollPhysics(), + }); + + final double? height; + final ScrollPhysics? scrollPhysics; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: Material( + color: Colors.white, + child: ListView( + physics: scrollPhysics, + children: List.generate( + 30, + (index) => ListTile( + title: Text('Item $index'), + ), + ), ), ), ); @@ -52,11 +71,71 @@ void main() { await tester.pumpWidget( SheetControllerScope( controller: controller, - child: const _TestWidget(), + child: const _TestApp( + child: ScrollableSheet( + child: _TestSheetContent(), + ), + ), ), ); expect(controller.hasClient, isTrue, reason: 'The controller should have a client.'); }); + + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/14 + testWidgets('Opening keyboard does not interrupt sheet animation', + (tester) async { + final controller = SheetController(); + final sheetKey = GlobalKey(); + final keyboardSimulationKey = GlobalKey(); + + await tester.pumpWidget( + _TestApp( + useMaterial: true, + child: KeyboardInsetSimulation( + key: keyboardSimulationKey, + keyboardHeight: 200, + child: DraggableSheet( + key: sheetKey, + controller: controller, + minExtent: const Extent.pixels(200), + initialExtent: const Extent.pixels(200), + child: const Material( + child: _TestSheetContent( + height: 500, + ), + ), + ), + ), + ), + ); + + expect(controller.value.pixels, 200, + reason: 'The sheet should be at the initial extent.'); + expect(controller.value.minPixels < controller.value.maxPixels, isTrue, + reason: 'The sheet should be draggable.'); + + // Start animating the sheet to the max extent. + unawaited( + controller.animateTo( + const Extent.proportional(1), + duration: const Duration(milliseconds: 250), + ), + ); + // Then, show the keyboard while the sheet is animating. + unawaited( + keyboardSimulationKey.currentState! + .showKeyboard(const Duration(milliseconds: 250)), + ); + await tester.pumpAndSettle(); + expect(MediaQuery.viewInsetsOf(sheetKey.currentContext!).bottom, 200, + reason: 'The keyboard should be fully shown.'); + expect( + controller.value.pixels, + controller.value.maxPixels, + reason: 'After the keyboard is fully shown, ' + 'the entire sheet should also be visible.', + ); + }); } diff --git a/package/test/src/keyboard_inset_simulation.dart b/package/test/src/keyboard_inset_simulation.dart new file mode 100644 index 00000000..ccbbf286 --- /dev/null +++ b/package/test/src/keyboard_inset_simulation.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A widget that simulates [MediaQueryData.viewInsets] as if the keyboard +/// is shown. +/// +/// This exposes a [MediaQueryData] to its descendants, and if +/// [KeyboardInsetSimulationState.showKeyboard] is called once, +/// it will gradually increase the `MediaQueryData.viewInsets.bottom` +/// to the given [keyboardHeight] as if the on-screen keyboard is appearing. +/// +/// Although there is [WidgetTester.showKeyboard] method, we use this widget +/// instead to simulate the keyboard appearance, as `showKeyboard` does not +/// change the `viewInsets` value. +class KeyboardInsetSimulation extends StatefulWidget { + const KeyboardInsetSimulation({ + super.key, + required this.keyboardHeight, + required this.child, + }); + + final double keyboardHeight; + final Widget child; + + @override + State createState() => + KeyboardInsetSimulationState(); +} + +class KeyboardInsetSimulationState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + Future showKeyboard(Duration duration) async { + assert(_controller.isDismissed); + return _controller.animateTo(widget.keyboardHeight, duration: duration); + } + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + lowerBound: 0, + upperBound: widget.keyboardHeight, + )..addListener(() => setState(() {})); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(KeyboardInsetSimulation oldWidget) { + super.didUpdateWidget(oldWidget); + assert(widget.keyboardHeight == oldWidget.keyboardHeight); + } + + @override + Widget build(BuildContext context) { + final inheritedMediaQuery = MediaQuery.of(context); + return MediaQuery( + data: inheritedMediaQuery.copyWith( + viewInsets: inheritedMediaQuery.viewInsets.copyWith( + bottom: _controller.value, + ), + ), + child: widget.child, + ); + } +}