flutterdartanimationflutter-showmodalbottomsheetanimationcontroller

Access animation position of modal bottom sheet inside of the sheet widget


TDLR:

I want to change the appearance of a custom modal sheet based on its route's animation position (i.e. change as the user slides it up and down). However, I don't want to have to convert any UI widget that shows the modal sheet to StatefulWidget in order to manage that controller. Instead, I want to abstract the logic that gets the sheet's animation position inside of the sheet.

Full Explanation:

I have a widget I'm using as a modal bottom sheet that incorporates a sheet handle, and I want to animate the handle's width when the user changes the position of the bottom sheet. From what I can tell, the only way to get access to the scroll position of an active bottom sheet is to access an AnimationController passed to the transitionAnimationController property of showModalBottomSheet.

However, I don't want to have to convert any widget that calls showModalBottomSheet and shows this modal widget into a StatefulWidget in order to create this AnimationController and then pass the controller to both showModalBottomSheet and the modal widget. I don't believe the state of the modal sheet is mounted when showModalBottomSheet is called, so I don't think it's possible to abstract the creation of an AnimationController inside of the sheet's state. This would mean that I need another way to access the route's animation position without explicitly passing an AnimationController created in a parent.

Sheet handle:

class _ModalSheetHandleState extends State<_ModalSheetHandle>
    with SingleTickerProviderStateMixin {
  final _handelHeight = Insets.xs;
  late final _controller = AnimationController(
    duration: Timings.short,
    vsync: this,
  );
  late final _animation =
      Tween<double>(begin: 35, end: 50).animate(_controller);

  @override
  void initState() {
    widget.modalSheetTransitionController.addListener(updateHandle);
    super.initState();
  }

  @override
  void dispose() {
    widget.modalSheetTransitionController.removeListener(updateHandle);
    _controller.dispose();
    super.dispose();
  }

  updateHandle() {
    if (widget.modalSheetTransitionController.isCompleted) {
      _controller.forward();
    } else if (!widget.modalSheetTransitionController.isCompleted &&
        _controller.isCompleted) {
      _controller.reverse();
    }
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (_, __) => Container(
        width: _animation.value,
        height: _handelHeight,
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(_handelHeight),
        ),
      ),
    );
  }
}

What I'm doing right now:

Currently, I'm creating the AnimationController passed to showModalBottomSheet in the widget that uses the modal bottom sheet. This is the opposite of what I want to do, since I want to abstract the stateful management of this controller inside of the modal sheet. Here's what I'm doing currently:

// I *don't* want to convert any UI widget using the modal
// sheet to a stateful widget.
class _SomeUIElementState extends State<SomeUIElement> 
    with SingleTickerProviderStateMixin {
  late final _modalSheetTransitionController = AnimationController(
    duration: Timings.med,
    vsync: this,
  );
  
  @override
  Widget build(BuildContext context) {
    return SomeWidget(
      child: Button(
        onTap: () {
          showModalBottomSheet(
            context: context,
            builder: (_) => ContextMenu(
              modalSheetTransitionAnimationController:
                  _modalSheetTransitionController,
              actions: /*actions*/,
            ),
            transitionAnimationController:
                _modalSheetTransitionController,
          );
        },
      ),
    );
  }
}

Solution

  • Figured out how to do this. Instead of accessing the route animation through an AnimationController, access it through:

    ModalRoute.of(context)!.animation!
    

    Updated modal handle:

    class _ModalSheetHandleState extends State<_ModalSheetHandle>
        with SingleTickerProviderStateMixin {
      final _handelHeight = Insets.xs;
      final double _collapsedHandleWidth = 35;
      final double _expandedHandleWidth = 50;
      bool _isExpanded = false;
    
      @override
      void didChangeDependencies() {
        // Listen to the modal's route animation here. This calls
        // dependOnInheritedWidgetOfExactType, which can't be called in initState.
        // It is safe to call in didChangeDependencies, which is called immediately
        // after initState.
        final routeAnimation = ModalRoute.of(context)!.animation!;
        routeAnimation.addListener(() => updateHandle(routeAnimation));
        super.didChangeDependencies();
      }
    
      void updateHandle(Animation<double> routeAnimation) {
        if (routeAnimation.isCompleted) {
          setState(() => _isExpanded = true);
        } else if (!routeAnimation.isCompleted && _isExpanded) {
          setState(() => _isExpanded = false);
        }
      }
    
      @override
      Widget build(BuildContext context) {
        return AnimatedContainer(
          duration: Timings.short,
          width: _isExpanded ? _expandedHandleWidth : _collapsedHandleWidth,
          height: _handelHeight,
          decoration: BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.circular(_handelHeight),
          ),
        );
      }
    }