Skip to content

Create a ModalBottomSheetPage #87352

@idkq

Description

@idkq

By default, Navigator does not support navigating to a modal bottom sheet Page.

Use case

I want to navigate to a modal bottom sheet. 'Navigating' means using navigator - as opposed to 'show', using showModalBottomSheet.

Details

Say you have a Navigator 2.0 that receive a list of page on Navigator.pages.

void main() {
  runApp(MaterialApp(title: 'Navigation', home: MyW()));
}

class MyW extends StatefulWidget {
  @override
  State<MyW> createState() => _MyWState();
}

class _MyWState extends State<MyW> {
  bool showModal = false;

  @override
  Widget build(BuildContext context) {
    return Navigator(
        onPopPage: (route, dynamic result) {
          return route.didPop(result);
        },
        pages: [
          MaterialPage(
            child: Container(
              color: Colors.blue,
              child: Center(
                  child: ElevatedButton(
                child: Text('show'),
                onPressed: () {
                  setState(() {
                    showModal = !showModal;
                  });
                },
              )),
            ),
          ),
          if (showModal) SecondPage() // how to create a Modal Bottom Sheet Page
          
        ]);
  }
}

In the example above a Modal Page must be subclassed from Page<T>, which receives a subclassed PopupRoute<T> for it to work.

Solution

Check this solution lulupointu/vrouter#63 (comment)

So ultimately we have this:

Click to expand!
import 'package:flutter/material.dart';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(MaterialApp(title: 'Navigation', home: MyW()));
}

class MyW extends StatefulWidget {
  @override
  State<MyW> createState() => _MyWState();
}

class _MyWState extends State<MyW> {
  bool showModal = false;

  @override
  Widget build(BuildContext context) {
    return Navigator(
        onPopPage: (route, dynamic result) {
          return route.didPop(result);
        },
        pages: [
          MaterialPage(
            child: Container(
              color: Colors.blue,
              child: Center(
                  child: ElevatedButton(
                child: Text('show'),
                onPressed: () {
                  setState(() {
                    showModal = !showModal;
                  });
                },
              )),
            ),
          ),
          if (showModal)
            ModalBottomSheetPage(
                builder: (BuildContext context) {
                  showModal = !showModal;
                  return Container();
                },
                key: ValueKey('xx'),
                name: 'kjh')
        ]);
  }
}

const Duration _bottomSheetEnterDuration = Duration(milliseconds: 250);
const Duration _bottomSheetExitDuration = Duration(milliseconds: 200);
const Curve _modalBottomSheetCurve = decelerateEasing;

class ModalBottomSheetRoute<T> extends PopupRoute<T> {
  ModalBottomSheetRoute({
    this.builder,
    required this.capturedThemes,
    this.barrierLabel,
    this.backgroundColor,
    this.elevation,
    this.shape,
    this.clipBehavior,
    this.modalBarrierColor,
    this.isDismissible = true,
    this.enableDrag = true,
    required this.isScrollControlled,
    RouteSettings? settings,
    this.transitionAnimationController,
  }) : super(settings: settings);

  final WidgetBuilder? builder;
  final CapturedThemes capturedThemes;
  final bool isScrollControlled;
  final Color? backgroundColor;
  final double? elevation;
  final ShapeBorder? shape;
  final Clip? clipBehavior;
  final Color? modalBarrierColor;
  final bool isDismissible;
  final bool enableDrag;
  final AnimationController? transitionAnimationController;

  @override
  Duration get transitionDuration => _bottomSheetEnterDuration;

  @override
  Duration get reverseTransitionDuration => _bottomSheetExitDuration;

  @override
  bool get barrierDismissible => isDismissible;

  @override
  final String? barrierLabel;

  @override
  Color get barrierColor => modalBarrierColor ?? Colors.black54;

  AnimationController? _animationController;

  // @override
  // AnimationController createAnimationController() {
  //   assert(_animationController == null);
  //   _animationController = transitionAnimationController ??
  //       BottomSheet.createAnimationController(navigator!.overlay!);
  //   return _animationController!;
  // }

