Создаем параллакс-эффект во Flutter с CustomPaint
В современном мире мобильной разработки, где внимание к деталям и уникальность интерфейса играют ключевую роль, параллакс-эффект открывает новые горизонты для вовлечения пользователей. Существует много разных способов его реализации, например, через Flow
или PageView
. В этой статье мы раскроем, как с помощью CustomPaint оживить приложение на Flutter, добавив эффект параллакса.
Параллакс-эффект, как художественный прием в дизайне, создает иллюзию глубины и движения. Это достигается путем различной скорости движения слоев изображения, придавая динамизм и глубину визуальным элементам. Например, фон может двигаться медленнее передних элементов, создавая впечатление многомерности.
Задача
Допустим, поступила задача сверстать экранчик, и креативный дизайнер придумал для этого длинную вертикальную картинку на бэкграунде и какое-то определенное количество элементов на переднем плане. Картинка должна смещаться медленнее относительно основного контента, создавая эффект параллакса.
Для простоты реализации пусть это будут одинаковые элементы представляющие собой полупрозрачные карточки с рандомными изображениями.
Шаги
Виджет карточки с изображением. Для получения приятных на глаз изображений воспользуемся сервисом https://picsum.photos/:
import 'package:flutter/material.dart';
class ItemCard extends StatelessWidget {
const ItemCard({
super.key,
required this.id,
});
final int id;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 20,
),
clipBehavior: Clip.hardEdge,
width: double.maxFinite,
height: 300,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.4),
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 10,
spreadRadius: 5,
),
],
),
child: Stack(
children: [
Image.network(
'https://picsum.photos/id/$id/500/300',
width: double.maxFinite,
),
Positioned(
left: 20,
bottom: 20,
child: Text(
'Image $id',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
}
import 'dart:math' show Random;
import 'package:flutter/material.dart';
class ParallaxScreen extends StatefulWidget {
const ParallaxScreen({super.key});
@override
State createState() => _ParallaxScreenState();
}
class _ParallaxScreenState extends State {
late final List ids = List.generate(10, (index) => Random().nextInt(500));
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
itemCount: ids.length,
itemBuilder: (context, index) {
final int id = ids[index];
return ItemCard(id: id);
},
),
);
}
}
Бэкграунд реализуем через
CustomPainter
, так как с его помощью можно относительно легко проводить различные манипуляции с картинкойui.Image
. Для отслеживания скроллинга в списке будем использоватьScrollController
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class _BackgroundImagePainter extends CustomPainter {
final ScrollController controller;
final ui.Image image;
const _BackgroundImagePainter(this.controller, this.image) : super(repaint: controller);
@override
void paint(Canvas canvas, Size size) {
final imageWidth = image.width.toDouble();
final imageHeight = image.height.toDouble();
final aspectRatio = imageWidth / imageHeight;
final src = Rect.fromLTWH(
0,
0,
imageWidth,
imageHeight,
);
final deltaY = -controller.offset * 0.6;
final dst = Rect.fromLTWH(
0,
deltaY,
size.width,
size.width / aspectRatio,
);
canvas.drawImageRect(
image,
src,
dst,
Paint()..filterQuality = FilterQuality.high,
);
}
@override
bool shouldRepaint(_BackgroundImagePainter oldDelegate) => controller.offset != oldDelegate.controller.offset;
}
Краткое пояснение:
Передаем
scrollController
в полеrepaint
родительскогоsuper
конструктора, чтобыcustomPainter
реагировал на скроллaspectRatio
— соотношения сторон картинки. Оно нужно для того, чтобы картинка не получалась растянутой или сдавленной.Используем метод canvas.drawImageRect, работу которого описал в статье «Как реализовать обрезку изображений во flutter без сторонних библиотек»
Напомню, что
src
— часть изображения, которую мы хотим отобразить, и здесь следует указывать реальные размеры картинкиdst
— прямоугольник, в котором мы хотим нарисовать вырезанную изsrc
часть
При данной реализации картинка отрисовывается полностью и выходит за пределы видимой области экрана по высоте.
*Здесь при желании можно оптимизировать , чтобы не держать в памяти часть, которая не отображается на экранеКогда происходит скролл, картинка смещается по оси Y на значение
deltaY
, равное-controller.offset * 0.6
, где 0.6, это коэффициент, за счет которого достигается эффект параллакса
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class _Background extends StatefulWidget {
const _Background({
required this.child,
required this.scrollController,
});
final Widget child;
final ScrollController scrollController;
@override
State<_Background> createState() => _BackgroundState();
}
class _BackgroundState extends State<_Background> {
@override
void initState() {
super.initState();
_loadImage();
}
ui.Image? _image;
Future _loadImage() async {
const imageProvider = NetworkImage('https://picsum.photos/id/307/600/4000');
final ImageStreamListener listener = ImageStreamListener((info, _) {
setState(() {
_image = info.image;
});
});
final ImageStream stream = imageProvider.resolve(const ImageConfiguration());
stream.addListener(listener);
}
@override
void dispose() {
_image?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _image != null ? _BackgroundImagePainter(widget.scrollController, _image!) : null,
child: widget.child,
);
}
}
Краткие пояснения:
Для загрузки изображения в методе
_loadImage
используетсяImageProvider
, реализованный черезNetworkImage
. Если, например, нужно получить картинку из другого источника, то есть дефолтныеAssetImage
иFileImage
CustomPaint
принимает размерыchild
, поэтомуsize
указывать не нужно
Конечный код реализованного экрана:
class ParallaxScreen extends StatefulWidget {
const ParallaxScreen({super.key});
@override
State createState() => _ParallaxScreenState();
}
class _ParallaxScreenState extends State {
late final List ids = List.generate(10, (index) => Random().nextInt(500));
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _Background(
scrollController: _scrollController,
child: ListView.builder(
controller: _scrollController,
itemCount: ids.length,
itemBuilder: (context, index) {
final int id = ids[index];
return ItemCard(id: id);
},
),
),
);
}
}
Результат
Заключение
Сегодня мы изучили, как с небольшими усилиями можно добавить эффект параллакса в ваше Flutter-приложение.
Напишите в комментариях, приходилось ли вам добавлять такой эффект в своем проекте и какими инструментами вы пользовались.