0

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,
          );
        },
      ),
    );
  }
}
  • Have you thought of using state management and hoisting the state up in the parent? – bqubique Mar 10 '23 at 07:47
  • @bqubique I think you're suggesting to do the stateful creation an management of the `AnimationController` in the widget calling `showModalBottomSheet` to show the modal. I *don't* want to do this, because I don't want to convert any UI element showing the modal sheet to a stateful widget; I want to abstract this away in the sheet widget. – Ben Weschler Mar 11 '23 at 08:03
  • No I meant you could create the ```AnimationController``` in the state management solution e.g. Riverpod. That way you could listen to changes there. – bqubique Mar 11 '23 at 20:33
  • @bqubique Gotcha, I think you're saying create and provide the `AnimationController` somewhere at the top of the widget tree so that any caller of `showModalBottomSheet` has access to it. I still wouldn't want to do this since it would create unnecessary clutter in the widget tree and would make it more complicated to reuse the modal sheet. The sheet widget should be able to be dropped into any project without worrying about state management. In any case, it turns out passing around AnimationControllers is necessary — see my answer below. – Ben Weschler Mar 13 '23 at 21:00

1 Answers1

0

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),
      ),
    );
  }
}