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

Вспомним, что уже реализовано:
Экран со списком элементов, где каждый элемент - это картинка с подписью
Бэкграунд, который также смещается при скролле, но чуть медленнее, чем элементы в списке
Код
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
Быстрый апгрейд
Добавим библиотеку 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
Добавим импорт в файл, где реализован виджет экрана
import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:sensors_plus/sensors_plus.dart'; // add this line
Поправим реализацию 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, ), ); }); }


Что имеем при такой реализации:
Используем
StreamBuilder, потому что данные из датчиков поступают в виде стрима;
Для тех, кто не сильно разбирается в стримах в dart, но знаком, например, с YouTube (ТыТруба), то может провести аналогию со стримами, когда в другом конце земного шара какой-то блогер что-то показывает, а все остальные пользователи могут это увидеть в реальном времени. Там блогер закидывает, грубо говоря, картинку в трубу (Sink), а пользователи получают эту картинку из трубы (Stream).
С датчиками примерно то же самое: в реальном времени собираются данные с определенной частотой и передаются вSink. Кто подключится кStreamэтой трубы, тот будет получать эти данныеДанные поступают в виде единиц/десяток, поэтому для повышения чувствительности умножаем на 10 (на ваше усмотрение)
Полем
samplingPeriodзадается частота, с которой следует собирать данные с датчиковВ примере кода данные берутся с акселерометра. При таком выборе сохраняется положение картинки после завершения наклона. Если, например, использовать гироскоп, то после завершения вращения картинка вернется в исходное положение.
Тут тоже каждый выбирает под свои цели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, ], ); }
Заключение
Сегодня рассмотрели простой вариант, как можно дополнительно добавить в приложение отзывчивость при взаимодействии с реальным миром на примере реализации гироскопического параллакс эффекта
Если понравился материал, поставьте ⬆️, чтобы я понимал, что тема интересна и писал больше подобных статей
