Создаем гироскопический параллакс-эффект во 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *