Сегодня рассмотрим, как с помощью небольших знаний в математике и встроенных инструментов flutter реализовать функционал редактирования изображений. Сначала рассмотрим существующие библиотеки, которые предоставляют нужный функционал, а потом реализуем собственное решение, используя GestureDetector, CustomPainter, RepaintBoundary и GlobalKey.

Готовые решения
Прежде чем перейду к своей реализации коротко расскажу, что на момент написания статьи уже имеется достаточно удобных библиотек для редактирования картинок. Вот несколько популярных примеров:

Как написано в самой библиотеке, все манипуляции с картинкой проводятся не напрямую в коде Dart, а с помощью PlatformChannels, обращаясь к нативным библиотекам.
Сложности, которые могут возникнуть при использовании этой библиотеки, следующие:
1) Ограниченная кастомизация
2) Требуется настройка под платформы
3) Нет поддержки desktop
В связи с этим, если важен только функционал и привычный для пользователей UX без особых требований к дизайну, то этой библиотеки будет достаточно.

Эта библиотека под коркой использует другую библиотеку для манипуляций с изображениями — image.
Другими словами, это обертка, которая уже предоставляет необходимый привычный функционал для работы с изображениями.
Плюс по сравнению с предыдущей библиотекой в том, что поддерживаются все платформы и для них не требуются какие-то специфичные настройки.
Есть такие же ограничения в кастомизации UI.
Поскольку эта библиотека — набор готовых решений, то она также подходит для проектов, где нет требований к дизайну и важно быстро добавить требуемый функционал.
Это библиотека позволяет проводить различные манипуляции с изображениями, не предлагая готовый набор виджетов.
Плюсы:
-
Удобный интерфейс использования
-
Широкий функционал от обычной обрезки изображений до контроля над каждым пикселем
-
Нет ограничений по UI
Минусы, возможно, притянутые за уши:
-
Отдельная зависимость в проект
-
Пересечения в нейминге, например, класс
Imageесть в иdart:ui, и вpackage:image/image.dart, что в отдельных кейсах может немного запутать -
Манипулируемый объект
Imageиз библиотеки нельзя напрямую использовать во flutter, и требуются преобразования в обе стороны (пример)
Это решение может отлично подойти для большинства проектов с четкими требованиями к UI.
Реализация собственного решения
Итак, мы прошлись по имеющимся решениям для данной задачи. Далее мы рассмотрим, какие возможности предоставляет сам flutter без подключения лишних зависимостей.
Сформулируем следующие требования
-
Картинка по url
-
Перемещается прозрачный прямоугольник по всей картинке
-
Мы можем изменять scale этого прямоугольника
-
При нажатии на иконку save открывается экран с обрезанным вариантом картинки
Реализация картинки по URL
Простая реализация отображения картинки по введенному url:
import 'package:flutter/material.dart'; class ImageEditorScreen extends StatelessWidget { const ImageEditorScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Image Editor'), actions: [ IconButton( onPressed: () { // TODO: Save image }, icon: const Icon(Icons.save), ), ], ), body: Center( child: Image.network('https://picsum.photos/id/418/400/700'), ), ); } }
Здесь используется сервис для рандомных картинок-плейсхолдеров по указанным в url размерам https://picsum.photos
Перемещающийся и масштабируемый прямоугольник
Чуть-чуть математики
Для реализации перемещающегося прямоугольника немного вспомним векторную алгебру.
Сначала про единичную, или идентичную матрицу. Эта такая квадратная матрица, в которой элементы главной диагонали равны единице. Главная диагональ — это диагональ от верхнего левого угла до правого нижнего. Можно представить в следующем виде:
Здесь примеры матриц 2х2, 3х3, 4х4 соответственно. Особенность таких матриц в том, что при умножении другой матрицы на единичную не происходит никаких изменений.
Что значит умножить одну матрицу на другую?
Это значит взять 1 элемент 1 строки первой матрицы и умножить на 1 элемент 1 столбца второй, затем, прибавить произведение элемента 1 строки 2 столбца первой матрицы и элемена 2 строки 1 столбца второй матрицы и так далее, пока не пройдем по всем элементам 1 строки первой матрицы. Результат будет значением элемента в первой строке первого стобца результирующей матрицы.
Затем также со 2 строкой первой матрицы. Перемножение с элементами 1 столбца второй матрицы даст значение элемента в 2 строке 1 столбца результирующей матрицы. И так далее по всем строкам первой матрицы
После того, как прошли по всем строкам первой матрицы, переходим ко второму столбцу второй матрицы и по-новому считаем значения для второго столбца результирующей матрицы.
Или для наглядности представим в таком виде:
Где A и B — матрицы, у которых разные размеры;
С — результат умножения матрицы A на матрицу B, или результирующая матрица.
Важные моменты с точки зрения алгебры:
-
Умножение матриц не коммутативно, то есть
AxB != BxA -
Количество столбцов в матрице A должно совпадать с количеством строк в матрице B
-
Результирующая матрица будет иметь столько строк, сколько было в левом операнде (матрице A), и столько столбцов, сколько было в правом операнде (матрице B).
Линейное преобразование и матрица преобразования
Линейное преобразование – это операция, которая изменяет векторы (например, точки или объекты в пространстве) определенным образом. Матрица преобразования — это такая матрица, которая как раз и показывает, каким образом нужно провести преобразование.
Матрица преобразования в контексте dart/flutter
Matrix4( m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44 )
-
Элементы m11, m22, m33 — Это коэффициенты масштабирования по осям X, Y и Z соответственно. Они определяют, как изменяется размер объекта в каждом измерении.
-
Элементы m12, m13, m23, m21, m31, m32 — Это элементы, используемые для поворота и искажения (скоса). Они определяют, как объект будет повернут или искажен в пространстве. Например, поворот вокруг оси Z влияет на элементы m11, m12, m21 и m22.
-
Элементы m14, m24, m34 — Эти элементы часто используются в контексте перспективных преобразований в 3D графике. Они влияют на то, как объекты смещаются или искажаются в перспективе.
-
Элементы m41, m42, m43 — Это компоненты трансляции (перемещения) по осям X, Y и Z соответственно. Они определяют, насколько далеко объект смещается в каждом измерении.
-
Элемент m44 — Обычно устанавливается в 1 и используется для поддержания однородности матрицы. В контексте однородных координат, этот элемент помогает поддерживать корректное взаимодействие между различными типами трансформаций.
Например, матрица перемещения (трансляции) может быть представлена в таком виде:
Matrix4( 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, dx, dy, xz, 1 )
Матрица масштабирования:
Matrix4( scaleX, 0, 0, 0, 0, scaleY, 0, 0, 0, 0, scaleZ, 0, 0, 0, 0, 1 )
Матрица, которая ничего не изменяет (единичная матрица, или Matrix4.identity()):
Matrix4( 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 )
Что это значит для нас?
Красный квадрат из гифки — это, грубо говоря, пиксели красного цвета, расположенные в определенных координатах в пространстве. С помощью линейных преобразований мы можем влиять на координаты этих пикселей, масштабируя, перемещая или вращая их.
Есть виджет Transform, который принимает в качестве аргумента transform объект Matrix4, который является матрицей преобразования. Реализуем виджет, в котором можно изменять transform:
class MovingRectWrapper extends StatefulWidget { const MovingRectWrapper({super.key, required this.child}); final Widget child; @override State<MovingRectWrapper> createState() => MovingRectWrapperState(); } class MovingRectWrapperState extends State<MovingRectWrapper> { Offset _focalPoint = Offset.zero; double _scale = 1.0; Matrix4 _matrix = Matrix4.identity(); final double _width = 100.0; final double _height = 100.0; void _onScaleStart(ScaleStartDetails details) { _focalPoint = details.focalPoint; _scale = 1.0; } void _onScaleUpdate(ScaleUpdateDetails details) { Matrix4 matrix = _matrix; // Translation final newFocalPoint = details.focalPoint; final offsetDelta = (newFocalPoint - _focalPoint); _focalPoint = newFocalPoint; final offsetDeltaMatrix = _getTranslateMatrix(offsetDelta); matrix = offsetDeltaMatrix * matrix; // Scaling final newScale = details.scale; if (newScale != 1.0) { final scaleDelta = newScale / _scale; _scale = newScale; final scaleDeltaMatrix = _getScaleMatrix(scaleDelta); matrix = scaleDeltaMatrix * matrix; } setState(() { _matrix = matrix; }); } void _onReset() { setState(() { _matrix = Matrix4.identity(); }); } @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onDoubleTap: _onReset, onScaleStart: _onScaleStart, onScaleUpdate: _onScaleUpdate, child: Stack( alignment: Alignment.center, children: [ widget.child, Transform( transform: _matrix, child: SizedBox( width: _width, height: _height, child: DecoratedBox( decoration: BoxDecoration( color: Colors.transparent, border: Border.all( color: Colors.red, width: 2, ), ), ), ), ), ], ), ); } } Matrix4 _getTranslateMatrix(Offset offsetDelta) { final dx = offsetDelta.dx; final dy = offsetDelta.dy; return Matrix4( 1, 0, 0, 0, // comments are used for readable formatting 0, 1, 0, 0, // of Matrix4 arguments 0, 0, 1, 0, // dx, dy, 0, 1, // ); } Matrix4 _getScaleMatrix(double scale) { return Matrix4( scale, 0, 0, 0, // 0, scale, 0, 0, // 0, 0, 1, 0, // 0, 0, 0, 1, // ); }
Здесь:
— переменная Matrix4 _matrix — для хранения текущего значения transform;
— переменная Offset _focalPoint — координаты точки соприкосновения с экраном, используются для расчета относительного смещения;
— переменная double _scale — для хранения текущего масштабирования, используется для расчета изменения масштаба;
— метод void _onScaleStart(ScaleStartDetails details) — для того, чтобы при первом прикосновении с экраном задать начальные значения _focalPoint и _scale, относительно которых далее будет рассчитываться матрица преобразования;
— метод void _onScaleUpdate(ScaleUpdateDetails details) — для расчетов результирующей матрицы преобразования;
— метод void _onReset() используется для обнуления transform;
— функция Matrix4 _getTranslateMatrix(Offset offsetDelta) возвращает матрицу трансляции;
— функция Matrix4 _getScaleMatrix(double scaleDelta) возвращает матрицу масштабирования. Здесь сделано так, чтобы масштабирование было равномерно по обеим осям.
Перемещающийся прямоугольник готов, теперь перейдем к реализации финального требования.
Получение обрезанного изображения
Мы достигли того, что можем перемещать прямоугольник по картинке. Мы знаем его начальные размеры и координаты, а также знаем, какие трансформации с ним происходили. Таким образом, мы можем определить его текущие координаты центра и размеры.
Добавим следующие геттеры в стейте виджета MovingRectWrapper:
Size get size => Size( _width * _matrix[0], _height * _matrix[5], ); Offset get center => Offset( _matrix[12] + context.size!.width / 2 - _width / 2 + size.width / 2, _matrix[13] + context.size!.height / 2 - _height / 2 + size.height / 2, );
Небольшие пояснения
Чтобы правильно определить координаты смещенного центра масштабированного квадрата, надо, например, для оси X:
1) взять смещение по оси _matrix[12]
2) прибавить поправку на половину ширины самого виджета context.size!.width / 2, так как в стеке все дочерние виджеты по умолчанию расположены в центе из-за Alignment.center
3) вычесть половину начальной ширины квадрата, чтобы найти положение его крайней левой точки на оси X
4) прибавить половину масштабированной ширины, чтобы найти текущую координату центра
Аналогичные вычисления для координаты центра на оси Y.
Пояснения по аргументам матрицы:
12 элемент матрицы отвечает за смещение по оси X; 13 — по оси Y; 0 элемент (левый верхний на главной диагонали) — это масштабирование по оси X;
5 элемент — это фильм, который пора пересмотреть 🙂
С учетом перемещающегося квадрата пока имеем такую реализацию основного экрана:
class ImageEditorScreen extends StatelessWidget { const ImageEditorScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Image Editor'), actions: [ IconButton( onPressed: () { // TODO: Save image }, icon: const Icon(Icons.save), ), ], ), body: Center( child: MovingRectWrapper( child: Image.network('https://picsum.photos/id/418/400/700'), ), ), ); } }
Реализацию второго экрана выполним через CustomPaint:
class CroppedImageScreen extends StatelessWidget { const CroppedImageScreen({ super.key, required this.image, required this.center, required this.size, required this.widgetSize, }); final ui.Image image; final Offset center; final Size size; final Size widgetSize; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Cropped Image'), ), body: Center( child: CustomPaint( size: size, painter: CroppedImagePainter( image: image, center: center, widgetSize: widgetSize, ), ), ), ); } } class CroppedImagePainter extends CustomPainter { const CroppedImagePainter({ required this.image, required this.center, required this.widgetSize, }); final ui.Image image; final Offset center; final Size widgetSize; @override void paint(Canvas canvas, Size size) { final pixelRatio = image.width / widgetSize.width; final src = Rect.fromCenter( center: Offset( center.dx * pixelRatio, center.dy * pixelRatio, ), width: size.width * pixelRatio, height: size.height * pixelRatio, ); final dst = Rect.fromCenter( center: Offset( size.width / 2, size.height / 2, ), width: size.width, height: size.height, ); canvas.drawImageRect( image, src, dst, Paint(), ); } @override bool shouldRepaint(CroppedImagePainter oldDelegate) => false; }
Что здесь происходит?
На этот экран мы передаем ту же картинку ui.Image image, и вырезаем из нее прямоугольник, центр которого расположен в координатах Offset center и размеры которого Size size. Также передаем размеры самого виджета, из которого вырезается прямоугольник, Size widgetSize. Они нужны для правильного определения размеров прямоугольника в реальных масштабах картинки.
Вырезать прямоугольник из картинки можно с помощью метода drawImageRect() у объекта Canvas в методе paint() у CustomPainter. Действие метода drawImageRect() даже с описанием довольно трудно понять.
Вот как в документации описан принцип его работы:
Draws the subset of the given image described by the
srcargument into the canvas in the axis-aligned rectangle given by thedstargument.
Обычный перевод в гугле дает следующий результат:
Рисует подмножество данного изображения, описанное аргументом
src, на холсте в прямоугольнике, выровненном по оси и заданном аргументомdst.
Спустя какое-то время я пришел к выводу, как он работает, и готов описать здесь.
Во-первых, src , или source, — это часть изображения, которую мы хотим отобразить. Здесь следует указывать реальные размеры картинки, поэтому добавлена поправка pixelRatio, которая показывает отношение реальных размеров изображения к размерам, занимаемым на экране устройства.
Во-вторых, dst , или destination, — это такой прямоугольник, в котором мы хотим нарисовать вырезанную часть. Тут следует указывать размеры, которые должны быть на экране. Координаты нужно указывать с учетом того, что левый верхеий угол прямоугольника будет с координатами (0;0). Следует также отметить, что изображение будет растянуто или сжато, если, например, мы вырезали квадрат и решили расположить его в прямоугольнике.
Примеры, как можно, но не нужно задавать dst
Здесь виджет CustomPaint обернут в Container красного цвета, чтобы было понимание, какие размеры на самом деле имеет виджет.
Таким образом, подходим к основному этапу — как перейти от первого экрана с перемещением квадрата к экрану, где только вырезанная квадратом часть. Для этого воспользуемся инструментами, которые есть во flutter под коркой — GlobalKey и RepaintBoundary.
import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; final _repaintBoundaryKey = GlobalKey(); final _movingRectKey = GlobalKey(); class ImageEditorScreen extends StatelessWidget { const ImageEditorScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Image Editor'), actions: [ IconButton( onPressed: () { const double pixelRatio = 3.0; final RenderRepaintBoundary boundary = _repaintBoundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary; final Size widgetSize = boundary.size; final ui.Image image = boundary.toImageSync(pixelRatio: pixelRatio); final MovingRectWrapperState state = _movingRectKey.currentState as MovingRectWrapperState; final Offset rectCenterOffset = state.center; final Size rectSize = state.size; Navigator.of(context).push( MaterialPageRoute( builder: (context) => CroppedImageScreen( image: image, center: rectCenterOffset, size: rectSize, widgetSize: widgetSize, ), ), ); }, icon: const Icon(Icons.save), ), ], ), body: Center( child: MovingRectWrapper( key: _movingRectKey, child: RepaintBoundary( key: _repaintBoundaryKey, child: Image.network('https://picsum.photos/id/418/400/700'), ), ), ), ); } }
Здесь мы обернули Image.network в RepaintBoundary и добавили GlobalKey. Напомню, что RepaintBoundary это такой виджет, который создает для clild отдельный слой рендеринга. Часто этот виджет используется в контексте улучшения перформанса, чтобы не перестраивать тяжеловесные виджеты при наличии каких-либо анимаций внутри либо снаружи. В нашем случае мы воспользуемся тем его свойством, что с помощью глобального ключа можно получить RenderObject, а из него сам отрендеренный виджет в виде изображения.
Также с помощью глобального ключа, добавленному в MovingRectWrapper, можно получить его текущее состояние, а, следовательно, обратиться к указанным ранее публичным геттерам Size get size и Offset get center.
Итоговый код
import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; final _repaintBoundaryKey = GlobalKey(); final _movingRectKey = GlobalKey(); class ImageEditorScreen extends StatelessWidget { const ImageEditorScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Image Editor'), actions: [ IconButton( onPressed: () { const double pixelRatio = 3.0; final RenderRepaintBoundary boundary = _repaintBoundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary; final Size widgetSize = boundary.size; final ui.Image image = boundary.toImageSync(pixelRatio: pixelRatio); final MovingRectWrapperState state = _movingRectKey.currentState as MovingRectWrapperState; final Offset rectCenterOffset = state.center; final Size rectSize = state.size; Navigator.of(context).push( MaterialPageRoute( builder: (context) => CroppedImageScreen( image: image, center: rectCenterOffset, size: rectSize, widgetSize: widgetSize, ), ), ); }, icon: const Icon(Icons.save), ), ], ), body: Center( child: MovingRectWrapper( key: _movingRectKey, child: RepaintBoundary( key: _repaintBoundaryKey, child: Image.network('https://picsum.photos/id/418/400/700'), ), ), ), ); } } class MovingRectWrapper extends StatefulWidget { const MovingRectWrapper({super.key, required this.child}); final Widget child; @override State<MovingRectWrapper> createState() => MovingRectWrapperState(); } class MovingRectWrapperState extends State<MovingRectWrapper> { Offset _focalPoint = Offset.zero; double _scale = 1.0; Matrix4 _matrix = Matrix4.identity(); final double _width = 100.0; final double _height = 100.0; Offset get center => Offset( _matrix[12] + context.size!.width / 2 - _width / 2 + size.width / 2, _matrix[13] + context.size!.height / 2 - _height / 2 + size.height / 2, ); Size get size => Size( _width * _matrix[0], _height * _matrix[5], ); void _onReset() { setState(() { _matrix = Matrix4.identity(); _scale = 1.0; }); } void _onScaleStart(ScaleStartDetails details) { _focalPoint = details.focalPoint; _scale = 1.0; } void _onScaleUpdate(ScaleUpdateDetails details) { Matrix4 matrix = _matrix; final newFocalPoint = details.focalPoint; final offsetDelta = (newFocalPoint - _focalPoint); _focalPoint = newFocalPoint; final offsetDeltaMatrix = _getTranslateMatrix(offsetDelta); matrix = offsetDeltaMatrix * matrix; final newScale = details.scale; if (newScale != 1.0) { final scaleDelta = newScale / _scale; _scale = newScale; final scaleDeltaMatrix = _getScaleMatrix(scaleDelta); matrix = scaleDeltaMatrix * matrix; } setState(() { _matrix = matrix; }); } @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onDoubleTap: _onReset, onScaleStart: _onScaleStart, onScaleUpdate: _onScaleUpdate, child: Stack( alignment: Alignment.center, children: [ widget.child, Transform( transform: _matrix, child: SizedBox( width: _width, height: _height, child: DecoratedBox( decoration: BoxDecoration( color: Colors.transparent, border: Border.all( color: Colors.red, width: 2, ), ), ), ), ), ], ), ); } } Matrix4 _getTranslateMatrix(Offset offsetDelta) { final dx = offsetDelta.dx; final dy = offsetDelta.dy; return Matrix4( 1, 0, 0, 0, // comments are used for readable formatting 0, 1, 0, 0, // of Matrix4 arguments 0, 0, 1, 0, // dx, dy, 0, 1, // ); } Matrix4 _getScaleMatrix(double scale) { return Matrix4( scale, 0, 0, 0, // 0, scale, 0, 0, // 0, 0, 1, 0, // 0, 0, 0, 1, // ); } class CroppedImageScreen extends StatelessWidget { const CroppedImageScreen({ super.key, required this.image, required this.center, required this.size, required this.widgetSize, }); final ui.Image image; final Offset center; final Size size; final Size widgetSize; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Cropped Image'), ), body: Center( child: CustomPaint( size: size, painter: CroppedImagePainter( image: image, center: center, widgetSize: widgetSize, ), ), ), ); } } class CroppedImagePainter extends CustomPainter { const CroppedImagePainter({ required this.image, required this.center, required this.widgetSize, }); final ui.Image image; final Offset center; final Size widgetSize; @override void paint(Canvas canvas, Size size) { final pixelRatio = image.width / widgetSize.width; final src = Rect.fromCenter( center: Offset( center.dx * pixelRatio, center.dy * pixelRatio, ), width: size.width * pixelRatio, height: size.height * pixelRatio, ); final dst = Rect.fromCenter( center: Offset( size.width / 2, size.height / 2, ), width: size.width, height: size.height, ); canvas.drawImageRect( image, src, dst, Paint(), ); } @override bool shouldRepaint(CroppedImagePainter oldDelegate) => false; }
Итоги
Мы рассмотрели несколько библиотек для редактирования изображений. Их на самом деле гораздо больше, и можно найти подходящий для вашего проекта вариант на pub.dev.
В этой статье показан один из возможных сценариев того, как можно реализовать свой собственный редактор изображений с помощью возможностей flutter, а именно через GestureDetector, CustomPainter, RepaintBoundary и GlobalKey. Я надеюсь, что данная статья послужит отправной точкой или даст идеи для других похожих кейсов.
ссылка на оригинал статьи https://habr.com/ru/articles/790608/
Добавить комментарий