Создаем гироскопический параллакс-эффект во Flutter
Недавно я рассказал, как создать параллакс эффект при скролле с помощью виджета CustomPaint
Создаем параллакс-эффект во Flutter с CustomPaint
В современном мире мобильной разработки, где внимание к деталям и уникальность интерфейса играют клю…
habr.comСегодня расскажу, как, сделав небольшой апгрейд, можно оживить картинку при простом наклоне телефона.
Результат
Вспомним, что уже реализовано:
Экран со списком элементов, где каждый элемент — это картинка с подписью
Бэкграунд, который также смещается при скролле, но чуть медленнее, чем элементы в списке
Код
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 createState() => _ParallaxScreenState();
}
class _ParallaxScreenState extends State {
// get an array of random ids
late final List 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 _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
flutter_sensors | Flutter package
pub.devЭто пример библиотеки, которая без обновлений существует уже более 2-х лет, поддерживается в текущей версии flutter и dart и вполне достойно выполняет свои задачи. В ранних проектах я пользовался именно этой библиотекой
sensors_plus
sensors_plus | Flutter package
pub.devЭто пример другой библиотеки, у которой проще 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(
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(
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,
],
);
}
Заключение
Сегодня рассмотрели простой вариант, как можно дополнительно добавить в приложение отзывчивость при взаимодействии с реальным миром на примере реализации гироскопического параллакс эффекта
Если понравился материал, поставьте ⬆️, чтобы я понимал, что тема интересна и писал больше подобных статей