Я — Тим, разработчик в Гудитворкс. Когда мы делали приложение-гид по ресторанам, мне нужно было анимировать карусель карточек. На упрощенном примере я покажу, как во 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. Если у вас остались вопросы — с удовольствием отвечу.
