Руководство по реализации анимаций Hero в Flutter с GoRouter
Узнайте, как реализовать анимации Hero в переходах GoRouter, сохраняя свайп. Полные примеры кода и руководство по устранению неполадок для Flutter-разработчиков.
Как реализовать анимацию Hero в пользовательском переходе GoRouter в Flutter?
Я пытался реализовать анимацию, похожую на переход Cupertino в Flutter, используя GoRouter. Я создал эффект параллакса с помощью пользовательского перехода, но анимации Hero не работают корректно. Ниже приведена моя текущая реализация, но я не понимаю, как добавить поддержку анимации Hero.
Вот мой текущий код пользовательского перехода:
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 с пользовательскими переходами
- Изменённая реализация кода
- Настройка виджетов Hero на страницах
- Устранение распространённых проблем
- Расширенные варианты настройки
Понимание основ анимации Hero
Анимации Hero в Flutter позволяют виджетам «летать» из одного места в другое во время перехода между маршрутами. Согласно документации Flutter по анимациям Hero, каждый тег Hero должен быть уникальным и совпадать на исходной и целевой страницах.
Основные принципы, которые нужно понять:
- Совпадение тегов: виджеты Hero на исходной и целевой страницах должны иметь одинаковые теги.
- Автоматическая анимация: Flutter обрабатывает интерполяцию между позициями исходного и целевого виджетов.
- Независимость слоёв: виджеты Hero могут анимироваться независимо от остального контента страницы.
При использовании Hero с GoRouter необходимо убедиться, что ваш пользовательский переход не мешает этой автоматической системе анимации. Как отмечено в статье Medium Марко Наполи, класс PageRouteBuilder используется для создания пользовательских переходов и предоставляет объекты Animation, которые работают с виджетами Hero.
Интеграция Hero с пользовательскими переходами
Чтобы добавить поддержку анимации Hero в существующий переход iOS со свайпом, необходимо изменить transitionsBuilder так, чтобы он корректно обрабатывал виджеты Hero. Ключевая идея — анимации Hero создают «путь полёта» между исходной и целевой позициями, и ваш пользовательский переход должен уважать этот путь, а не переопределять его.
Подход:
- Обнаружить виджеты Hero: определить наличие виджетов Hero в переходе.
- Сохранить пути полёта: позволить виджетам Hero сохранять естественные пути анимации.
- Координировать анимации: убедиться, что свайп не конфликтует с анимацией Hero.
Как показано в документации переходов GoRouter, CustomTransitionPage идеально подходит для интеграции анимаций Hero с пользовательскими переходами.
Изменённая реализация кода
Ниже приведён пример, как изменить существующую реализацию, чтобы поддержать анимацию Hero:
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 как на исходной, так и на целевой страницах.
Пример исходной страницы
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,
),
),
),
),
);
}
}
Пример целевой страницы
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 использует пользовательский переход:
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 не анимируются, проверьте:
- Несоответствие тегов: теги должны совпадать точно.
- Вложенные маршруты: как отмечено в issue GitHub #112095, анимации Hero могут иметь проблемы с вложенными маршрутами в
ShellRoute. - Структура дерева виджетов: убедитесь, что виджеты Hero находятся в правильном месте в дереве.
Конфликты свайпа и Hero
Если возникают конфликты между свайпом и анимацией Hero:
- Координация анимаций: убедитесь, что анимация свайпа не мешает времени анимации Hero.
- Тестирование касаний: проверьте, что
GestureDetectorне блокирует события касания для виджетов Hero.
Производительность
Для оптимизации производительности:
- Сложность анимации: упрощайте сложные анимации Hero при использовании пользовательских переходов.
- Повторное использование виджетов: переиспользуйте виджеты Hero, где это возможно, вместо создания новых экземпляров.
Расширенные варианты настройки
Пользовательские пути анимации Hero
Можно создать собственный путь анимации, реализовав createRectTween:
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:
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. Ключевые выводы:
- Сохраняйте функциональность Hero: пользовательский переход должен работать совместно с анимациями Hero, а не против них.
- Правильная настройка виджетов: убедитесь, что виджеты Hero корректно настроены с совпадающими тегами на обеих страницах.
- Координация анимаций: внимательно управляйте временем и синхронизацией пользовательских анимаций и анимаций Hero.
- Тестирование: тщательно проверяйте различные сценарии, чтобы обеспечить плавный пользовательский опыт.
Следуя этим рекомендациям и внедрив предложенные изменения кода, вы сможете успешно интегрировать анимацию Hero в пользовательский переход iOS со свайпом, сохраняя при этом функциональность свайпа. Результат будет выглядеть профессионально и привлекательно для пользователей.
Не забывайте тщательно тестировать на разных устройствах и сценариях, чтобы гарантировать оптимальную производительность и пользовательский опыт. По мере освоения интеграции вы сможете исследовать более продвинутые варианты настройки, создавая уникальные и впечатляющие переходы для вашего Flutter‑приложения.