Создаем гироскопический параллакс-эффект во Flutter

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Недавно я рассказал, как создать параллакс эффект при скролле с помощью виджета CustomPaint

Сегодня расскажу, как, сделав небольшой апгрейд, можно оживить картинку при простом наклоне телефона.

Результат
Результат

Вспомним, что уже реализовано:

  1. Экран со списком элементов, где каждый элемент - это картинка с подписью

  2. Бэкграунд, который также смещается при скролле, но чуть медленнее, чем элементы в списке

Код
import 'dart:math' show Random;
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

class ParallaxScreen extends StatefulWidget {
  const ParallaxScreen({super.key});

  @override
  State<ParallaxScreen> createState() => _ParallaxScreenState();
}

class _ParallaxScreenState extends State<ParallaxScreen> {
  // get an array of random ids
  late final List<int> ids = List.generate(10, (index) => Random().nextInt(500));

  // controller to handle scrolling of items
  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);
          },
        ),
      ),
    );
  }
}

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', // get an image from network by id
            width: double.maxFinite,
          ),
          Positioned(
            left: 20,
            bottom: 20,
            child: Text(
              'Image $id',
              style: const TextStyle(
                color: Colors.white,
                fontSize: 24,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

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;
  // logic for downloaiding an image as ui.Image object
  Future<void> _loadImage() async {
    const imageProvider = NetworkImage('https://picsum.photos/id/307/600/4000'); // get background image. You can use anyone you want
    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,
    );
  }
}

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

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

Акселерометр - если супер просто, то это датчик, который измеряет ускорение (акселерацию) при движении устройства в реальном времени в трех проекциях. Единица измерения или м/c^2, или значение от 0 до 1 (нормированное относительно ускорения свободного падения 9.8 м/с^2). Если, например, телефон лежит на столе, то по оси Z абсолютное значение ускорения будет либо 9.8, либо 1

Гироскоп - датчик, который показывает, с какой скоростью повернули устройство относительно такой-то оси. Единица измерения или °/с, или рад/с. Если тело в покое, то датчик показывает нулевые значения

Существующие библиотеки

Для получения данных с этих датчиков есть несколько готовых решений на pub.dev

flutter_sensors

Это пример библиотеки, которая без обновлений существует уже более 2-х лет, поддерживается в текущей версии flutter и dart и вполне достойно выполняет свои задачи. В ранних проектах я пользовался именно этой библиотекой

sensors_plus

Это пример другой библиотеки, у которой проще API, по сравнению с flutter_sensors, а также не требуются дополнительные настройки разрешений в AndroidManifest.xml для Android или info.plist для iOS.
Еще одно отличие, что при расчете ускорения используются разные единицы. Это стоит учитывать при миграции с одной библиотки на другую.

В данном примере воспользуемся sensors_plus

Быстрый апгрейд

  1. Добавим библиотеку sensors_plus в pubspec.yaml

name: parallax_with_sensors_example
description: "A new Flutter project."
publish_to: "none"
version: 0.1.0

environment:
  sdk: ">=3.2.6 <4.0.0"

dependencies:
  flutter:
    sdk: flutter
  sensors_plus: any # Add this line

dev_dependencies:
  flutter_lints: ^2.0.0
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true
  1. Добавим импорт в файл, где реализован виджет экрана

import 'dart:async';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:sensors_plus/sensors_plus.dart'; // add this line
  1. Поправим реализацию ParallaxScreen

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<AccelerometerEvent>(
        stream: accelerometerEventStream(samplingPeriod: SensorInterval.uiInterval),
        builder: (context, snapshot) {
          final x = snapshot.data?.x ?? 0;
          final y = snapshot.data?.y ?? 0;
          return AnimatedContainer(
            duration: const Duration(milliseconds: 100),
            transform: Matrix4.identity()..translate(x * 10, y * 10),
            child: CustomPaint(
              painter: _image != null ? _BackgroundImagePainter(widget.scrollController, _image!) : null,
              child: widget.child,
            ),
          );
        });
  }
Акселерометр. Положение сохраняется
Акселерометр. Положение сохраняется
Гироскоп. Положение сбрасывается
Гироскоп. Положение сбрасывается

Что имеем при такой реализации:

  1. Используем StreamBuilder, потому что данные из датчиков поступают в виде стрима;
    Для тех, кто не сильно разбирается в стримах в dart, но знаком, например, с YouTube (ТыТруба), то может провести аналогию со стримами, когда в другом конце земного шара какой-то блогер что-то показывает, а все остальные пользователи могут это увидеть в реальном времени. Там блогер закидывает, грубо говоря, картинку в трубу (Sink), а пользователи получают эту картинку из трубы (Stream).
    С датчиками примерно то же самое: в реальном времени собираются данные с определенной частотой и передаются в Sink. Кто подключится к Stream этой трубы, тот будет получать эти данные

  2. Данные поступают в виде единиц/десяток, поэтому для повышения чувствительности умножаем на 10 (на ваше усмотрение)

  3. Полем samplingPeriod задается частота, с которой следует собирать данные с датчиков

  4. В примере кода данные берутся с акселерометра. При таком выборе сохраняется положение картинки после завершения наклона. Если, например, использовать гироскоп, то после завершения вращения картинка вернется в исходное положение.
    Тут тоже каждый выбирает под свои цели

  5. AnimatedContainer - используется просто для плавности анимации

Вариант, как можно оставить список статичным и перемещать только картинку на бэкграунде:

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        StreamBuilder<AccelerometerEvent>(
            stream: accelerometerEventStream(samplingPeriod: SensorInterval.uiInterval),
            builder: (context, snapshot) {
              final x = snapshot.data?.x ?? 0;
              final y = snapshot.data?.y ?? 0;
              return AnimatedContainer(
                duration: const Duration(milliseconds: 100),
                transform: Matrix4.identity()..translate(x * 10, y * 10),
                child: CustomPaint(
                  size: Size.infinite,
                  painter: _image != null ? _BackgroundImagePainter(widget.scrollController, _image!) : null,
                ),
              );
            }),
        widget.child,
      ],
    );
  }

Заключение

Сегодня рассмотрели простой вариант, как можно дополнительно добавить в приложение отзывчивость при взаимодействии с реальным миром на примере реализации гироскопического параллакс эффекта

Если понравился материал, поставьте ⬆️, чтобы я понимал, что тема интересна и писал больше подобных статей

Источник: https://habr.com/ru/articles/794512/


Интересные статьи

Интересные статьи

Согласно нашей статистике, 93% наших Клиентов, которые решили создать кроссплатформенное приложение, просят использовать фреймворк Flutter. И только единицы из этого числа на самом деле знают, что так...
Всех приветствую! Меня зовут Кирилл, я Go-разработчик в компании Ozon. Сейчас моим полем деятельности является разработка микросервисов в департаменте Fresh, однако я также успел принять участие в нек...
Одной из проблем развития бортовых систем для автомобиля является создание качественного переносимого программного обеспечения, которое бы работало на бортовых системах разных автопроизводителей и мог...
Меня зовут Стас Гаранжа, я выпускник курса «Python-разработчик» в Яндекс.Практикуме. Я хочу помочь начинающим разработчикам, которые приступили к изучению Django Rest Framework (DRF) и хо...
Современные игры становятся все реалистичнее, и один из способов этого добиться — создать разрушаемое окружение. К тому же, крушить мебель, растения, стены, здания и целые города ...