I’m trying to implement the OpenContainer transition for go_router in Flutter. It seems like no matter what I do, the end result is choppy. :/

I’m using the native Transition widgets as well as the relatively new SnapshotWidget, but to no avail. Should I maybe use a custom painter? Would that improve performance?

Please take a look at the code below and let me know if you see any ways that I could improve performance.


Here’s my attempt at an OpenContainer transition for go_router:

import 'package:flutter/material.dart';
import 'package:your_project_name_xxx/ui_models/container_transition_extra.dart';

class ContainerTransition extends StatefulWidget {
  final ContainerTransitionExtra extra;
  final Animation<double> animation;
  final Widget? sourceWidget;
  final Widget targetWidget;

  const ContainerTransition({
    super.key,
    required this.extra,
    required this.animation,
    required this.sourceWidget,
    required this.targetWidget,
  });

  @override
  State<ContainerTransition> createState() => _ContainerTransitionState();
}

class _ContainerTransitionState extends State<ContainerTransition> {
  static final _toTween = Tween<double>(begin: 0, end: 1);
  static final _fromTween = Tween<double>(begin: 1, end: 0);

  late SnapshotController _snapshotController;
  late CurvedAnimation _curvedAnimation;
  late CurvedAnimation _sourceAnimation;
  late CurvedAnimation _targetAnimation;

  late RelativeRect? _sourcePosition;
  late Animation<double> _scrimOpacityAnimation;
  late Animation<double> _sourceOpacityAnimation;
  late Animation<double> _targetOpacityAnimation;
  late Animation<BorderRadius?> _containerRadiusAnimation;
  late Animation<RelativeRect> _containerPositionAnimation;

  @override
  void initState() {
    super.initState();
    _sourcePosition = widget.extra.tween.begin;
    _snapshotController = SnapshotController(allowSnapshotting: true);
    _curvedAnimation = CurvedAnimation(
      parent: widget.animation,
      curve: Curves.easeInOut,
    );
    _curvedAnimation.addStatusListener((status) {
      if (status.isAnimating) {
        _snapshotController.allowSnapshotting = true;
      } else if (status.isCompleted || status.isDismissed) {
        _snapshotController.allowSnapshotting = false;
      }
    });
    _sourceAnimation = CurvedAnimation(
      parent: _curvedAnimation,
      curve: Interval(0, 1 / 3),
    );
    _targetAnimation = CurvedAnimation(
      parent: _curvedAnimation,
      curve: Interval(1 / 3, 1),
    );
    _scrimOpacityAnimation = _toTween.animate(_sourceAnimation);
    _sourceOpacityAnimation = _fromTween.animate(_sourceAnimation);
    _targetOpacityAnimation = _toTween.animate(_targetAnimation);
    _containerRadiusAnimation = BorderRadiusTween(
            begin: BorderRadius.circular(widget.extra.containerRadius),
            end: BorderRadius.zero)
        .animate(_curvedAnimation);
    _containerPositionAnimation = _curvedAnimation.drive(widget.extra.tween);
  }

  @override
  void dispose() {
    _snapshotController.dispose();
    _sourceAnimation.dispose();
    _targetAnimation.dispose();
    _curvedAnimation.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Positioned.fill(
          child: FadeTransition(
            opacity: _scrimOpacityAnimation,
            child: ColoredBox(color: widget.extra.scrimColor),
          ),
        ),
        PositionedTransition(
          rect: _containerPositionAnimation,
          child: DecoratedBox(
            decoration: BoxDecoration(
              borderRadius: _containerRadiusAnimation.value,
              color: widget.extra.containerColor,
            ),
            child: FadeTransition(
              opacity: _targetOpacityAnimation,
              child: widget.targetWidget,
            ),
          ),
        ),
        if (_sourcePosition != null)
          Positioned.fromRelativeRect(
            rect: _sourcePosition!,
            child: FadeTransition(
              opacity: _sourceOpacityAnimation,
              child: widget.sourceWidget,
            ),
          ),
      ],
    );
  }
}

Here’s how I’m building the route:

Page<T> buildContainerRoute<T>(
    BuildContext context,
    GoRouterState state,
    Ref ref,
    Widget child,
  ) {
    final containerExtra = state.extra;

    if (containerExtra is ContainerTransitionExtra) {
      final registryTag = containerExtra.sourceBuilderTag;
      final registryBuilder =
          registryTag != null ? SourceBuilderRegistry().get(registryTag) : null;
      if (registryBuilder != null) {
        if (registryBuilder.wasUsedAlready) {
          SourceBuilderRegistry().unregister(registryTag!);
        } else {
          SourceBuilderRegistry().markAsUsed(registryTag!);
        }
      }
      return CustomTransitionPage<T>(
        key: state.pageKey,
        child: child,
        transitionDuration: Durations.medium2,
        reverseTransitionDuration: Durations.medium1,
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          return ContainerTransition(
            animation: animation,
            extra: containerExtra,
            sourceWidget:
                Center(child: registryBuilder?.registryItem.call(context)),
            targetWidget: ClipRect(
              child: OverflowBox(
                alignment: Alignment.topCenter,
                maxWidth: containerExtra.cachedMaxWidth,
                maxHeight: containerExtra.cachedMaxHeight,
                child: child,
              ),
            ),
          );
        },
      );
    } else {
      if (!kIsWeb && Platform.isAndroid) {
        return MaterialPage(key: state.pageKey, child: child);
      } else {
        return CupertinoPage(key: state.pageKey, child: child);
      }
    }
  }