Как реализовать обрезку изображений во flutter без сторонних библиотек

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

79290c2766db28c3be56c16207393022.gif

Готовые решения

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

3ac5be1c5d7da89dda1500ab24e5d7e3.gif

image_cropper

Как написано в самой библиотеке, все манипуляции с картинкой проводятся не напрямую в коде Dart, а с помощью PlatformChannels, обращаясь к нативным библиотекам.

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

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

9a5675271d0e9fa354ffaa6d44285eef.png

crop_image

Эта библиотека под коркой использует другую библиотеку для манипуляций с изображениями — image.

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

Плюс по сравнению с предыдущей библиотекой в том, что поддерживаются все платформы и для них не требуются какие-то специфичные настройки.

Есть такие же ограничения в кастомизации UI.

Поскольку эта библиотека — набор готовых решений, то она также подходит для проектов, где нет требований к дизайну и важно быстро добавить требуемый функционал.

Image

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

Плюсы:
1) Удобный интерфейс использования
2) Широкий функционал от обычной обрезки изображений до контроля над каждым пикселем
3) Нет ограничений по UI

Минусы, возможно, притянутые за уши:
1) Отдельная зависимость в проект
2) Пересечения в нейминге, например, класс Image есть в и dart:ui, и в package:image/image.dart, что в отдельных кейсах может немного запутать
3) Манипулируемый объект Image из библиотеки нельзя напрямую использовать во flutter, и требуются преобразования в обе стороны (пример)

Это решение может отлично подойти для большинства проектов с четкими требованиями к UI.

Реализация собственного решения

Итак, мы прошлись по имеющимся решениям для данной задачи. Далее мы рассмотрим, какие возможности предоставляет сам flutter без подключения лишних зависимостей.

Сформулируем следующие требования

  1. Картинка по url

  2. Перемещается прозрачный прямоугольник по всей картинке

  3. Мы можем изменять scale этого прямоугольника

  4. При нажатии на иконку 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

Перемещающийся и масштабируемый прямоугольник

Результат реализации второго и третьего требований

Результат реализации второго и третьего требований

Чуть-чуть математики

Для реализации перемещающегося прямоугольника немного вспомним векторную алгебру.

Сначала про единичную, или идентичную матрицу. Эта такая квадратная матрица, в которой элементы главной диагонали равны единице. Главная диагональ — это диагональ от верхнего левого угла до правого нижнего. Можно представить в следующем виде:

\begin{bmatrix} 1 & 0 \\ 0 & 1 \\ \end{bmatrix}; \begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \\ \end{bmatrix}; \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix}

Здесь примеры матриц 2×2, 3×3, 4×4 соответственно. Особенность таких матриц в том, что при умножении другой матрицы на единичную не происходит никаких изменений.

Что значит умножить одну матрицу на другую?

Это значит взять 1 элемент 1 строки первой матрицы и умножить на 1 элемент 1 столбца второй, затем, прибавить произведение элемента 1 строки 2 столбца первой матрицы и элемена 2 строки 1 столбца второй матрицы и так далее, пока не пройдем по всем элементам 1 строки первой матрицы. Результат будет значением элемента в первой строке первого стобца результирующей матрицы.
Затем также со 2 строкой первой матрицы. Перемножение с элементами 1 столбца второй матрицы даст значение элемента в 2 строке 1 столбца результирующей матрицы. И так далее по всем строкам первой матрицы
После того, как прошли по всем строкам первой матрицы, переходим ко второму столбцу второй матрицы и по-новому считаем значения для второго столбца результирующей матрицы.
Или для наглядности представим в таком виде:

A = \begin{bmatrix} a_{11} & a_{12} & a_{13} \\ a_{21} & a_{22} & a_{23} \end{bmatrix}; B = \begin{bmatrix} b_{11} & b_{12} \\ b_{21} & b_{22} \\ b_{31} & b_{32} \end{bmatrix}C = \begin{bmatrix}     a_{11}b_{11} + a_{12}b_{21} + a_{13}b_{31} & a_{11}b_{12} + a_{12}b_{22} + a_{13}b_{32} \\     a_{21}b_{11} + a_{22}b_{21} + a_{23}b_{31} & a_{21}b_{12} + a_{22}b_{22} + a_{23}b_{32} \end{bmatrix}

