Создаем параллакс-эффект во Flutter с CustomPaint

В современном мире мобильной разработки, где внимание к деталям и уникальность интерфейса играют ключевую роль, параллакс-эффект открывает новые горизонты для вовлечения пользователей. Существует много разных способов его реализации, например, через Flow или PageView. В этой статье мы раскроем, как с помощью CustomPaint оживить приложение на Flutter, добавив эффект параллакса.

285e5a8652037856f5ed0810165e29b6.gif

Параллакс-эффект, как художественный прием в дизайне, создает иллюзию глубины и движения. Это достигается путем различной скорости движения слоев изображения, придавая динамизм и глубину визуальным элементам. Например, фон может двигаться медленнее передних элементов, создавая впечатление многомерности.

Задача

Допустим, поступила задача сверстать экранчик, и креативный дизайнер придумал для этого длинную вертикальную картинку на бэкграунде и какое-то определенное количество элементов на переднем плане. Картинка должна смещаться медленнее относительно основного контента, создавая эффект параллакса.

Для простоты реализации пусть это будут одинаковые элементы представляющие собой полупрозрачные карточки с рандомными изображениями.

Шаги

  • Виджет карточки с изображением. Для получения приятных на глаз изображений воспользуемся сервисом 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);
        },
      ),
    );
  }
}

80991fb232e9947d2ca3e15a50f6a9ab.gif

  • Бэкграунд реализуем через 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;
}

Краткое пояснение:

  1. Передаем scrollController в поле repaint родительского super конструктора, чтобы customPainter реагировал на скролл

  2. aspectRatio — соотношения сторон картинки. Оно нужно для того, чтобы картинка не получалась растянутой или сдавленной.

  3. Используем метод canvas.drawImageRect, работу которого описал в статье «Как реализовать обрезку изображений во flutter без сторонних библиотек»

    • Напомню, что src — часть изображения, которую мы хотим отобразить, и здесь следует указывать реальные размеры картинки

    • dst — прямоугольник, в котором мы хотим нарисовать вырезанную из src часть

  4. При данной реализации картинка отрисовывается полностью и выходит за пределы видимой области экрана по высоте.
    *Здесь при желании можно оптимизировать , чтобы не держать в памяти часть, которая не отображается на экране

  5. Когда происходит скролл, картинка смещается по оси 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-приложение.

Напишите в комментариях, приходилось ли вам добавлять такой эффект в своем проекте и какими инструментами вы пользовались.

© Habrahabr.ru