  @override
  Widget buildPage(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation) {
    // By definition, the bottom sheet is aligned to the bottom of the page
    // and isn't exposed to the top padding of the MediaQuery.
    final Widget bottomSheet = MediaQuery.removePadding(
      context: context,
      removeTop: true,
      child: Builder(
        builder: (BuildContext context) {
          final BottomSheetThemeData sheetTheme =
              Theme.of(context).bottomSheetTheme;
          return _ModalBottomSheet<T>(
            route: this,
            backgroundColor: backgroundColor ??
                sheetTheme.modalBackgroundColor ??
                sheetTheme.backgroundColor,
            elevation:
                elevation ?? sheetTheme.modalElevation ?? sheetTheme.elevation,
            shape: shape,
            clipBehavior: clipBehavior,
            isScrollControlled: isScrollControlled,
            enableDrag: enableDrag,
          );
        },
      ),
    );
    return capturedThemes.wrap(bottomSheet);
  }
}

class _ModalBottomSheet<T> extends StatefulWidget {
  const _ModalBottomSheet({
    Key? key,
    this.route,
    this.backgroundColor,
    this.elevation,
    this.shape,
    this.clipBehavior,
    this.isScrollControlled = false,
    this.enableDrag = true,
  }) : super(key: key);

  final ModalBottomSheetRoute<T>? route;
  final bool isScrollControlled;
  final Color? backgroundColor;
  final double? elevation;
  final ShapeBorder? shape;
  final Clip? clipBehavior;
  final bool enableDrag;

  @override
  _ModalBottomSheetState<T> createState() => _ModalBottomSheetState<T>();
}

class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> {
  ParametricCurve<double> animationCurve = _modalBottomSheetCurve;

  String _getRouteLabel(MaterialLocalizations localizations) {
    switch (Theme.of(context).platform) {
      case TargetPlatform.iOS:
      case TargetPlatform.macOS:
        return '';
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
      case TargetPlatform.linux:
      case TargetPlatform.windows:
        return localizations.dialogLabel;
    }
  }

  void handleDragStart(DragStartDetails details) {
    // Allow the bottom sheet to track the user's finger accurately.
    animationCurve = Curves.linear;
  }

  void handleDragEnd(DragEndDetails details, {bool? isClosing}) {
    // Allow the bottom sheet to animate smoothly from its current position.
    animationCurve = _BottomSheetSuspendedCurve(
      widget.route!.animation!.value,
      curve: _modalBottomSheetCurve,
    );
  }

  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasMediaQuery(context));
    assert(debugCheckHasMaterialLocalizations(context));
    final MediaQueryData mediaQuery = MediaQuery.of(context);
    final MaterialLocalizations localizations =
        MaterialLocalizations.of(context);
    final String routeLabel = _getRouteLabel(localizations);

    return AnimatedBuilder(
      animation: widget.route!.animation!,
      child: Padding(
        padding: MediaQuery.of(context).viewInsets,
        child: BottomSheet(
          animationController: widget.route!._animationController,
          onClosing: () {
            if (widget.route!.isCurrent) {
              Navigator.pop(context);
            }
          },
          builder: widget.route!.builder!,
          backgroundColor: widget.backgroundColor,
          elevation: widget.elevation,
          shape: widget.shape,
          clipBehavior: widget.clipBehavior,
          enableDrag: widget.enableDrag,
          onDragStart: handleDragStart,
          onDragEnd: handleDragEnd,
        ),
      ),
      builder: (BuildContext context, Widget? child) {
        // Disable the initial animation when accessible navigation is on so
        // that the semantics are added to the tree at the correct time.
        final double animationValue = animationCurve.transform(
            mediaQuery.accessibleNavigation
                ? 1.0
                : widget.route!.animation!.value);
        return Semantics(
          scopesRoute: true,
          namesRoute: true,
          label: routeLabel,
          explicitChildNodes: true,
          child: ClipRect(
            child: CustomSingleChildLayout(
              delegate: _ModalBottomSheetLayout(
                  animationValue, widget.isScrollControlled),
              child: child,
            ),
          ),
        );
      },
    );
  }
}

class _BottomSheetSuspendedCurve extends ParametricCurve<double> {
  /// Creates a suspended curve.
  const _BottomSheetSuspendedCurve(
    this.startingPoint, {
    this.curve = Curves.easeOutCubic,
  });

  /// The progress value at which [curve] should begin.
  ///
  /// This defaults to [Curves.easeOutCubic].
  final double startingPoint;

