Pull to refresh

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

Reading time4 min
Views3.1K

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

Tags:
Hubs:
Total votes 1: ↑1 and ↓0+1
Comments6

Articles