Repository: fujidaiti/smooth_sheets (dart)

Issue:
Snapping effect doesn't work when closing keyboard on non-fullscreen sheet
Thank you for your response and the suggestion to use `SheetContentScaffold`. However, the issue still persists, the sheet still partially moves up when the keyboard opens. I also noted your comment about avoiding nesting a sheet within a Scaffold, so I removed the scaffold from the root.

**Here is the updated code I used:**
```
import 'package:flutter/material.dart';
import 'package:smooth_sheets/smooth_sheets.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: SmoothSheetDemo(),
    );
  }
}

class SmoothSheetDemo extends StatefulWidget {
  const SmoothSheetDemo({super.key});

  @override
  _SmoothSheetDemoState createState() => _SmoothSheetDemoState();
}

class _SmoothSheetDemoState extends State<SmoothSheetDemo> {
  final SheetController _smoothSheetController = SheetController();

  @override
  Widget build(BuildContext context) {
    final List<double> snaps = [0.3, 0.5, 1.0];
    const minExtent = Extent.proportional(0.3);
    final extentSnaps = snaps.map((e) => Extent.proportional(e)).toList();
    final physics = SnappingSheetPhysics(
      snappingBehavior: SnapToNearest(
        snapTo: extentSnaps,
      ),
    );

    return SafeArea(
      child: Stack(
        children: [
          const Scaffold(
            body: Column(
              children: [
                TextField(),
                Expanded(
                  child: Placeholder(),
                )
              ],
            ),
            resizeToAvoidBottomInset: false,
          ),
          DraggableSheet(
            minExtent: minExtent,
            controller: _smoothSheetController,
            initialExtent: minExtent,
            physics: physics,
            child: SheetContentScaffold(
              body: buildContent(),
            ),
          ),
        ],
      ),
    );
  }

  Widget buildContent() {
    return Container(
      color: Colors.grey[900],
      child: Column(
        children: [
          const SizedBox(height: 16),
          const TextField(),
          const SizedBox(height: 16),
          Expanded(
            child: ListView(
              children: List.generate(
                100,
                (index) => Container(
                  margin: const EdgeInsets.all(4),
                  child: const FlutterLogo(),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

```

_Originally posted by @faizanje in https://github.com/fujidaiti/smooth_sheets/issues/184#issuecomment-2197356152_

Pull request:
## Related issues (optional)

- Fixes #226
- Fixes #192 

## Description

This PR refines the `didFinalizeDimensions` method of `IdleActivity` to address the unstable behavior of the sheet when opening or closing the on-screen keyboard, as reported in #192 and #226.

## Summary (check all that apply)

- [x] Modified / added code
- [x] Modified / added tests
- [x] Modified / added examples
- [ ] Modified / added others (pubspec.yaml, workflows, etc...)
- [ ] Updated README
- [ ] Contains breaking changes
  - [ ] Created / updated migration guide
- [ ] Incremented version number
  - [ ] Updated CHANGELOG

Patch:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index a6d65440..853918bf 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 00000000..315397ab
--- /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 90e2e1f5..3e4fac79 100644
--- a/lib/src/foundation/sheet_activity.dart
+++ b/lib/src/foundation/sheet_activity.dart
@@ -66,48 +66,79 @@ abstract class SheetActivity<T extends SheetExtent> {
 
   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 986a0f40..dec60d71 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 ef56d37a..66f9a8d2 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 8b5be257..206c13d7 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;
 


Test patch:
diff --git a/test/foundation/physics_test.dart b/test/foundation/physics_test.dart
index e2a947f8..c8fd0f35 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 c4f74abf..20986351 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 b0849d81..97677a1a 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 55eb1ccf..b042aebf 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,
       );