Я — Тим, разработчик в Гудитворкс. Когда мы делали приложение-гид по ресторанам, мне нужно было анимировать карусель карточек. На упрощенном примере я покажу, как во 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. Если у вас остались вопросы — с удовольствием отвечу.
nikita_dol
deviceWidth
- это не ширинаPageView
, а 0.68 лучше брать из viewportFraction, что бы не ходить по всему коду и не менять в случае чегоНе понимаю зачем сохранять позицию, если она уже есть. Так же
setState
не имеет смысла, так как_scrollPosition
используется вAnimatedBuilder
, который сам подписывается на измененияЕсли уж и использовать
AnimatedBuilder
, то в нём нужно перестраивать только то, что нужно (то есть, нужно передатьTransform
вchild
)Что произойдёт, если в
PageController
initialPage != 0
?Статья - это круто и я очень рад, что они появляются, но, просто, материала по Flutter становится всё больше, а качество всё меньше. Джуны, один за другим копируют код из статей и потом удивляются, почему реджектят их PR ????
mSnus
Там вообще много констант, сразу бросается в глаза вот такое:
static const imageHeight = 278.0
Чтобы поменять картинку, придется править код...
Tieriko Автор
Спасибо! Тут два момента:
размер карточки обусловлен дизайном и должен быть строго 278. Это не карусель для карточек случайного / изменяемого размера.
эту же константу мы используем в GraphQL для запроса картинки нужного размера из CMS
Наверно, можно было бы вынести её в тему проекта (где цвета, шрифты и прочее), но, во-первых, не уверен, что это логично — константа используется для одного элемента на одном конкретном экране. А во-вторых — это бы существенно увеличило количество кода в этой статье :)
nikita_dol
Учитывая, что это запросы к беку с размером картинки, то лучше делать более универсальное решение (ведь, наверняка, это не единственное место, где запрашивают картинки с размером), которое просто берёт доступный размер для виджета (
LayoutBuilder
), а высоту карусели назвать неimageHeight
Tieriko Автор
Ола! Крутой фидбек! Редко они бывают на основании столь внимательного изучения статьи.
Сразу скажу — у этой статьи нет цели научить писать идеальный код, скорее, я хотел показать способы оптимизации подобных анимаций.
Да, константы в коде могут вызывать вопросы — здесь и правда лучше делать так, как ты говоришь. Спасибо, поправлю.
Ты имеешь ввиду, что можно сразу подписаться на изменения, которые отдает
PageController
?Да, спасибо, поправлю статью. Это место переусложнено многократными изменениями в продакшн-проекте :) Не стал рефакторить для статьи, переделаю.
Скорее всего, не произойдет ничего, но перепроверю.
Спасибо ещё раз! Я написал в том числе затем, чтобы получить полезный фидбек.
nikita_dol
Да. Можно сразу из него извлекать текущее положение, что и делается для
_scrollPosition