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

Комментарии (6)


  1. nikita_dol
    08.08.2022 20:54
    +2

    final deviceWidth = MediaQuery.of(context).size.width;
    final width = deviceWidth * 0.68;

    deviceWidth - это не ширина PageView, а 0.68 лучше брать из viewportFraction, что бы не ходить по всему коду и не менять в случае чего

    setState(() => _scrollPosition.value = widget.pageController.page ?? 0.0);

    Не понимаю зачем сохранять позицию, если она уже есть. Так же setState не имеет смысла, так как _scrollPosition используется в AnimatedBuilder , который сам подписывается на изменения

    AnimatedBuilder(
      animation: _scrollPosition,
      builder: (BuildContext context, Widget? child) {
        return Positioned(
          top: arrowPadding - 33,
          left: widthCard + _getArrowOffset(),
          child: Transform.rotate(
            angle: pi / 2.0,

    Если уж и использовать AnimatedBuilder, то в нём нужно перестраивать только то, что нужно (то есть, нужно передать Transform в child )

    Что произойдёт, если в PageController initialPage != 0 ?

    Статья - это круто и я очень рад, что они появляются, но, просто, материала по Flutter становится всё больше, а качество всё меньше. Джуны, один за другим копируют код из статей и потом удивляются, почему реджектят их PR ????


    1. mSnus
      08.08.2022 22:18
      +1

      Там вообще много констант, сразу бросается в глаза вот такое:
      static const imageHeight = 278.0

      Чтобы поменять картинку, придется править код...


      1. Tieriko Автор
        09.08.2022 13:18

        Спасибо! Тут два момента:

        • размер карточки обусловлен дизайном и должен быть строго 278. Это не карусель для карточек случайного / изменяемого размера.

        • эту же константу мы используем в GraphQL для запроса картинки нужного размера из CMS

        Наверно, можно было бы вынести её в тему проекта (где цвета, шрифты и прочее), но, во-первых, не уверен, что это логично — константа используется для одного элемента на одном конкретном экране. А во-вторых — это бы существенно увеличило количество кода в этой статье :)


        1. nikita_dol
          09.08.2022 14:43

          Учитывая, что это запросы к беку с размером картинки, то лучше делать более универсальное решение (ведь, наверняка, это не единственное место, где запрашивают картинки с размером), которое просто берёт доступный размер для виджета (LayoutBuilder), а высоту карусели назвать не imageHeight


    1. Tieriko Автор
      09.08.2022 13:04

      Ола! Крутой фидбек! Редко они бывают на основании столь внимательного изучения статьи.

      Сразу скажу — у этой статьи нет цели научить писать идеальный код, скорее, я хотел показать способы оптимизации подобных анимаций.

      deviceWidth - это не ширина PageView, а 0.68 лучше брать из viewportFraction, что бы не ходить по всему коду и не менять в случае чего

      Да, константы в коде могут вызывать вопросы — здесь и правда лучше делать так, как ты говоришь. Спасибо, поправлю.

      Не понимаю зачем сохранять позицию, если она уже есть.

      Ты имеешь ввиду, что можно сразу подписаться на изменения, которые отдает PageController?

      Так же setState не имеет смысла, так как _scrollPosition используется в AnimatedBuilder , который сам подписывается на изменения

      Да, спасибо, поправлю статью. Это место переусложнено многократными изменениями в продакшн-проекте :) Не стал рефакторить для статьи, переделаю.

      Что произойдёт, если в PageController initialPage != 0 ?

      Скорее всего, не произойдет ничего, но перепроверю.

      Статья - это круто и я очень рад, что они появляются, но, просто, материала по Flutter становится всё больше, а качество всё меньше. Джуны, один за другим копируют код из статей и потом удивляются, почему реджектят их PR ????

      Спасибо ещё раз! Я написал в том числе затем, чтобы получить полезный фидбек.


      1. nikita_dol
        09.08.2022 13:09

        Ты имеешь ввиду, что можно сразу подписаться на изменения, которые отдает PageController?

        Да. Можно сразу из него извлекать текущее положение, что и делается для _scrollPosition