Анимация интерактивной карусели во Flutter

—

от автора

Я — Тим, разработчик в Гудитворкс. Когда мы делали приложение-гид по ресторанам, мне нужно было анимировать карусель карточек. На упрощенном примере я покажу, как во Flutter сделать такую интерактивную карусель. Результат — ниже:

При скролле в зависимости от положения карточки меняется прозрачность текста и положение вилки-стрелочки.
При скролле в зависимости от положения карточки меняется прозрачность текста и положение вилки-стрелочки.

В конце рассказа будет ссылка на репозиторий с полным кодом примера.

Отображение элементов карусели зависит от скролла: при его изменении срабатывает build-функция всего виджета карточки, а это приводит к затратному ререндеру. Чтобы избежать лишних перерисовок, можно использовать виджет AnimatedBuilder — он позволяет делать расчёты не в корне виджета, а максимально близко к тому виджету в дереве, который зависит от этих расчётов.

Для начала для скроллящегося списка страниц PageView создается контроллер PageController. Параметр viewportFraction будет нужен для масштабирования размеров карточек.

// main.dart  class _HomeState extends State<Home> {   static const imageHeight = 278.0;   final _controller = PageController(viewportFraction: 0.68);   ... }

Текущая позиция скролла будет храниться в инстансе класса ValueNotifier (он наследует классу ChangeNotifier, но следит за изменением только одного значения).

// card/cover_card.dart   class _CoverCardState extends State<CoverCard> {   ...     final ValueNotifier<double> _scrollPosition = ValueNotifier<double>(0.0);     void _onScrollPositionChanged() {     setState(() => _scrollPosition.value = widget.pageController.page ?? 0.0);   }     ... }

Если его значение изменится, он оповестит своих листенеров.

Подцепим к контроллеру листенер, который передает данные об изменении позиции скролла в _scrollPosition (не забудьте отцепить, чтобы не было утечек памяти).

// card/cover_card.dart  @override void initState() {   super.initState();   widget.pageController.addListener(_onScrollPositionChanged); }  @override void dispose() {   super.dispose();   widget.pageController.removeListener(_onScrollPositionChanged); }

Чтобы при изменении позиции скролла срабатывала build-функция AnimatedBuilder, мы передаем _scrollPosition в параметр animation.

Один AnimatedBuilder рисует стрелочку:

// card/cover_card.dart  @override Widget build(BuildContext context) {   ...    return Stack(     ...     children: [       Positioned(         ...         ),       ),       AnimatedBuilder(         animation: _scrollPosition,         builder: (BuildContext context, Widget? child) {           return Positioned(             top: arrowPadding - 33,             left: widthCard + _getArrowOffset(),             child: Transform.rotate(               angle: pi / 2.0,               child: const ImageIcon(                 AssetImage(                   'assets/icons/arrow_insider.png',                 ),                 color: Color(0xfff9b9ad),                 size: 62,               ),             ),           );         },       ),       ...     ],   ); }

Положение стрелки зависит от позиции скролла и индекса карточки, расчеты происходят в функции _getArrowOffset. Если карточка не в фокусе и не сбоку от фокуса, то со стрелкой ничего не происходит.

// card/cover_card.dart  class _CoverCardState extends State<CoverCard> {   static const double _defaultArrowOffset = .0;   static const double _focusArrowOffset = 40;   static const cardPadding = 111.0;     final ValueNotifier<double> _scrollPosition = ValueNotifier<double>(0.0);     void _onScrollPositionChanged() {     setState(() => _scrollPosition.value = widget.pageController.page ?? 0.0);   }     double _getArrowOffset() {     final scrollPosition = _scrollPosition.value;     final currentPosition = scrollPosition.floor();       final delta = scrollPosition - currentPosition;       final forwardAnimationOffest =         Curves.ease.transform(1 - delta) * _focusArrowOffset;     final backwardAnimationOffest =         Curves.ease.transform(delta) * _focusArrowOffset;       var animatedArrowOffset = _defaultArrowOffset;       if (widget.index == currentPosition) {       /// Closest to focus       animatedArrowOffset = forwardAnimationOffest;     } else if (widget.index == currentPosition + 1) {       /// Left or right sided from central card       animatedArrowOffset = backwardAnimationOffest;     }       return animatedArrowOffset;   } }

Другой AnimatedBuilder рисует текст. Ему так же передаем изменение позиции скролла в параметр animation.

// card/cover_card.dart  @override Widget build(BuildContext context) {   final deviceWidth = MediaQuery.of(context).size.width;   final width = deviceWidth * 0.68;   final widthCard = deviceWidth * 0.48;   final heightCard = widthCard * 1.4;    final arrowPadding =       widget.imageHeight / 2 + (heightCard + cardPadding) / 2;    return Stack(     ...     children: [       Positioned(         ...       ),       ...       AnimatedBuilder(         animation: _scrollPosition,         builder: (BuildContext context, Widget? child) {           return Positioned(             width: width,             top: heightCard + 85,             child: Opacity(               opacity: getTextOpacity(),               child: Column(                 children: [                   Text(                     "Jane Doe",                     textAlign: TextAlign.center,                     style: _titleTextStyle,                     maxLines: 2,                     overflow: TextOverflow.ellipsis,                   ),                   const SizedBox(height: 12),                   Text(                     "Lorem ipsum dolor sit amet",                     textAlign: TextAlign.center,                     style: _subtitleTextStyle,                     maxLines: 4,                     overflow: TextOverflow.ellipsis,                   ),                 ],               ),             ),           );         },       ),     ],   ); }

Текст оборачиваем в виджет Opacity: он меняет прозрачность обёрнутых в него виджетов в зависимости от позиции скролла и индекса карточки. Это затронет карточку в фокусе и те, что от нее по бокам:

// card/cover_card.dart  double getTextOpacity() {   final scrollPosition = _scrollPosition.value;   final currentPosition = scrollPosition.floor();   final delta = scrollPosition - currentPosition;    if (widget.index == currentPosition) return 1 - delta;    if (currentPosition + 1 == widget.index) return delta;    return 0; }

В результате, в зависимости от позиции скролла, меняется и положение стрелочки, и прозрачность текста.

Код примера — в репозитории на GitHub. Если у вас остались вопросы — с удовольствием отвечу.


ссылка на оригинал статьи https://habr.com/ru/post/681430/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *