Animating an Interactive Carousel in Flutter

I’m Tim, a developer at Goodworks. When we were making a restaurant guide app, I needed to animate a carousel of cards. Using a simplified example, I will show you how to make such an interactive carousel in Flutter. The result is below:

When scrolling, depending on the position of the card, the transparency of the text and the position of the fork-arrow change.
When scrolling, depending on the position of the card, the transparency of the text and the position of the fork-arrow change.

At the end of the story there will be a link to the repository with the full code of the example.

The display of carousel elements depends on the scroll: when it changes, the build function of the entire card widget, and this leads to an expensive rerender. To avoid unnecessary redrawing, you can use the widget AnimatedBuilder – it allows you to make calculations not at the root of the widget, but as close as possible to the widget in the tree that depends on these calculations.

To start with a scrollable list of pages PageView controller is created PageController. Parameter viewportFraction will be needed to scale the size of the cards.

// main.dart

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

The current scroll position will be stored in the class instance ValueNotifier (it inherits the class ChangeNotifierbut only monitors the change of one value).

// 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);
  }
 
  ...
}

If its value changes, it will notify its listeners.

Let’s hook up a listener to the controller, which sends data about the change in the scroll position to _scrollPosition (remember to unhook so there are no memory leaks).

// card/cover_card.dart

@override
void initState() {
  super.initState();
  widget.pageController.addListener(_onScrollPositionChanged);
}

@override
void dispose() {
  super.dispose();
  widget.pageController.removeListener(_onScrollPositionChanged);
}

So that when the scroll position changes, the build-function works AnimatedBuilderwe send _scrollPosition in parameter animation.

One AnimatedBuilder draws an arrow:

// 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,
              ),
            ),
          );
        },
      ),
      ...
    ],
  );
}

The position of the arrow depends on the position of the scroll and the index of the card, calculations are made in the function _getArrowOffset. If the card is out of focus and not to the side of focus, then nothing happens to the arrow.

// 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;
  }
}

Another AnimatedBuilder draws text. We also pass the change in the scroll position to the parameter 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,
                  ),
                ],
              ),
            ),
          );
        },
      ),
    ],
  );
}

Wrapping text in a widget Opacity: it changes the transparency of the widgets wrapped in it depending on the scroll position and card index. This will affect the card in focus and those on the sides of it:

// 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;
}

As a result, depending on the position of the scroll, both the position of the arrow and the transparency of the text change.

Example code – in repositories on GitHub. If you have any questions, I’ll be happy to answer.

Similar Posts

Leave a Reply