Где A и B — матрицы, у которых разные размеры;
С — результат умножения матрицы A на матрицу B, или результирующая матрица.

Важные моменты с точки зрения алгебры:
1) Умножение матриц не коммутативно, то есть AxB != BxA
2) Количество столбцов в матрице A должно совпадать с количеством строк в матрице B
3) Результирующая матрица будет иметь столько строк, сколько было в левом операнде (матрице A), и столько столбцов, сколько было в правом операнде (матрице B).

Линейное преобразование и матрица преобразования

Линейное преобразование — это операция, которая изменяет векторы (например, точки или объекты в пространстве) определенным образом. Матрица преобразования — это такая матрица, которая как раз и показывает, каким образом нужно провести преобразование.

Матрица преобразования в контексте dart/flutter

Matrix4(
  m11, m12, m13, m14,
  m21, m22, m23, m24,
  m31, m32, m33, m34,
  m41, m42, m43, m44
)
  1. Элементы m11, m22, m33 — Это коэффициенты масштабирования по осям X, Y и Z соответственно. Они определяют, как изменяется размер объекта в каждом измерении.

  2. Элементы m12, m13, m23, m21, m31, m32 — Это элементы, используемые для поворота и искажения (скоса). Они определяют, как объект будет повернут или искажен в пространстве. Например, поворот вокруг оси Z влияет на элементы m11, m12, m21 и m22.

  3. Элементы m14, m24, m34 — Эти элементы часто используются в контексте перспективных преобразований в 3D графике. Они влияют на то, как объекты смещаются или искажаются в перспективе.

  4. Элементы m41, m42, m43 — Это компоненты трансляции (перемещения) по осям X, Y и Z соответственно. Они определяют, насколько далеко объект смещается в каждом измерении.

  5. Элемент 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 createState() => MovingRectWrapperState();
}

class MovingRectWrapperState extends State {
  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 src argument into the canvas in the axis-aligned rectangle given by the dst argument.

Обычный перевод в гугле дает следующий результат:

Рисует подмножество данного изображения, описанное аргументом src, на холсте в прямоугольнике, выровненном по оси и заданном аргументом dst.

Спустя какое-то время я пришел к выводу, как он работает, и готов описать здесь.

Во-первых, src, или source, — это часть изображения, которую мы хотим отобразить. Здесь следует указывать реальные размеры картинки, поэтому добавлена поправка pixelRatio, которая показывает отношение реальных размеров изображения к размерам, занимаемым на экране устройства.

Во-вторых, dst, или destination, — это такой прямоугольник, в котором мы хотим нарисовать вырезанную часть. Тут следует указывать размеры, которые должны быть на экране. Координаты нужно указывать с учетом того, что левый верхеий угол прямоугольника будет с координатами (0;0). Следует также отметить, что изображение будет растянуто или сжато, если, например, мы вырезали квадрат и решили расположить его в прямоугольнике.

Примеры, как можно, но не нужно задавать dst

Здесь виджет CustomPaint обернут в Container красного цвета, чтобы было понимание, какие размеры на самом деле имеет виджет.

dst более узкий по горизонтали

dst более узкий по горизонтали

dst более широкий по горизонтали

dst более широкий по горизонтали

dst не попал координатами куда надо

dst не попал координатами куда надо

Таким образом, подходим к основному этапу — как перейти от первого экрана с перемещением квадрата к экрану, где только вырезанная квадратом часть. Для этого воспользуемся инструментами, которые есть во 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 createState() => MovingRectWrapperState();
}

class MovingRectWrapperState extends State {
  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. Я надеюсь, что данная статья послужит отправной точкой или даст идеи для других похожих кейсов.

© Habrahabr.ru