diff --git a/.vscode/launch.json b/.vscode/launch.json index a6d6544..853918b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -151,5 +151,12 @@ "program": "lib/tutorial/scrollable_pageview_sheet.dart", "cwd": "./example" }, + { + "name": "TextField with multiple stops", + "request": "launch", + "type": "dart", + "program": "lib/tutorial/textfield_with_multiple_stops.dart", + "cwd": "./example" + } ] } diff --git a/example/lib/tutorial/textfield_with_multiple_stops.dart b/example/lib/tutorial/textfield_with_multiple_stops.dart new file mode 100644 index 0000000..315397a --- /dev/null +++ b/example/lib/tutorial/textfield_with_multiple_stops.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +void main() { + runApp(const TextFieldWithMultipleStops()); +} + +class TextFieldWithMultipleStops extends StatelessWidget { + const TextFieldWithMultipleStops({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Stack( + children: [ + const Scaffold(), + ScrollableSheet( + initialExtent: const Extent.proportional(0.7), + minExtent: const Extent.proportional(0.4), + physics: const BouncingSheetPhysics( + parent: SnappingSheetPhysics( + snappingBehavior: SnapToNearest( + snapTo: [ + Extent.proportional(0.4), + Extent.proportional(0.7), + Extent.proportional(1), + ], + ), + ), + ), + child: SheetContentScaffold( + primary: true, + backgroundColor: Colors.grey, + appBar: AppBar( + backgroundColor: Colors.grey, + title: const Text('Sheet with a TextField'), + leading: IconButton( + onPressed: () => primaryFocus?.unfocus(), + icon: const Icon(Icons.keyboard_hide), + ), + ), + body: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 200), + child: const SingleChildScrollView( + child: TextField(maxLines: null), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/foundation/sheet_activity.dart b/lib/src/foundation/sheet_activity.dart index 90e2e1f..3e4fac7 100644 --- a/lib/src/foundation/sheet_activity.dart +++ b/lib/src/foundation/sheet_activity.dart @@ -66,48 +66,79 @@ abstract class SheetActivity { void didChangeViewportDimensions(Size? oldSize, EdgeInsets? oldInsets) {} - // TODO: Change `double?` to `Extent?`. + // TODO: Change `double?`s to `Extent?`s. void didChangeBoundaryConstraints( double? oldMinPixels, double? oldMaxPixels, ) {} + /// Called when all relevant metrics of the sheet are finalized + /// for the current frame. + /// + /// The [oldContentSize], [oldViewportSize], and [oldViewportInsets] will be + /// `null` if the [SheetMetrics.contentSize], [SheetMetrics.viewportSize], and + /// [SheetMetrics.viewportInsets] have not changed since the previous frame. + /// + /// Since this is called after the layout phase and before the painting phase + /// of the sheet, it is safe to update [SheetMetrics.pixels] to reflect the + /// latest metrics. + /// + /// By default, this method updates [SheetMetrics.pixels] to maintain the + /// current [Extent], which is determined by [SheetPhysics.findSettledExtent] + /// using the metrics of the previous frame. void didFinalizeDimensions( Size? oldContentSize, Size? oldViewportSize, EdgeInsets? oldViewportInsets, ) { - if (oldContentSize == null && oldViewportSize == null) { - // The sheet was laid out, but not changed in size. + if (oldContentSize == null && + oldViewportSize == null && + oldViewportInsets == null) { return; } - final metrics = owner.metrics; - final oldPixels = metrics.pixels; - final newInsets = metrics.viewportInsets; - final oldInsets = oldViewportInsets ?? newInsets; - final deltaInsetBottom = newInsets.bottom - oldInsets.bottom; - - switch (deltaInsetBottom) { - case > 0: - // Prevents the sheet from being pushed off the screen by the keyboard. - final correction = min(0.0, metrics.maxViewPixels - metrics.viewPixels); - owner - ..setPixels(oldPixels + correction) - ..didUpdateMetrics(); + final oldMetrics = owner.metrics.copyWith( + contentSize: oldContentSize, + viewportSize: oldViewportSize, + viewportInsets: oldViewportInsets, + ); + final prevDetent = owner.physics.findSettledExtent(0, oldMetrics); + final newPixels = prevDetent.resolve(owner.metrics.contentSize); - case < 0: - // Appends the delta of the bottom inset (typically the keyboard height) - // to keep the visual sheet position unchanged. - owner - ..setPixels(min( - oldPixels - deltaInsetBottom, - owner.metrics.maxPixels, - )) - ..didUpdateMetrics(); + if (newPixels == owner.metrics.pixels) { + return; + } else if (oldViewportInsets != null && + oldViewportInsets.bottom != owner.metrics.viewportInsets.bottom) { + // TODO: Is it possible to remove this assumption? + // We currently assume that when the bottom viewport inset changes, + // it is due to the appearance or disappearance of the keyboard, + // and that this change will gradually occur over several frames, + // likely due to animation. + owner + ..setPixels(newPixels) + ..didUpdateMetrics(); + return; } - owner.settle(); + const minAnimationDuration = Duration(milliseconds: 150); + const meanAnimationVelocity = 300 / 1000; // pixels per millisecond + final distance = (newPixels - owner.metrics.pixels).abs(); + final estimatedDuration = Duration( + milliseconds: (distance / meanAnimationVelocity).round(), + ); + if (estimatedDuration >= minAnimationDuration) { + owner.animateTo( + prevDetent, + duration: estimatedDuration, + curve: Curves.easeInOut, + ); + } else { + // The destination is close enough to the current position, + // so we immediately snap to it without animation. + owner + ..setPixels(newPixels) + ..didUpdateMetrics(); + } } @protected @@ -316,6 +347,7 @@ class BallisticSheetActivity extends SheetActivity if (owner.physics.findSettledExtent(velocity, oldMetrics) case final detent when detent.resolve(owner.metrics.contentSize) != newPixels) { + // TODO: Use SheetExtent.settle instead. owner.beginActivity( SettlingSheetActivity.withDuration( const Duration(milliseconds: 150), @@ -465,15 +497,6 @@ class SettlingSheetActivity extends SheetActivity { class IdleSheetActivity extends SheetActivity { @override SheetStatus get status => SheetStatus.stable; - - // TODO: Start a settling activity if the keyboard animation is running. - // @override - // void didFinalizeDimensions( - // Size? oldContentSize, - // Size? oldViewportSize, - // EdgeInsets? oldViewportInsets, - // ) { - // } } @internal diff --git a/lib/src/foundation/sheet_extent.dart b/lib/src/foundation/sheet_extent.dart index 986a0f4..dec60d7 100644 --- a/lib/src/foundation/sheet_extent.dart +++ b/lib/src/foundation/sheet_extent.dart @@ -416,16 +416,13 @@ abstract class SheetExtent extends ChangeNotifier beginActivity(BallisticSheetActivity(simulation: simulation)); } - // TODO: Change the signature to `void settle(Extent settledPosition, [Duration? duration])`. - void settle() { - assert(metrics.hasDimensions); - final simulation = physics.createSettlingSimulation(metrics); - if (simulation != null) { - // TODO: Begin a SettlingSheetActivity - goBallisticWith(simulation); - } else { - goIdle(); - } + void settleTo(Extent detent, Duration duration) { + beginActivity( + SettlingSheetActivity.withDuration( + duration, + destination: detent, + ), + ); } Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { diff --git a/lib/src/foundation/sheet_physics.dart b/lib/src/foundation/sheet_physics.dart index ef56d37..66f9a8d 100644 --- a/lib/src/foundation/sheet_physics.dart +++ b/lib/src/foundation/sheet_physics.dart @@ -181,7 +181,7 @@ mixin SheetPhysicsMixin on SheetPhysics { final minPixels = metrics.minPixels; final maxPixels = metrics.maxPixels; if (FloatComp.distance(metrics.devicePixelRatio) - .isInBounds(pixels, minPixels, maxPixels)) { + .isInBoundsExclusive(pixels, minPixels, maxPixels)) { return Extent.pixels(pixels); } else if ((pixels - minPixels).abs() < (pixels - maxPixels).abs()) { return metrics.minExtent; diff --git a/lib/src/internal/float_comp.dart b/lib/src/internal/float_comp.dart index 8b5be25..206c13d 100644 --- a/lib/src/internal/float_comp.dart +++ b/lib/src/internal/float_comp.dart @@ -77,6 +77,10 @@ class FloatComp { bool isInBounds(double a, double min, double max) => !isOutOfBounds(a, min, max); + /// Returns `true` if [a] is in the range `(min, max)`, exclusive. + bool isInBoundsExclusive(double a, double min, double max) => + isGreaterThan(a, min) && isLessThan(a, max); + /// Returns [b] if [a] is approximately equal to [b], otherwise [a]. double roundToIfApprox(double a, double b) => isApprox(a, b) ? b : a; diff --git a/test/foundation/physics_test.dart b/test/foundation/physics_test.dart index e2a947f..c8fd0f3 100644 --- a/test/foundation/physics_test.dart +++ b/test/foundation/physics_test.dart @@ -16,7 +16,7 @@ class _SheetPhysicsWithDefaultConfiguration extends SheetPhysics const _referenceSheetMetrics = SheetMetrics( minExtent: Extent.pixels(0), - maxExtent: Extent.pixels(600), + maxExtent: Extent.proportional(1), pixels: 600, contentSize: Size(360, 600), viewportSize: Size(360, 700), diff --git a/test/foundation/sheet_activity_test.dart b/test/foundation/sheet_activity_test.dart index c4f74ab..2098635 100644 --- a/test/foundation/sheet_activity_test.dart +++ b/test/foundation/sheet_activity_test.dart @@ -3,8 +3,8 @@ import 'package:flutter/painting.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; import 'package:smooth_sheets/src/foundation/sheet_activity.dart'; -import 'package:smooth_sheets/src/foundation/sheet_extent.dart'; import '../src/matchers.dart'; import '../src/stubbing.dart'; @@ -299,4 +299,89 @@ void main() { expect(metrics.pixels, 376); // 1120 * 0.05 = 56 pixels in 50ms }); }); + + group('IdleSheetActivity', () { + late MockSheetExtent owner; + late SheetMetrics metrics; + + setUp(() { + owner = MockSheetExtent(); + when(owner.physics).thenReturn(kDefaultSheetPhysics); + when(owner.metrics).thenAnswer((_) => metrics); + when(owner.setPixels(any)).thenAnswer((invocation) { + final pixels = invocation.positionalArguments[0] as double; + metrics = metrics.copyWith(pixels: pixels); + }); + }); + + test('should maintain previous extent when keyboard appears', () { + final activity = IdleSheetActivity()..init(owner); + const oldContentSize = Size(400, 900); + const oldViewportInsets = EdgeInsets.zero; + metrics = const SheetMetrics( + pixels: 450, + minExtent: Extent.proportional(0.5), + maxExtent: Extent.proportional(1), + contentSize: Size(400, 850), + viewportSize: Size(400, 900), + viewportInsets: EdgeInsets.only(bottom: 50), + ); + activity + ..didChangeContentSize(oldContentSize) + ..didChangeViewportDimensions(oldContentSize, oldViewportInsets) + ..didFinalizeDimensions(oldContentSize, null, oldViewportInsets); + expect(metrics.pixels, 425); + }); + + test( + 'should maintain previous extent when content size changes, ' + 'without animation if gap is small', + () { + final activity = IdleSheetActivity()..init(owner); + const oldContentSize = Size(400, 600); + metrics = const SheetMetrics( + pixels: 300, + minExtent: Extent.proportional(0.5), + maxExtent: Extent.proportional(1), + contentSize: Size(400, 580), + viewportSize: Size(400, 900), + viewportInsets: EdgeInsets.zero, + ); + activity + ..didChangeContentSize(oldContentSize) + ..didFinalizeDimensions(oldContentSize, null, null); + expect(metrics.pixels, 290); + // Still in the idle activity. + verifyNever(owner.beginActivity(any)); + }, + ); + + test( + 'should maintain previous extent when content size changes, ' + 'with animation if gap is large', + () { + final activity = IdleSheetActivity()..init(owner); + const oldContentSize = Size(400, 600); + metrics = const SheetMetrics( + pixels: 300, + minExtent: Extent.proportional(0.5), + maxExtent: Extent.proportional(1), + contentSize: Size(400, 500), + viewportSize: Size(400, 900), + viewportInsets: EdgeInsets.zero, + ); + activity + ..didChangeContentSize(oldContentSize) + ..didFinalizeDimensions(oldContentSize, null, null); + expect(metrics.pixels, 300); + verify( + owner.animateTo( + const Extent.proportional(0.5), + duration: anyNamed('duration'), + curve: anyNamed('curve'), + ), + ); + }, + ); + }); } diff --git a/test/foundation/sheet_viewport_test.dart b/test/foundation/sheet_viewport_test.dart index b0849d8..97677a1 100644 --- a/test/foundation/sheet_viewport_test.dart +++ b/test/foundation/sheet_viewport_test.dart @@ -19,6 +19,9 @@ class _FakeSheetContext extends Fake implements SheetContext { @override double get devicePixelRatio => 3.0; + + @override + TickerProvider get vsync => const TestVSync(); } class _FakeSheetActivity extends SheetActivity { diff --git a/test/src/stubbing.mocks.dart b/test/src/stubbing.mocks.dart index 55eb1cc..b042aeb 100644 --- a/test/src/stubbing.mocks.dart +++ b/test/src/stubbing.mocks.dart @@ -420,10 +420,17 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { ); @override - void settle() => super.noSuchMethod( + void settleTo( + _i3.Extent? detent, + Duration? duration, + ) => + super.noSuchMethod( Invocation.method( - #settle, - [], + #settleTo, + [ + detent, + duration, + ], ), returnValueForMissingStub: null, );