  /// The curve to use when [startingPoint] is reached.
  final Curve curve;

  @override
  double transform(double t) {
    assert(t >= 0.0 && t <= 1.0);
    assert(startingPoint >= 0.0 && startingPoint <= 1.0);

    if (t < startingPoint) {
      return t;
    }

    if (t == 1.0) {
      return t;
    }

    final double curveProgress = (t - startingPoint) / (1 - startingPoint);
    final double transformed = curve.transform(curveProgress);
    return lerpDouble(startingPoint, 1, transformed)!;
  }

  @override
  String toString() {
    return '${describeIdentity(this)}($startingPoint, $curve)';
  }
}

class _ModalBottomSheetLayout extends SingleChildLayoutDelegate {
  _ModalBottomSheetLayout(this.progress, this.isScrollControlled);

  final double progress;
  final bool isScrollControlled;

  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
    return BoxConstraints(
      minWidth: constraints.maxWidth,
      maxWidth: constraints.maxWidth,
      minHeight: 0.0,
      maxHeight: isScrollControlled
          ? constraints.maxHeight
          : constraints.maxHeight * 9.0 / 16.0,
    );
  }

  @override
  Offset getPositionForChild(Size size, Size childSize) {
    return Offset(0.0, size.height - childSize.height * progress);
  }

  @override
  bool shouldRelayout(_ModalBottomSheetLayout oldDelegate) {
    return progress != oldDelegate.progress;
  }
}

class ModalBottomSheetPage<T> extends Page<T> {
  final WidgetBuilder builder;
  final Color? backgroundColor;
  final double? elevation;
  final ShapeBorder? shape;
  final Clip? clipBehavior;
  final Color? barrierColor;
  final bool isScrollControlled;
  final bool useRootNavigator;
  final bool isDismissible;
  final bool enableDrag;
  final AnimationController? transitionAnimationController;

  ModalBottomSheetPage({
    required LocalKey key,
    required String? name,
    required this.builder,
    this.backgroundColor,
    this.elevation,
    this.shape,
    this.clipBehavior,
    this.barrierColor,
    this.isScrollControlled = false,
    this.useRootNavigator = false,
    this.isDismissible = true,
    this.enableDrag = true,
    this.transitionAnimationController,
  }) : super(key: key, name: name);

  @override
  Route<T> createRoute(BuildContext context) {
    return ModalBottomSheetRoute<T>(
      builder: builder,
      capturedThemes: InheritedTheme.capture(
          from: context,
          to: Navigator.of(context, rootNavigator: useRootNavigator).context),
      isScrollControlled: isScrollControlled,
      barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
      backgroundColor: backgroundColor,
      elevation: elevation,
      shape: shape,
      clipBehavior: clipBehavior,
      isDismissible: isDismissible,
      modalBarrierColor: barrierColor,
      enableDrag: enableDrag,
      settings: this,
      // This is important and needs to be like this
      transitionAnimationController: transitionAnimationController,
    );
  }
}

I see that other Nav packages are also having issues slovnicki/beamer#276

@lulupointu @chunhtai @johnpryan @slovnicki

Maybe there is a better way?

I tried to create a Widget that calls showModalBottomSheet. But the parent widget (the screen behind the modal) would need to be provided. Not a good hack.

Click to expand!
class ModalWidget extends StatefulWidget {
  final Widget parent;
  final Widget child;
  const ModalWidget({Key? key,required this.child, required this.parent}) : super(key: key);

  @override
  State<ModalWidget> createState() => _ModalWidgetState();
}

class _ModalWidgetState extends State<ModalWidget> {
  @override
  void initState() {
    super.initState();
    SchedulerBinding.instance!.addPostFrameCallback((timeStamp) {
       showModalBottomSheet<void>(
         context: context,
         builder: (BuildContext context) {
        
          return widget.child;
         },
      );
    });
  }

  @override
  Widget build(BuildContext context) => widget.parent;
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Issues that are less important to the Flutter projectc: new featureNothing broken; request for a new capabilityc: proposalA detailed proposal for a change to Flutterf: routesNavigator, Router, and related APIs.frameworkflutter/packages/flutter repository. See also f: labels.team-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework team

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions