Другое

Руководство по реализации анимаций Hero в Flutter с GoRouter

Узнайте, как реализовать анимации Hero в переходах GoRouter, сохраняя свайп. Полные примеры кода и руководство по устранению неполадок для Flutter-разработчиков.

Как реализовать анимацию Hero в пользовательском переходе GoRouter в Flutter?

Я пытался реализовать анимацию, похожую на переход Cupertino в Flutter, используя GoRouter. Я создал эффект параллакса с помощью пользовательского перехода, но анимации Hero не работают корректно. Ниже приведена моя текущая реализация, но я не понимаю, как добавить поддержку анимации Hero.

Вот мой текущий код пользовательского перехода:

dart
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

CustomTransitionPage<T> buildIosSwipeTransition<T>({
  required Widget child,
  required GoRouterState state,
  bool maintainState = true,
  bool fullscreenDialog = false,
}) {
  return CustomTransitionPage<T>(
    key: state.pageKey,
    name: state.name,
    child: child,
    maintainState: maintainState,
    fullscreenDialog: fullscreenDialog,
    transitionDuration: const Duration(milliseconds: 250),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return _IosSwipeTransition(
        routeAnimation: animation as ProxyAnimation,
        secondaryAnimation: secondaryAnimation,
        child: child,
      );
    },
  );
}

class _IosSwipeTransition extends StatefulWidget {
  const _IosSwipeTransition({
    required this.routeAnimation,
    required this.secondaryAnimation,
    required this.child,
  });

  final ProxyAnimation routeAnimation;
  final Animation<double> secondaryAnimation;
  final Widget child;

  @override
  State<_IosSwipeTransition> createState() => _IosSwipeTransitionState();
}

class _IosSwipeTransitionState extends State<_IosSwipeTransition> {
  AnimationController? _controller;

  @override
  void dispose() {
    _controller?.dispose();
    super.dispose();
  }

  bool get _canSwipe {
    final router = GoRouter.of(context);
    return router.canPop();
  }

  AnimationController? get _routeController {
    final Animation<double>? parent = widget.routeAnimation.parent;
    if (parent is AnimationController) {
      return parent;
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    final width = MediaQuery.of(context).size.width;
    final topPadding = MediaQuery.of(context).padding.top;

    return Stack(
      fit: StackFit.expand,
      children: [
        AnimatedBuilder(
          animation: Listenable.merge([
            widget.routeAnimation,
            widget.secondaryAnimation,
          ]),
          builder: (context, _) {
            final radius =
                widget.routeAnimation.value == 1.0 ? 0.0 : topPadding;

            final offsetX = (1.0 - widget.routeAnimation.value) * width;

            final compensation = 0.25 * width * widget.secondaryAnimation.value;

            final finalOffset = offsetX - compensation;

            return Transform.translate(
              offset: Offset(finalOffset.clamp(-width, width), 0),
              child: ClipRRect(
                borderRadius: BorderRadius.circular(radius),
                child: _buildSwipeGesture(context, widget.child, width),
              ),
            );
          },
        ),
      ],
    );
  }

  Widget _buildSwipeGesture(BuildContext context, Widget child, double width) {
    if (!_canSwipe) return child;

    return GestureDetector(
      behavior: HitTestBehavior.translucent,
      onHorizontalDragStart: (details) {
        final controller = _routeController;
        if (controller != null) {
          controller.stop();
        }
      },
      onHorizontalDragUpdate: (details) {
        if (!_canSwipe) return;

        final controller = _routeController;
        if (controller == null || controller.isAnimating) return;

        final delta = details.primaryDelta ?? 0.0;
        final progressDelta = delta / width;

        final newValue = (controller.value - progressDelta).clamp(0.0, 1.0);
        controller.value = newValue;
      },
      onHorizontalDragEnd: (details) {
        if (!_canSwipe) return;

        final controller = _routeController;
        if (controller == null) return;

        final velocity = details.velocity.pixelsPerSecond.dx;
        final progress = controller.value;

        final shouldPop = velocity > 500 || progress < 0.7;

        final target = shouldPop ? 0.0 : 1.0;
        final distance = (progress - target).abs();
        final duration = Duration(
          milliseconds: lerpDouble(250, 600, distance)!.round(),
        );

        if (shouldPop) {
          void listener(AnimationStatus status) {
            if (status == AnimationStatus.completed) {
              controller.removeStatusListener(listener);
              if (context.mounted && _canSwipe) {
                GoRouter.of(context).pop();
              }
            }
          }

          controller.addStatusListener(listener);
          controller.animateTo(
            0.0,
            duration: duration,
            curve: Curves.easeOutCubic,
          );
        } else {
          controller.animateTo(
            1.0,
            duration: duration,
            curve: Curves.easeOutCubic,
          );
        }
      },
      child: child,
    );
  }
}

Как я могу изменить эту реализацию, чтобы поддерживать анимацию Hero при сохранении функциональности свайпа?

Чтобы реализовать анимацию Hero в пользовательском переходе GoRouter, сохраняя при этом функциональность свайпа, необходимо интегрировать поддержку виджетов Hero в существующую реализацию перехода iOS. Ключевой момент — убедиться, что виджеты Hero могут анимироваться между страницами, не нарушая логику пользовательского свайпа.


Содержание


Понимание основ анимации Hero

Анимации Hero в Flutter позволяют виджетам «летать» из одного места в другое во время перехода между маршрутами. Согласно документации Flutter по анимациям Hero, каждый тег Hero должен быть уникальным и совпадать на исходной и целевой страницах.

Основные принципы, которые нужно понять:

  1. Совпадение тегов: виджеты Hero на исходной и целевой страницах должны иметь одинаковые теги.
  2. Автоматическая анимация: Flutter обрабатывает интерполяцию между позициями исходного и целевого виджетов.
  3. Независимость слоёв: виджеты Hero могут анимироваться независимо от остального контента страницы.

При использовании Hero с GoRouter необходимо убедиться, что ваш пользовательский переход не мешает этой автоматической системе анимации. Как отмечено в статье Medium Марко Наполи, класс PageRouteBuilder используется для создания пользовательских переходов и предоставляет объекты Animation, которые работают с виджетами Hero.


Интеграция Hero с пользовательскими переходами

Чтобы добавить поддержку анимации Hero в существующий переход iOS со свайпом, необходимо изменить transitionsBuilder так, чтобы он корректно обрабатывал виджеты Hero. Ключевая идея — анимации Hero создают «путь полёта» между исходной и целевой позициями, и ваш пользовательский переход должен уважать этот путь, а не переопределять его.

Подход:

  1. Обнаружить виджеты Hero: определить наличие виджетов Hero в переходе.
  2. Сохранить пути полёта: позволить виджетам Hero сохранять естественные пути анимации.
  3. Координировать анимации: убедиться, что свайп не конфликтует с анимацией Hero.

Как показано в документации переходов GoRouter, CustomTransitionPage идеально подходит для интеграции анимаций Hero с пользовательскими переходами.


Изменённая реализация кода

Ниже приведён пример, как изменить существующую реализацию, чтобы поддержать анимацию Hero:

dart
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

CustomTransitionPage<T> buildIosSwipeTransition<T>({
  required Widget child,
  required GoRouterState state,
  bool maintainState = true,
  bool fullscreenDialog = false,
}) {
  return CustomTransitionPage<T>(
    key: state.pageKey,
    name: state.name,
    child: child,
    maintainState: maintainState,
    fullscreenDialog: fullscreenDialog,
    transitionDuration: const Duration(milliseconds: 250),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return _IosSwipeTransitionWithHeroes(
        routeAnimation: animation as ProxyAnimation,
        secondaryAnimation: secondaryAnimation,
        child: child,
      );
    },
  );
}

class _IosSwipeTransitionWithHeroes extends StatefulWidget {
  const _IosSwipeTransitionWithHeroes({
    required this.routeAnimation,
    required this.secondaryAnimation,
    required this.child,
  });

  final ProxyAnimation routeAnimation;
  final Animation<double> secondaryAnimation;
  final Widget child;

  @override
  State<_IosSwipeTransitionWithHeroes> createState() => _IosSwipeTransitionWithHeroesState();
}

class _IosSwipeTransitionWithHeroesState extends State<_IosSwipeTransitionWithHeroes> {
  AnimationController? _controller;

  @override
  void dispose() {
    _controller?.dispose();
    super.dispose();
  }

  bool get _canSwipe {
    final router = GoRouter.of(context);
    return router.canPop();
  }

  AnimationController? get _routeController {
    final Animation<double>? parent = widget.routeAnimation.parent;
    if (parent is AnimationController) {
      return parent;
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    final width = MediaQuery.of(context).size.width;
    final topPadding = MediaQuery.of(context).padding.top;

    return Stack(
      fit: StackFit.expand,
      children: [
        AnimatedBuilder(
          animation: Listenable.merge([
            widget.routeAnimation,
            widget.secondaryAnimation,
          ]),
          builder: (context, _) {
            return _buildHeroAwareTransition(context, width, topPadding);
          },
        ),
      ],
    );
  }

  Widget _buildHeroAwareTransition(BuildContext context, double width, double topPadding) {
    // Check if there are any Hero widgets in the current route
    final heroWidgets = _findHeroWidgets(context);
    
    if (heroWidgets.isEmpty) {
      // No Hero widgets, use regular transition
      return _buildRegularTransition(width, topPadding);
    } else {
      // Hero widgets present, create Hero-aware transition
      return _buildHeroTransition(heroWidgets, width, topPadding);
    }
  }

  List<Hero> _findHeroWidgets(BuildContext context) {
    // This is a simplified approach - in practice, you might need
    // a more sophisticated method to detect Hero widgets
    final List<Hero> heroes = [];
    
    void visitor(Element element) {
      if (element.widget is Hero) {
        heroes.add(element.widget as Hero);
      }
      element.visitChildren(visitor);
    }
    
    (context as Element).visitChildren(visitor);
    return heroes;
  }

  Widget _buildRegularTransition(double width, double topPadding) {
    final radius = widget.routeAnimation.value == 1.0 ? 0.0 : topPadding;
    final offsetX = (1.0 - widget.routeAnimation.value) * width;
    final compensation = 0.25 * width * widget.secondaryAnimation.value;
    final finalOffset = offsetX - compensation;

    return Transform.translate(
      offset: Offset(finalOffset.clamp(-width, width), 0),
      child: ClipRRect(
        borderRadius: BorderRadius.circular(radius),
        child: _buildSwipeGesture(context, widget.child, width),
      ),
    );
  }

  Widget _buildHeroTransition(List<Hero> heroes, double width, double topPadding) {
    // Create a widget that wraps the content with Hero transition support
    return Hero(
      tag: 'custom_swipe_transition', // Use a consistent tag
      createRectTween: (begin, end) {
        return MaterialRectArcTween(begin: begin, end: end);
      },
      transitionOnUserGestures: true,
      child: _buildRegularTransition(width, topPadding),
    );
  }

  Widget _buildSwipeGesture(BuildContext context, Widget child, double width) {
    if (!_canSwipe) return child;

    return GestureDetector(
      behavior: HitTestBehavior.translucent,
      onHorizontalDragStart: (details) {
        final controller = _routeController;
        if (controller != null) {
          controller.stop();
        }
      },
      onHorizontalDragUpdate: (details) {
        if (!_canSwipe) return;

        final controller = _routeController;
        if (controller == null || controller.isAnimating) return;

        final delta = details.primaryDelta ?? 0.0;
        final progressDelta = delta / width;

        final newValue = (controller.value - progressDelta).clamp(0.0, 1.0);
        controller.value = newValue;
      },
      onHorizontalDragEnd: (details) {
        if (!_canSwipe) return;

        final controller = _routeController;
        if (controller == null) return;

        final velocity = details.velocity.pixelsPerSecond.dx;
        final progress = controller.value;

        final shouldPop = velocity > 500 || progress < 0.7;

        final target = shouldPop ? 0.0 : 1.0;
        final distance = (progress - target).abs();
        final duration = Duration(
          milliseconds: lerpDouble(250, 600, distance)!.round(),
        );

        if (shouldPop) {
          void listener(AnimationStatus status) {
            if (status == AnimationStatus.completed) {
              controller.removeStatusListener(listener);
              if (context.mounted && _canSwipe) {
                GoRouter.of(context).pop();
              }
            }
          }

          controller.addStatusListener(listener);
          controller.animateTo(
            0.0,
            duration: duration,
            curve: Curves.easeOutCubic,
          );
        } else {
          controller.animateTo(
            1.0,
            duration: duration,
            curve: Curves.easeOutCubic,
          );
        }
      },
      child: child,
    );
  }
}

Настройка виджетов Hero на страницах

Чтобы анимации Hero работали корректно, необходимо правильно разместить виджеты Hero как на исходной, так и на целевой страницах.

Пример исходной страницы

dart
class SourcePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: GestureDetector(
          onTap: () {
            // Переход к целевой странице
            context.go('/destination');
          },
          child: Hero(
            tag: 'hero_image',
            child: Image.network(
              'https://example.com/image.jpg',
              width: 100,
              height: 100,
            ),
          ),
        ),
      ),
    );
  }
}

Пример целевой страницы

dart
class DestinationPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Hero(
          tag: 'hero_image',
          child: Image.network(
            'https://example.com/image.jpg',
            width: 200,
            height: 200,
          ),
        ),
      ),
    );
  }
}

Конфигурация роутера

Убедитесь, что ваш GoRouter использует пользовательский переход:

dart
final GoRouter _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => SourcePage(),
      routes: [
        GoRoute(
          path: 'destination',
          pageBuilder: (context, state) => buildIosSwipeTransition(
            child: DestinationPage(),
            state: state,
          ),
        ),
      ],
    ),
  ],
);

Устранение распространённых проблем

Анимация Hero не запускается

Если виджеты Hero не анимируются, проверьте:

  1. Несоответствие тегов: теги должны совпадать точно.
  2. Вложенные маршруты: как отмечено в issue GitHub #112095, анимации Hero могут иметь проблемы с вложенными маршрутами в ShellRoute.
  3. Структура дерева виджетов: убедитесь, что виджеты Hero находятся в правильном месте в дереве.

Конфликты свайпа и Hero

Если возникают конфликты между свайпом и анимацией Hero:

  1. Координация анимаций: убедитесь, что анимация свайпа не мешает времени анимации Hero.
  2. Тестирование касаний: проверьте, что GestureDetector не блокирует события касания для виджетов Hero.

Производительность

Для оптимизации производительности:

  1. Сложность анимации: упрощайте сложные анимации Hero при использовании пользовательских переходов.
  2. Повторное использование виджетов: переиспользуйте виджеты Hero, где это возможно, вместо создания новых экземпляров.

Расширенные варианты настройки

Пользовательские пути анимации Hero

Можно создать собственный путь анимации, реализовав createRectTween:

dart
class CustomHeroPath extends Tween<Rect> {
  CustomHeroPath({required Rect? begin, required Rect? end})
      : super(begin: begin, end: end);

  @override
  Rect lerp(double t) {
    if (begin == null || end == null) return Rect.zero;
    
    // Создаём пользовательский путь анимации
    final centerX = (begin.left + begin.right) / 2;
    final progress = Curves.easeInOut.transform(t);
    
    return Rect.fromCenter(
      center: Offset(
        lerpDouble(begin.left, end.left, progress)!,
        lerpDouble(begin.top, end.top, progress)!,
      ),
      width: lerpDouble(begin.width, end.width, progress)!,
      height: lerpDouble(begin.height, end.height, progress)!,
    );
  }
}

Координация нескольких Hero

Для синхронизации нескольких анимаций Hero:

dart
Hero(
  tag: 'coordinated_hero',
  flightShuttleBuilder: (flightContext, animation, direction, fromHero, toHero) {
    return AnimatedBuilder(
      animation: animation,
      builder: (context, child) {
        // Пользовательская логика для координации нескольких анимаций Hero
        return toHero;
      },
    );
  },
  child: yourHeroWidget,
)

Заключение

Внедрение анимаций Hero в пользовательский переход GoRouter требует тщательной координации логики свайпа и встроенной системы анимации Flutter. Ключевые выводы:

  1. Сохраняйте функциональность Hero: пользовательский переход должен работать совместно с анимациями Hero, а не против них.
  2. Правильная настройка виджетов: убедитесь, что виджеты Hero корректно настроены с совпадающими тегами на обеих страницах.
  3. Координация анимаций: внимательно управляйте временем и синхронизацией пользовательских анимаций и анимаций Hero.
  4. Тестирование: тщательно проверяйте различные сценарии, чтобы обеспечить плавный пользовательский опыт.

Следуя этим рекомендациям и внедрив предложенные изменения кода, вы сможете успешно интегрировать анимацию Hero в пользовательский переход iOS со свайпом, сохраняя при этом функциональность свайпа. Результат будет выглядеть профессионально и привлекательно для пользователей.

Не забывайте тщательно тестировать на разных устройствах и сценариях, чтобы гарантировать оптимальную производительность и пользовательский опыт. По мере освоения интеграции вы сможете исследовать более продвинутые варианты настройки, создавая уникальные и впечатляющие переходы для вашего Flutter‑приложения.

Авторы
Проверено модерацией
Модерация