Small details that build taste in Flutter.

curated by Kamran BekirovKamran Bekirov

Smooth, draggable bottom sheets

Material's showModalBottomSheet is mechanical and soulles:

The replacement should feel smooth, keep the speed of a fling and settle like a spring:

Copy the file at the bottom and call showSpringBottomSheet, the same API as showModalBottomSheet:

showSpringBottomSheet<bool>(
  context: context,
  builder: (context) {
    return ConfirmSheet();
  },
);

The route wraps your widget in the SheetContainer at the bottom of the file. It gives every sheet the background color, the rounded top corners, and a Material, so you don't repeat them in every modal. Edit it once to match your design.

This is for small modals that don't scroll. Build the content on a Column with mainAxisSize: MainAxisSize.min so the sheet is only as tall as its content, and don't pass a scrollable inside, it steals the drag and the sheet stops closing with it. For full screen content, use an iOS 26 style sheet.

The whole file, ready to copy:

import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
  
/// Shows a modal sheet anchored to the bottom edge of the screen.
///
/// A spring-driven replacement for showModalBottomSheet. The sheet enters
/// and exits on smooth springs, and dragging it drives the route animation
/// directly, so the velocity of a released fling is carried into the open
/// or close spring.
///
/// The sheet surface comes from [SheetContainer]; the widget returned by
/// [builder] brings only its content and padding.
///
/// Returns a [Future] that resolves to the value passed to [Navigator.pop]
/// when the sheet is closed.
Future<T?> showSpringBottomSheet<T>({
  required BuildContext context,
  required WidgetBuilder builder,
}) {
  return Navigator.of(context).push(
    SpringSheetRoute<T>(
      builder: builder,
    ),
  );
}
  
/// A route that shows a spring-driven modal sheet at the bottom of the
/// screen.
class SpringSheetRoute<T> extends PopupRoute<T> {
  SpringSheetRoute({
    required this.builder,
    super.settings,
  });
  
  /// Builds the content of the sheet.
  final WidgetBuilder builder;
  
  // SwiftUI's .smooth spring written out as plain numbers: critically
  // damped, stiffness 4pi^2 / duration^2, at 300ms in and 200ms out.
  static const SpringDescription _enterSpring = SpringDescription(
    mass: 1,
    stiffness: 438.6,
    damping: 41.9,
  );
  static const SpringDescription _exitSpring = SpringDescription(
    mass: 1,
    stiffness: 987.0,
    damping: 62.8,
  );
  
  // Resistance applied when dragging past fully open.
  static const double _overdragResistance = 100;
  
  // Thresholds past which a released drag closes the sheet: fling speed in
  // sheet heights per second, or resting position as a fraction of height.
  static const double _closeVelocity = 0.9;
  static const double _closePosition = 0.5;
  
  // Release velocity in sheet heights per second (positive is downward),
  // carried from the drag into the close simulation.
  double? _releaseVelocity;
  
  bool _popped = false;
  
  @override
  Color? get barrierColor => const Color(0x8A000000);
  
  @override
  bool get barrierDismissible => true;
  
  @override
  String? get barrierLabel => null;
  
  @override
  Duration get transitionDuration => const Duration(milliseconds: 300);
  
  @override
  Duration get reverseTransitionDuration => const Duration(milliseconds: 200);
  
  // The scrim fades with the clamped animation so that dragging past fully
  // open does not push the barrier curve out of range. The slide reads the
  // raw controller.
  @override
  Animation<double>? get animation {
    final Animation<double>? raw = super.animation;
    if (raw == null) return null;
  
    return _ClampedAnimation(raw);
  }
  
  @override
  AnimationController createAnimationController() {
    return AnimationController.unbounded(
      duration: transitionDuration,
      reverseDuration: reverseTransitionDuration,
      vsync: navigator!,
    );
  }
  
  @override
  Simulation? createSimulation({required bool forward}) {
    final double velocity = _releaseVelocity ?? 0;
    _releaseVelocity = null;
  
    return SpringSimulation(
      forward ? _enterSpring : _exitSpring,
      controller?.value ?? 0,
      forward ? 1 : 0,
      -velocity,
      snapToEnd: true,
    );
  }
  
  @override
  bool didPop(T? result) {
    _popped = true;
  
    return super.didPop(result);
  }
  
  void _dragBy(double relativeDelta) {
    if (_popped) return;
  
    final AnimationController controller = this.controller!;
  
    double delta = relativeDelta;
    if (controller.value > 1) {
      final double overshoot = controller.value - 1;
      delta *= 1 / (1 + overshoot * _overdragResistance);
    }
    controller.value -= delta;
  }
  
  void _endDrag(double relativeVelocity) {
    if (_popped) return;
    final AnimationController controller = this.controller!;
    final double value = controller.value;
  
    if (value > 1) {
      // Dragged past fully open. Settle back, damping the velocity by the
      // same resistance that was applied while dragging.
      final double overshoot = value - 1;
      final double damped =
          relativeVelocity / (1 + overshoot * _overdragResistance);
      controller.animateWith(
        SpringSimulation(_enterSpring, value, 1, -damped, snapToEnd: true),
      );

      return;
    }
  
    final bool close = switch (relativeVelocity) {
      > _closeVelocity => true,
      < -_closeVelocity => false,
      _ => value < _closePosition,
    };
  
    if (close) {
      _releaseVelocity = relativeVelocity;
      navigator?.pop();
    } else {
      controller.animateWith(
        SpringSimulation(
          _enterSpring,
          value,
          1,
          -relativeVelocity,
          snapToEnd: true,
        ),
      );
    }
  }
  
  @override
  Widget buildPage(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
  ) {
    return Align(
      alignment: Alignment.bottomCenter,
      child: AnimatedBuilder(
        animation: controller!,
        builder: (context, child) {
          return FractionalTranslation(
            translation: Offset(0, 1 - controller!.value),
            child: child,
          );
        },
        child: Builder(
          builder: (context) {
            // Drag deltas are normalized by the sheet's own height, so the
            // gesture and the route animation share one coordinate space.
            double height() => context.size?.height ?? 1;
  
            return GestureDetector(
              excludeFromSemantics: true,
              onVerticalDragUpdate: (details) {
                _dragBy(details.primaryDelta! / height());
              },
              onVerticalDragEnd: (details) {
                _endDrag(details.velocity.pixelsPerSecond.dy / height());
              },
              onVerticalDragCancel: () {
                _endDrag(0);
              },
              child: SizedBox(
                width: double.infinity,
                child: SheetContainer(
                  child: builder(context),
                ),
              ),
            );
          },
        ),
      ),
    );
  }
}
  
/// The surface for sheets shown with [showSpringBottomSheet]: white with
/// rounded top corners, matching the old bottom sheet theme.
class SheetContainer extends StatelessWidget {
  const SheetContainer({
    super.key,
    required this.child,
  });
  
  final Widget child;
  
  @override
  Widget build(BuildContext context) {
    return Material(
      type: .transparency,
      child: ClipRRect(
        borderRadius: const .vertical(
          top: Radius.circular(20),
        ),
        child: ColoredBox(
          color: Colors.white,
          child: child,
        ),
      ),
    );
  }
}
  
/// An animation that forwards [parent] with its value clamped to the range
/// 0.0 to 1.0, for consumers such as the modal barrier that should not see
/// values past fully open.
class _ClampedAnimation extends Animation<double>
    with AnimationWithParentMixin<double> {
  _ClampedAnimation(this.parent);
  
  @override
  final Animation<double> parent;
  
  @override
  double get value => parent.value.clamp(0.0, 1.0);
}
Kamran Bekirov
Kamran Bekirov

Want this level of care in your Flutter apps?

Work With Me